<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Self-hosting the Google Fonts.
 *
 * Instead of loading fonts from Google's domain on the front end — render-blocking, and in the EU
 * a GDPR problem, because the visitor's IP ends up at Google — we download the .woff2 files once
 * and serve them from our own domain (uploads/aipb-fonts/), rewriting Google's @font-face CSS to
 * point at the local files. The result is cached in an option and regenerated only when the font
 * list changes (the key is a hash of the css2 URL).
 *
 * If generation fails (no network, and so on) we return an empty string and the caller falls back
 * to the <link> to Google: the fonts keep working, they are just not self-hosted.
 */
class AIPB_Fonts {

	/** array( 'key' => md5(url), 'css' => string ) */
	const OPT_CACHE = 'aipb_fonts_cache';

	/** The subfolder inside wp-content/uploads. */
	const SUBDIR = 'aipb-fonts';

	/** A "modern" UA, so Google hands back woff2 — old ones would get ttf. */
	const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';

	public static function init() {
		// Rebuild the self-hosted cache when the brand's font list changes (on the admin side,
		// where there is network): that way the first visitor does not pay for the generation.
		add_action( 'update_option_' . AIPB_Design::OPT_FONTS, array( __CLASS__, 'warm' ), 10, 0 );
		add_action( 'add_option_' . AIPB_Design::OPT_FONTS, array( __CLASS__, 'warm' ), 10, 0 );
	}

	/** Is self-hosting on? Filterable, to switch it off. */
	public static function enabled() {
		return (bool) apply_filters( 'aipb_self_host_fonts', true );
	}

	/** Rebuilds the self-hosted CSS, best effort. The warm-up hook when fonts are saved. */
	public static function warm() {
		delete_option( self::OPT_CACHE );

		if ( self::enabled() ) {
			self::local_css();
		}
	}

	/**
	 * Self-hosted @font-face CSS for the brand's fonts, or '' when it is unavailable.
	 *
	 * Cached per URL: it regenerates only when the font list changes.
	 */
	public static function local_css() {
		$url = AIPB_Design::google_fonts_url();
		if ( '' === $url ) {
			return '';
		}

		$key   = md5( $url );
		$cache = get_option( self::OPT_CACHE );

		if ( is_array( $cache ) && isset( $cache['key'], $cache['css'] ) && $cache['key'] === $key && '' !== $cache['css'] ) {
			return $cache['css'];
		}

		$css = self::generate( $url );
		if ( '' !== $css ) {
			update_option( self::OPT_CACHE, array( 'key' => $key, 'css' => $css ), false );
		}

		return $css;
	}

	/**
	 * Is self-hosting actually happening, and what does it weigh?
	 *
	 * WHY IT IS WORTH A PANEL. Self-hosting is a best-effort: {@see generate()} returns '' at the
	 * first failed download and the front end quietly falls back to the <link> to Google — which is
	 * exactly the request the feature exists to remove, and the fallback is invisible from the
	 * admin. So a site can believe for months that no visitor's IP reaches Google while every one of
	 * them does. This says which of the two is true, today.
	 *
	 * It reads the cache and the folder, never the network: opening this page must not download
	 * anything.
	 *
	 * @return array{on:bool,local:bool,files:int,bytes:int,families:int,weights:int}
	 */
	public static function status() {
		$fonts   = AIPB_Design::fonts();
		$weights = 0;
		$seen    = array();

		foreach ( $fonts as $font ) {
			$seen[ $font['family'] ] = true;
			$weights += count( $font['weights'] );
		}

		$cache = get_option( self::OPT_CACHE );
		$local = self::enabled() && is_array( $cache ) && ! empty( $cache['css'] ) && isset( $cache['key'] )
			&& $cache['key'] === md5( AIPB_Design::google_fonts_url() );

		$files = 0;
		$bytes = 0;

		if ( $local ) {
			$uploads = wp_upload_dir();
			$dir     = empty( $uploads['error'] ) ? trailingslashit( $uploads['basedir'] ) . self::SUBDIR : '';

			// Only the files this stylesheet actually names: the folder also holds the leftovers of
			// fonts the brand used to have, and counting those would overstate the weight.
			if ( $dir && preg_match_all( '#/([0-9a-f]{32}\.woff2)#i', (string) $cache['css'], $found ) ) {
				foreach ( array_unique( $found[1] ) as $name ) {
					$path = trailingslashit( $dir ) . $name;
					if ( file_exists( $path ) ) {
						$files++;
						$bytes += (int) filesize( $path );
					}
				}
			}
		}

		return array(
			'on'       => self::enabled(),
			'local'    => $local,
			'files'    => $files,
			'bytes'    => $bytes,
			'families' => count( $seen ),
			'weights'  => $weights,
		);
	}

	/** A <style> block ready for the front end, or '' when self-hosting is unavailable. */
	public static function frontend_style() {
		if ( ! self::enabled() ) {
			return '';
		}

		$css = self::local_css();

		return '' === $css ? '' : '<style id="aipb-fonts">' . $css . '</style>';
	}

	/**
	 * Downloads the css2 sheet, saves the .woff2 files locally and returns the rewritten CSS.
	 *
	 * Returns '' at the first hitch, so the caller falls back to the <link> to Google.
	 */
	protected static function generate( $url ) {
		$css = self::fetch( $url );
		if ( '' === $css ) {
			return '';
		}

		$uploads = wp_upload_dir();
		if ( ! empty( $uploads['error'] ) ) {
			return '';
		}

		$dir = trailingslashit( $uploads['basedir'] ) . self::SUBDIR;
		if ( ! wp_mkdir_p( $dir ) ) {
			return '';
		}

		$base   = trailingslashit( $uploads['baseurl'] ) . self::SUBDIR;
		$failed = false;

		$out = preg_replace_callback(
			'#url\((https://fonts\.gstatic\.com/[^)\'"]+?\.woff2)\)#i',
			function ( $m ) use ( $dir, $base, &$failed ) {
				if ( $failed ) {
					return $m[0];
				}

				$name = md5( $m[1] ) . '.woff2';
				$path = trailingslashit( $dir ) . $name;

				if ( ! file_exists( $path ) && ! self::download( $m[1], $path ) ) {
					$failed = true;

					return $m[0];
				}

				return 'url(' . trailingslashit( $base ) . $name . ')';
			},
			$css
		);

		// If even one download failed we do not serve half a stylesheet (part local, part Google):
		// better to fall back to the <link> entirely.
		return $failed ? '' : (string) $out;
	}

	/** The css2 stylesheet, or '' when it cannot be fetched. */
	protected static function fetch( $url ) {
		$response = wp_remote_get( $url, array(
			'timeout' => 15,
			'headers' => array( 'User-Agent' => self::UA ),
		) );

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return '';
		}

		return (string) wp_remote_retrieve_body( $response );
	}

	/** One font file, saved locally. False at the first problem. */
	protected static function download( $remote, $path ) {
		$response = wp_remote_get( $remote, array(
			'timeout' => 15,
			'headers' => array( 'User-Agent' => self::UA ),
		) );

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return false;
		}

		$body = wp_remote_retrieve_body( $response );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		return '' !== $body && false !== file_put_contents( $path, $body );
	}
}
