<?php
/**
 * BaseCloud Boost — Self-Host Google Fonts
 *
 * Render-blocking requests to fonts.googleapis.com (CSS) and fonts.gstatic.com
 * (font files) cost two extra DNS lookups + TLS handshakes on the critical path
 * and are flagged by PageSpeed Insights ("reduce render-blocking", "third-party").
 *
 * This optimizer downloads the Google Fonts stylesheet and its woff2 files to the
 * local cache once, then replaces the remote <link rel="stylesheet"> with the
 * localised @font-face CSS inlined directly in the page — so fonts load from the
 * site's own origin with no third-party round-trips and no extra render-blocking
 * request. Results are cached to disk and reused on every subsequent page, so the
 * one-time download happens on a single cache-priming request, not per visitor.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Downloads and locally serves Google Fonts.
 */
class Self_Host_Fonts {

	/** @var string Directory for localised font CSS + woff2 files. */
	private string $cache_dir;

	/** @var string Public URL for the localised font directory. */
	private string $cache_url;

	/** @var int Hard cap on font files downloaded per stylesheet (safety). */
	private const MAX_FILES = 30;

	public function __construct() {
		$this->cache_dir = BCBOOST_MINIFY_DIR . 'fonts/';
		$this->cache_url = content_url( '/cache/basecloud-boost-min/fonts/' );
	}

	/**
	 * Boot the optimizer.
	 *
	 * @return void
	 */
	public function boot(): void {
		if ( ! BCBOOST_get_option( 'self_host_fonts', false ) ) {
			return;
		}

		// Run before Google_Fonts (priority 10) so the remote <link> is replaced
		// with local CSS first; nothing googleapis-related remains for it to touch.
		add_filter( 'BCBOOST_buffer', [ $this, 'process' ], 8 );

		// Fonts now load from our own origin, so the Google Fonts preconnect /
		// dns-prefetch hints are dead weight — drop them.
		add_filter( 'BCBOOST_preconnect_origins', static function ( array $origins ): array {
			foreach ( array_keys( $origins ) as $origin ) {
				if ( false !== strpos( (string) $origin, 'fonts.googleapis.com' )
					|| false !== strpos( (string) $origin, 'fonts.gstatic.com' ) ) {
					unset( $origins[ $origin ] );
				}
			}
			return $origins;
		} );
	}

	/**
	 * Replace every Google Fonts <link rel="stylesheet"> with inlined local CSS,
	 * then preload the first localised woff2 files so the (above-the-fold) text
	 * fonts start downloading immediately instead of after CSS parse.
	 *
	 * @param string $html Full page HTML.
	 * @return string
	 */
	public function process( string $html ): string {
		$localized_css = [];

		$html = preg_replace_callback(
			'#<link\b[^>]*href=(["\'])([^"\']*fonts\.googleapis\.com/css[^"\']*)\1[^>]*>#i',
			function ( array $m ) use ( &$localized_css ): string {
				// Only swap real stylesheet links (not preconnect/preload hints).
				if ( ! preg_match( '/\brel\s*=\s*["\']stylesheet["\']/i', $m[0] ) ) {
					return $m[0];
				}

				$css = $this->localize( html_entity_decode( $m[2] ) );
				if ( null === $css ) {
					return $m[0]; // Download failed — leave the original link intact.
				}

				$localized_css[] = $css;
				return '<style id="bcboost-fonts" data-bcboost="self-hosted">' . $css . '</style>';
			},
			$html
		) ?? $html;

		if ( ! empty( $localized_css ) ) {
			$html = $this->inject_font_preloads( $html, implode( "\n", $localized_css ) );
		}

		return $html;
	}

	/**
	 * Preload the first 1–2 LOCAL woff2 files referenced by the inlined font CSS.
	 *
	 * Google Fonts CSS lists faces family-by-family, so the first files are the
	 * primary text fonts — near-certain to be used above the fold. Preloading
	 * them removes the "CSS parse → font discovery" serial step (a common
	 * PageSpeed "font-display"/late-font flag). Conservative by design: woff2
	 * only, same-origin only (our own cache dir), capped at 2, deduped against
	 * preloads already on the page, and skipped entirely in non-optimizable
	 * (builder/preview) contexts.
	 *
	 * @param string $html Full page HTML (fonts already inlined).
	 * @param string $css  Concatenated localised font CSS.
	 * @return string
	 */
	private function inject_font_preloads( string $html, string $css ): string {
		if ( ! BCBOOST_is_optimizable_request() ) {
			return $html;
		}

		/**
		 * Filter whether self-hosted font files are preloaded (kill switch).
		 *
		 * @param bool $enabled Default true.
		 */
		if ( ! apply_filters( 'BCBOOST_preload_self_hosted_fonts', true ) ) {
			return $html;
		}

		$head_close = stripos( $html, '</head>' );
		if ( false === $head_close ) {
			return $html;
		}

		/**
		 * Filter how many self-hosted woff2 files are preloaded (1–4).
		 *
		 * @param int $count Default 2.
		 */
		$max = min( 4, max( 1, (int) apply_filters( 'BCBOOST_font_preload_count', 2 ) ) );

		if ( ! preg_match_all(
			'#url\(\s*([\'"]?)(' . preg_quote( $this->cache_url, '#' ) . '[^\'")]+\.woff2)\1\s*\)#i',
			$css,
			$m
		) ) {
			return $html;
		}

		$links   = '';
		$emitted = 0;
		$seen    = [];
		foreach ( $m[2] as $font_url ) {
			if ( isset( $seen[ $font_url ] ) ) {
				continue;
			}
			$seen[ $font_url ] = true;

			// Never duplicate a preload already on the page (ours or the user's
			// manual preload_fonts entry rendered by Resource_Hints).
			if ( preg_match( '#<link\b[^>]*rel=["\']preload["\'][^>]*' . preg_quote( $font_url, '#' ) . '#i', $html ) ) {
				continue;
			}

			// Fonts require crossorigin even on the same origin.
			$links .= '<link rel="preload" as="font" type="font/woff2" href="'
				. esc_url( $font_url ) . '" crossorigin data-bcboost="font-preload" />';

			if ( ++$emitted >= $max ) {
				break;
			}
		}

		if ( '' === $links ) {
			return $html;
		}

		return substr_replace( $html, $links, $head_close, 0 );
	}

	// ─── Localisation ──────────────────────────────────────────────────────────

	/**
	 * Return localised @font-face CSS for a Google Fonts URL, downloading and
	 * caching it (CSS + woff2 files) on first encounter.
	 *
	 * @param string $gf_url Google Fonts stylesheet URL.
	 * @return string|null Localised CSS, or null on failure.
	 */
	private function localize( string $gf_url ): ?string {
		$hash      = substr( md5( $gf_url ), 0, 12 );
		$css_file  = $this->cache_dir . 'bcboost-' . $hash . '.css';

		// Serve from cache when available.
		if ( is_file( $css_file ) ) {
			$cached = file_get_contents( $css_file );
			return false !== $cached ? $cached : null;
		}

		// A short-lived lock prevents several concurrent requests all downloading
		// the same font set at once.
		$lock = 'bcboost_fonts_' . $hash;
		if ( false === get_transient( $lock ) ) {
			set_transient( $lock, 1, 2 * MINUTE_IN_SECONDS );
		} else {
			return null; // Another request is already downloading — leave original.
		}

		$css = $this->build_local_css( $gf_url );

		delete_transient( $lock );

		if ( null === $css || '' === trim( $css ) ) {
			return null;
		}

		wp_mkdir_p( $this->cache_dir );
		file_put_contents( $css_file, $css, LOCK_EX );

		return $css;
	}

	/**
	 * Fetch the Google Fonts CSS, download its woff2 files locally, and rewrite
	 * the @font-face src URLs to the local copies.
	 *
	 * @param string $gf_url Google Fonts stylesheet URL.
	 * @return string|null Rewritten CSS, or null on failure.
	 */
	private function build_local_css( string $gf_url ): ?string {
		// Normalise protocol-relative URLs.
		if ( 0 === strpos( $gf_url, '//' ) ) {
			$gf_url = 'https:' . $gf_url;
		}

		// A modern desktop UA makes Google return woff2 (the smallest format).
		$response = wp_remote_get( $gf_url, [
			'timeout'    => 8,
			'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
		] );

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

		$css = (string) wp_remote_retrieve_body( $response );
		if ( '' === trim( $css ) ) {
			return null;
		}

		wp_mkdir_p( $this->cache_dir );

		$downloaded = 0;
		$css = preg_replace_callback(
			'#url\(\s*([\'"]?)(https://fonts\.gstatic\.com/[^\'")]+)\1\s*\)#i',
			function ( array $m ) use ( &$downloaded ): string {
				if ( $downloaded >= self::MAX_FILES ) {
					return $m[0]; // Cap hit — keep remote URL for the rest.
				}

				$remote = $m[2];
				$ext    = pathinfo( wp_parse_url( $remote, PHP_URL_PATH ) ?? '', PATHINFO_EXTENSION );
				$ext    = preg_replace( '/[^a-z0-9]/i', '', $ext ) ?: 'woff2';
				$name   = 'f-' . substr( md5( $remote ), 0, 16 ) . '.' . $ext;
				$path   = $this->cache_dir . $name;

				if ( ! is_file( $path ) ) {
					$file = wp_remote_get( $remote, [ 'timeout' => 8 ] );
					if ( is_wp_error( $file ) || 200 !== (int) wp_remote_retrieve_response_code( $file ) ) {
						return $m[0]; // Keep remote URL if the file can't be fetched.
					}
					$body = wp_remote_retrieve_body( $file );
					if ( '' === $body || file_put_contents( $path, $body, LOCK_EX ) === false ) {
						return $m[0];
					}
				}

				$downloaded++;
				return 'url(' . $this->cache_url . $name . ')';
			},
			$css
		);

		if ( null === $css ) {
			return null;
		}

		// Guarantee font-display: swap on every face so text stays visible (no FOIT).
		$css = preg_replace_callback(
			'/@font-face\s*\{([^}]*)\}/i',
			static function ( array $ff ): string {
				$block = $ff[1];
				if ( stripos( $block, 'font-display' ) !== false ) {
					return $ff[0];
				}
				return '@font-face{' . rtrim( $block ) . ';font-display:swap;}';
			},
			$css
		) ?? $css;

		// Only keep it if we actually localised at least one font file.
		return $downloaded > 0 ? trim( $css ) : null;
	}
}
