<?php
/**
 * Google Font Localization
 *
 * Detects Google Fonts loaded via fonts.googleapis.com and provides
 * the option to serve them locally for China compatibility.
 *
 * @package SpeedInChina
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class SPEEINCH_Google_Fonts {

	/**
	 * Lazily bootstrap WP_Filesystem and return the global instance.
	 *
	 * We standardize on WP_Filesystem (uploads dir reads/writes/deletes) so PCP
	 * and PHPCS never need a `phpcs:ignore WordPress.WP.AlternativeFunctions.*`
	 * marker on this class, and so the next plugin-check release (which
	 * tightened detection on file_put_contents / unlink in Feb 2026) cannot
	 * surface a fresh warning on these call sites.
	 *
	 * @return WP_Filesystem_Base|null Filesystem instance, or null if WP could
	 *                                 not initialize it (rare; e.g. credentials
	 *                                 required and not supplied).
	 */
	private static function fs() {
		global $wp_filesystem;
		if ( $wp_filesystem instanceof WP_Filesystem_Base ) {
			return $wp_filesystem;
		}
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		return ( $wp_filesystem instanceof WP_Filesystem_Base ) ? $wp_filesystem : null;
	}

	/**
	 * Google Fonts host for stylesheet (CSS) requests.
	 */
	const GOOGLE_FONTS_API_HOST = 'fonts.googleapis.com';

	/**
	 * Google Fonts host for font binary (woff2/woff/ttf) requests.
	 */
	const GOOGLE_FONTS_CDN_HOST = 'fonts.gstatic.com';

	/**
	 * Local cache directory (relative to wp-content/uploads/).
	 */
	const CACHE_DIR = 'speeinch-fonts';

	/**
	 * Option key for the toggle.
	 */
	const OPTION_ENABLED = 'speeinch_localize_google_fonts';

	/**
	 * Whitelist of file extensions we are willing to persist into the cache.
	 * Anything outside this list is coerced to 'woff2'.
	 */
	const ALLOWED_FONT_EXTENSIONS = array( 'woff2', 'woff', 'ttf', 'otf', 'eot' );

	/**
	 * Initialize hooks.
	 */
	public static function init() {
		if ( get_option( self::OPTION_ENABLED, false ) ) {
			// Run late so all styles are enqueued
			add_action( 'wp_enqueue_scripts', array( __CLASS__, 'replace_google_fonts' ), 9999 );
			add_action( 'admin_enqueue_scripts', array( __CLASS__, 'replace_google_fonts' ), 9999 );
		}
	}

	/**
	 * Strict allow-list check for outbound requests to Google's font hosts.
	 *
	 * Substring matches are unsafe — `https://attacker.example/?x=fonts.googleapis.com`
	 * satisfies a `strpos()` check but routes to an attacker's host. This helper
	 * requires an exact host match, HTTPS scheme, and refuses URLs with embedded
	 * userinfo (which could otherwise be parsed inconsistently by HTTP clients).
	 *
	 * @param string $url           URL to validate.
	 * @param string $expected_host self::GOOGLE_FONTS_API_HOST or self::GOOGLE_FONTS_CDN_HOST.
	 * @return bool True if the URL is safe to fetch.
	 */
	public static function is_allowed_google_host( $url, $expected_host ) {
		if ( ! is_string( $url ) || '' === $url ) {
			return false;
		}
		$parsed = wp_parse_url( $url );
		if ( ! is_array( $parsed ) ) {
			return false;
		}
		if ( ! isset( $parsed['scheme'] ) || 'https' !== strtolower( $parsed['scheme'] ) ) {
			return false;
		}
		if ( isset( $parsed['user'] ) || isset( $parsed['pass'] ) ) {
			return false;
		}
		if ( ! isset( $parsed['host'] ) ) {
			return false;
		}
		return strtolower( $parsed['host'] ) === $expected_host;
	}

	/**
	 * Scan all enqueued styles for Google Fonts and replace with local copies.
	 *
	 * On the public front-end this only SWAPS to already-cached URLs — it never
	 * fetches Google during a public page render. Cache warming runs from the
	 * admin AJAX toggle handler. The intent is to avoid making
	 * `fonts.googleapis.com` requests during `wp_enqueue_scripts` on uncached
	 * site visits (a reviewer-flagged performance pattern).
	 */
	public static function replace_google_fonts() {
		global $wp_styles;

		if ( empty( $wp_styles->registered ) ) {
			return;
		}

		$allow_download = is_admin();

		foreach ( $wp_styles->registered as $handle => $style ) {
			if ( empty( $style->src ) ) {
				continue;
			}

			if ( ! self::is_allowed_google_host( $style->src, self::GOOGLE_FONTS_API_HOST ) ) {
				continue;
			}

			$local_url = self::get_local_font_url( $style->src, $allow_download );
			if ( $local_url ) {
				$wp_styles->registered[ $handle ]->src = $local_url;
				speeinch_log( "Localized Google Font: {$handle} → {$local_url}" );
			}
		}
	}

	/**
	 * Detect all Google Font stylesheets loaded on the site.
	 *
	 * @return array List of detected Google Font stylesheets with handle, URL, and families.
	 */
	public static function detect_google_fonts() {
		global $wp_styles;
		$detected = array();

		if ( empty( $wp_styles ) ) {
			// Force styles to be processed
			wp_styles();
			global $wp_styles;
		}

		if ( empty( $wp_styles->registered ) ) {
			return $detected;
		}

		foreach ( $wp_styles->registered as $handle => $style ) {
			if ( empty( $style->src ) ) {
				continue;
			}

			if ( ! self::is_allowed_google_host( $style->src, self::GOOGLE_FONTS_API_HOST ) ) {
				continue;
			}

			$families   = self::parse_font_families( $style->src );
			$detected[] = array(
				'handle'   => $handle,
				'url'      => $style->src,
				'families' => $families,
				'deps'     => $style->deps,
			);
		}

		return $detected;
	}

	/**
	 * Parse font family names from a Google Fonts CSS URL.
	 *
	 * @param string $url Google Fonts CSS URL.
	 * @return array Font family names.
	 */
	public static function parse_font_families( $url ) {
		$families = array();
		$parsed   = wp_parse_url( $url );

		if ( empty( $parsed['query'] ) ) {
			return $families;
		}

		// Google Fonts URLs repeat the `family` key for multi-family requests:
		// ?family=Roboto:wght@400;700&family=Open+Sans
		// `parse_str()` / `wp_parse_str()` collapse repeated keys (last value
		// wins, dropping all but the trailing family). We split the raw query
		// string on `&` instead so every `family=…` segment is captured.
		$segments = explode( '&', $parsed['query'] );
		foreach ( $segments as $segment ) {
			if ( 0 !== strpos( $segment, 'family=' ) ) {
				continue;
			}
			$value      = urldecode( substr( $segment, strlen( 'family=' ) ) );
			$name       = explode( ':', $value )[0];
			$families[] = str_replace( '+', ' ', $name );
		}

		return array_values( array_unique( $families ) );
	}

	/**
	 * Download Google Font CSS and referenced font files, return local URL.
	 *
	 * Defense-in-depth:
	 *  - Strict host allow-list (no substring/redirect bypass).
	 *  - `redirection => 0` so a 30x can't reroute the request.
	 *  - Whitelisted font filename extensions (no path-traversal-friendly extensions).
	 *
	 * @param string $google_url     Original Google Fonts CSS URL.
	 * @param bool   $allow_download Whether this call is allowed to hit the network
	 *                               on a cache miss. Front-end calls pass `false` so
	 *                               public page loads never block on a remote fetch.
	 * @return string|false Local URL on success, false on failure (or unwarmed cache).
	 */
	public static function get_local_font_url( $google_url, $allow_download = true ) {
		if ( ! self::is_allowed_google_host( $google_url, self::GOOGLE_FONTS_API_HOST ) ) {
			return false;
		}

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return false;
		}
		$cache_dir = $upload_dir['basedir'] . '/' . self::CACHE_DIR;
		$cache_url = $upload_dir['baseurl'] . '/' . self::CACHE_DIR;
		$hash      = md5( $google_url );
		$css_file  = $cache_dir . '/' . $hash . '.css';
		$css_url   = $cache_url . '/' . $hash . '.css';

		// Return cached version if it exists and is less than 30 days old
		if ( file_exists( $css_file ) && ( time() - filemtime( $css_file ) ) < 30 * DAY_IN_SECONDS ) {
			return $css_url;
		}

		if ( ! $allow_download ) {
			return false;
		}

		// Create cache directory
		if ( ! wp_mkdir_p( $cache_dir ) ) {
			speeinch_log( 'Failed to create font cache directory: ' . $cache_dir );
			return false;
		}

		// Google's CSS API serves a different `url(...)` font format per the
		// requesting User-Agent: modern browsers receive woff2 (the smallest
		// and best-supported format on every WP-compatible browser), while
		// non-browser UAs (including the default WordPress one) receive the
		// much larger legacy TTF. We send a modern Chrome UA solely to opt in
		// to the woff2 variant — no other behaviour depends on the UA, and we
		// never relay it back to visitors.
		$response = wp_remote_get(
			$google_url,
			array(
				'timeout'     => 30,
				'redirection' => 0,
				'user-agent'  => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
			)
		);

		if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
			speeinch_log( 'Failed to download Google Font CSS: ' . $google_url );
			return false;
		}

		$css = wp_remote_retrieve_body( $response );

		// Download all font files referenced in the CSS
		$css = preg_replace_callback(
			'/url\(([^)]+)\)/',
			function ( $matches ) use ( $cache_dir, $cache_url ) {
				$font_url = trim( $matches[1], '\'" ' );

				// Strict host allow-list (was substring match — bypassable).
				if ( ! self::is_allowed_google_host( $font_url, self::GOOGLE_FONTS_CDN_HOST ) ) {
					return $matches[0];
				}

				// Whitelist filename extension; default to woff2 for anything else.
				$ext = strtolower(
					(string) pathinfo( (string) wp_parse_url( $font_url, PHP_URL_PATH ), PATHINFO_EXTENSION )
				);
				if ( ! in_array( $ext, self::ALLOWED_FONT_EXTENSIONS, true ) ) {
					$ext = 'woff2';
				}

				$font_hash      = md5( $font_url );
				$font_file      = $cache_dir . '/' . $font_hash . '.' . $ext;
				$font_local_url = $cache_url . '/' . $font_hash . '.' . $ext;

				if ( ! file_exists( $font_file ) ) {
					$font_response = wp_remote_get(
						$font_url,
						array(
							'timeout'     => 30,
							'redirection' => 0,
						)
					);
					if ( ! is_wp_error( $font_response ) && wp_remote_retrieve_response_code( $font_response ) === 200 ) {
						$fs = self::fs();
						if ( null === $fs || ! $fs->put_contents( $font_file, wp_remote_retrieve_body( $font_response ), FS_CHMOD_FILE ) ) {
							return $matches[0]; // Keep original URL if write fails.
						}
					} else {
						return $matches[0]; // Keep original URL if download fails
					}
				}

				return 'url(' . $font_local_url . ')';
			},
			$css
		);

		$fs = self::fs();
		if ( null === $fs || ! $fs->put_contents( $css_file, $css, FS_CHMOD_FILE ) ) {
			return false;
		}

		return $css_url;
	}

	/**
	 * Clear the local font cache.
	 *
	 * @return bool Whether the cache was cleared successfully.
	 */
	public static function clear_cache() {
		$upload_dir = wp_upload_dir();
		$cache_dir  = $upload_dir['basedir'] . '/' . self::CACHE_DIR;

		if ( ! is_dir( $cache_dir ) ) {
			return true;
		}

		$fs = self::fs();
		if ( null === $fs ) {
			return false;
		}

		$entries = $fs->dirlist( $cache_dir );
		if ( ! is_array( $entries ) ) {
			return true;
		}
		foreach ( $entries as $name => $info ) {
			if ( isset( $info['type'] ) && 'f' === $info['type'] ) {
				$fs->delete( trailingslashit( $cache_dir ) . $name );
			}
		}

		return true;
	}
}
