<?php
/**
 * Cache module — Local Google Fonts
 *
 * Replaces remote <link href="https://fonts.googleapis.com/...">
 * declarations with a self-hosted CSS file pointing to locally-stored .woff2
 * files. Saves a TLS round-trip + cuts the privacy/GDPR exposure that comes
 * from leaking visitor IPs to Google.
 *
 * Pipeline (per remote URL, executed once and cached):
 *   1. Fetch the Google CSS with a modern Chrome UA (so Google returns woff2)
 *   2. Parse url() declarations pointing to fonts.gstatic.com
 *   3. Download each .woff2 to uploads/blaminhor-essentials/google-fonts/
 *   4. Rewrite the CSS to point at the local files
 *   5. Save the CSS locally and remember the mapping in an option
 * On subsequent renders, the <link> is rewritten in-buffer without any
 * network call. If anything fails along the way, the original Google link
 * is left untouched — failure is opaque to the visitor.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Local_Google_Fonts {

	const MAP_OPTION = 'blaminhor_essentials_cache_gfonts_map';
	const SUBDIR     = 'blaminhor-essentials/google-fonts';
	const UA_MODERN  = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	public function register_hooks() {
		// Run early so downstream optimisers see the rewritten <link>.
		add_filter( 'blaminhor_essentials_cache_html_buffer', array( $this, 'optimize_buffer' ), 12, 1 );
	}

	/**
	 * @param string $html
	 * @return string
	 */
	public function optimize_buffer( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return $html;
		}
		// Defence-in-depth bypass for skip-optimisation requests.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return $html;
		}
		if ( ! $this->parent->get_setting( 'local_google_fonts_enabled', true ) ) {
			return $html;
		}
		// Fast path — no Google Fonts at all in the buffer.
		if ( false === stripos( $html, 'fonts.googleapis.com' ) ) {
			return $html;
		}

		return preg_replace_callback(
			'#<link\s+[^>]*?href=("([^"]*fonts\.googleapis\.com/[^"]*)"|\'([^\']*fonts\.googleapis\.com/[^\']*)\')[^>]*?>#i',
			array( $this, 'rewrite_link' ),
			$html
		);
	}

	/**
	 * @param array $m
	 * @return string
	 */
	private function rewrite_link( $m ) {
		$remote = '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' );
		$remote = html_entity_decode( $remote, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		if ( '' === $remote ) {
			return $m[0];
		}

		$local_url = $this->local_css_url_for( $remote );
		if ( '' === $local_url ) {
			// Lookup miss — kick off a one-shot download (sync, bounded).
			$local_url = $this->fetch_and_localize( $remote );
			if ( '' === $local_url ) {
				return $m[0];
			}
		}

		return preg_replace(
			'#href=(["\'])[^"\']*\1#i',
			'href="' . esc_url( $local_url ) . '"',
			$m[0],
			1
		);
	}

	/**
	 * @param string $remote
	 * @return string Local URL, '' if not yet localized.
	 */
	private function local_css_url_for( $remote ) {
		$map = get_option( self::MAP_OPTION, array() );
		if ( ! is_array( $map ) ) {
			return '';
		}
		$key = md5( $remote );
		if ( ! isset( $map[ $key ] ) || ! is_array( $map[ $key ] ) ) {
			return '';
		}
		$entry = $map[ $key ];
		if ( empty( $entry['css_path'] ) || ! file_exists( $entry['css_path'] ) ) {
			return '';
		}
		return isset( $entry['css_url'] ) ? (string) $entry['css_url'] : '';
	}

	/**
	 * Fetch a Google CSS, download every referenced woff2 locally, rewrite the
	 * CSS to point at the local files, store the mapping. Returns the local
	 * CSS URL on success, '' on any failure.
	 *
	 * @param string $remote
	 * @return string
	 */
	private function fetch_and_localize( $remote ) {
		$css_response = wp_remote_get(
			$remote,
			array(
				'timeout'    => 5,
				'user-agent' => self::UA_MODERN,
				'headers'    => array( 'Accept' => 'text/css,*/*;q=0.1' ),
			)
		);
		if ( is_wp_error( $css_response ) || 200 !== (int) wp_remote_retrieve_response_code( $css_response ) ) {
			return '';
		}
		$css = (string) wp_remote_retrieve_body( $css_response );
		if ( '' === $css ) {
			return '';
		}

		$dir_info = $this->ensure_dir();
		if ( null === $dir_info ) {
			return '';
		}
		list( $dir_path, $dir_url ) = $dir_info;

		// Replace each gstatic url(...) with a local one.
		$rewritten = preg_replace_callback(
			'#url\(\s*([\'"]?)(https://fonts\.gstatic\.com/[^\)\'"\s]+)\1\s*\)#i',
			function ( $um ) use ( $dir_path, $dir_url ) {
				$font_url = $um[2];
				$ext      = pathinfo( wp_parse_url( $font_url, PHP_URL_PATH ) ?: '', PATHINFO_EXTENSION );
				$ext      = preg_match( '/^[a-z0-9]{1,5}$/i', $ext ) ? strtolower( $ext ) : 'woff2';
				$basename = md5( $font_url ) . '.' . $ext;
				$local_path = trailingslashit( $dir_path ) . $basename;
				if ( ! file_exists( $local_path ) ) {
					$resp = wp_remote_get(
						$font_url,
						array(
							'timeout'    => 5,
							'user-agent' => self::UA_MODERN,
						)
					);
					if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
						return $um[0]; // Leave the upstream URL on failure.
					}
					$body = wp_remote_retrieve_body( $resp );
					if ( '' === $body ) {
						return $um[0];
					}
					if ( false === @file_put_contents( $local_path, $body, LOCK_EX ) ) {
						return $um[0];
					}
				}
				return 'url(' . trailingslashit( $dir_url ) . $basename . ')';
			},
			$css
		);
		if ( ! is_string( $rewritten ) ) {
			return '';
		}

		$css_basename = md5( $remote ) . '.css';
		$css_path     = trailingslashit( $dir_path ) . $css_basename;
		$css_url      = trailingslashit( $dir_url ) . $css_basename;
		if ( false === @file_put_contents( $css_path, $rewritten, LOCK_EX ) ) {
			return '';
		}

		$map = get_option( self::MAP_OPTION, array() );
		if ( ! is_array( $map ) ) {
			$map = array();
		}
		$map[ md5( $remote ) ] = array(
			'remote'     => $remote,
			'css_path'   => $css_path,
			'css_url'    => $css_url,
			'created_at' => time(),
		);
		update_option( self::MAP_OPTION, $map, false );
		return $css_url;
	}

	/**
	 * @return array{0:string,1:string}|null [path, url]
	 */
	private function ensure_dir() {
		$uploads = wp_get_upload_dir();
		if ( empty( $uploads['basedir'] ) || empty( $uploads['baseurl'] ) ) {
			return null;
		}
		$path = trailingslashit( $uploads['basedir'] ) . self::SUBDIR;
		$url  = trailingslashit( $uploads['baseurl'] ) . self::SUBDIR;
		if ( ! file_exists( $path ) ) {
			if ( ! wp_mkdir_p( $path ) ) {
				return null;
			}
		}
		// Drop a defensive index.php so the directory cannot be listed.
		$index = trailingslashit( $path ) . 'index.php';
		if ( ! file_exists( $index ) ) {
			@file_put_contents( $index, "<?php\n// Silence is golden.\n" );
		}
		return array( $path, $url );
	}

	/**
	 * Wipe every cached CSS / woff2 + reset the mapping. Called from the
	 * Cache module's purge_all() so a settings change invalidates the
	 * locally-stored fonts together with the page cache.
	 */
	public function purge() {
		$uploads = wp_get_upload_dir();
		if ( empty( $uploads['basedir'] ) ) {
			return;
		}
		$path = trailingslashit( $uploads['basedir'] ) . self::SUBDIR;
		if ( is_dir( $path ) ) {
			foreach ( glob( trailingslashit( $path ) . '*' ) ?: array() as $file ) {
				if ( is_file( $file ) ) {
					wp_delete_file( $file );
				}
			}
		}
		delete_option( self::MAP_OPTION );
	}
}
