<?php
/**
 * BaseCloud Boost — Cache Context / Variance
 *
 * Computes the per-request "variant" of a page so the file-based page cache can
 * store more than one cached copy per URL. This is what makes geolocation /
 * currency switching keep working while a page is still served from cache:
 * instead of one frozen index.html per URL (which bakes in whichever currency
 * the FIRST visitor saw), Boost can store a separate cached file per country
 * and/or per cookie value.
 *
 * ─── IMPORTANT: keep in sync with advanced-cache.php ─────────────────────────
 * The drop-in (advanced-cache.php) runs BEFORE WordPress loads and therefore
 * cannot autoload this class. It re-implements the SAME algorithm inline (see
 * the "BCBOOST VARIANCE" block there). Any change to variant_segment(),
 * detect_country(), is_mobile_ua() or KNOWN_CURRENCY_COOKIES MUST be mirrored
 * there byte-for-byte, or a write and a read will disagree and the page will
 * always MISS.
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Resolves the cache variant (country + cookie) for the current request.
 */
class Cache_Context {

	/**
	 * Cookies set by popular currency / multi-currency plugins. When present they
	 * create a separate cached variant so each currency is cached independently.
	 * Order is fixed and MUST match advanced-cache.php exactly.
	 *
	 * @var string[]
	 */
	public const KNOWN_CURRENCY_COOKIES = [
		'aelia_cs_selected_currency',
		'aelia_customer_country',
		'woocommerce_currency',
		'woocs_current_currency',
		'wmc_current_currency',
		'wcml_currency',
		'wcpbc_country',
	];

	/**
	 * Server keys (in priority order) that a CDN / proxy / GeoIP module uses to
	 * expose the visitor's ISO-3166 country. MUST match advanced-cache.php.
	 *
	 * @var string[]
	 */
	private const COUNTRY_SERVER_KEYS = [
		'HTTP_CF_IPCOUNTRY',                // Cloudflare
		'HTTP_CLOUDFRONT_VIEWER_COUNTRY',   // AWS CloudFront
		'GEOIP_COUNTRY_CODE',               // Apache/nginx mod_geoip
		'MM_COUNTRY_CODE',                  // MaxMind
		'HTTP_X_COUNTRY_CODE',              // Generic proxy header
		'HTTP_X_GEO_COUNTRY',               // Generic proxy header
	];

	/**
	 * Build the variant segment for the current request.
	 *
	 * Returns a short hash when the request carries any variance signal (country
	 * and/or a varying cookie), or an empty string for the plain/default variant
	 * (which is stored as the backward-compatible index.html).
	 *
	 * @return string Variant hash, or '' for the default variant.
	 */
	public static function variant_segment(): string {
		$parts = [];

		if ( BCBOOST_get_option( 'cache_vary_by_country', false ) ) {
			$country = self::detect_country();
			if ( '' !== $country ) {
				$parts[] = 'c=' . $country;
			}
		}

		foreach ( self::vary_cookie_names() as $name ) {
			// wp_unslash so the value matches the raw cookie the pre-WordPress
			// drop-in hashes (WordPress slash-escapes $_COOKIE via wp_magic_quotes).
			if ( isset( $_COOKIE[ $name ] ) && '' !== (string) $_COOKIE[ $name ] ) {
				$parts[] = 'k:' . $name . '=' . (string) wp_unslash( $_COOKIE[ $name ] );
			}
		}

		if ( empty( $parts ) ) {
			return '';
		}

		return substr( md5( implode( '&', $parts ) ), 0, 12 );
	}

	/**
	 * Whether country-variance is enabled but the country could not be resolved.
	 *
	 * When true, the request must NOT be served from (or written to) the page
	 * cache: we have no reliable country, so any cached copy could show the wrong
	 * country's content (e.g. USD pricing to a South African visitor).
	 *
	 * @return bool
	 */
	public static function country_variance_unresolved(): bool {
		return BCBOOST_get_option( 'cache_vary_by_country', false ) && '' === self::detect_country();
	}

	/**
	 * Resolve the visitor's two-letter country code.
	 *
	 * Order: CDN/proxy/GeoIP server header → Boost bridge cookie → BCBOOST_resolve_country
	 * filter (so a theme/geo plugin can supply the country it already computed).
	 *
	 * @return string Uppercase ISO-3166 alpha-2 code, or '' if unknown.
	 */
	public static function detect_country(): string {
		foreach ( self::COUNTRY_SERVER_KEYS as $key ) {
			if ( ! empty( $_SERVER[ $key ] ) ) {
				$code = self::normalize_country( (string) $_SERVER[ $key ] );
				if ( '' !== $code ) {
					return $code;
				}
			}
		}

		if ( ! empty( $_COOKIE['bcboost_country'] ) ) {
			$code = self::normalize_country( (string) $_COOKIE['bcboost_country'] );
			if ( '' !== $code ) {
				return $code;
			}
		}

		/**
		 * Filter the resolved visitor country (ISO-3166 alpha-2).
		 *
		 * Themes / geolocation plugins that already know the country can return it
		 * here so Boost can cache a correct per-country variant even with no GeoIP
		 * header. Boost will also persist it to the `bcboost_country` cookie so the
		 * drop-in can serve the right variant on subsequent requests.
		 *
		 * @param string $country Country code resolved so far ('' if unknown).
		 */
		$filtered = (string) apply_filters( 'BCBOOST_resolve_country', '' );
		return self::normalize_country( $filtered );
	}

	/**
	 * Persist the resolved country to a first-party cookie so the pre-WordPress
	 * drop-in can serve the correct variant on the next request without a MISS.
	 *
	 * Only acts when country-variance is enabled, headers are not yet sent, and
	 * the cookie is missing or stale.
	 *
	 * @return void
	 */
	public static function maybe_set_country_cookie(): void {
		if ( ! BCBOOST_get_option( 'cache_vary_by_country', false ) ) {
			return;
		}
		if ( headers_sent() || is_admin() ) {
			return;
		}

		$country = self::detect_country();
		if ( '' === $country ) {
			return;
		}

		$existing = isset( $_COOKIE['bcboost_country'] ) ? self::normalize_country( (string) $_COOKIE['bcboost_country'] ) : '';
		if ( $existing === $country ) {
			return;
		}

		$secure = is_ssl();
		setcookie(
			'bcboost_country',
			$country,
			[
				'expires'  => time() + DAY_IN_SECONDS,
				'path'     => defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/',
				'domain'   => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '',
				'secure'   => $secure,
				'httponly' => false, // Readable by JS so client-side geo can reuse it.
				'samesite' => 'Lax',
			]
		);
		// Make it visible to the current request too.
		$_COOKIE['bcboost_country'] = $country;
	}

	/**
	 * Merged, de-duplicated list of cookie names that create cache variants:
	 * known currency cookies + the admin's custom `vary_cookies` list.
	 *
	 * @return string[]
	 */
	public static function vary_cookie_names(): array {
		$names = self::KNOWN_CURRENCY_COOKIES;

		$custom = array_filter( array_map(
			'trim',
			explode( "\n", (string) BCBOOST_get_option( 'vary_cookies', '' ) )
		) );

		foreach ( $custom as $name ) {
			if ( '' !== $name && ! in_array( $name, $names, true ) ) {
				$names[] = $name;
			}
		}

		return $names;
	}

	/**
	 * Whether the current User-Agent is a mobile device. MUST match the regex in
	 * advanced-cache.php so the write path and the read path agree.
	 *
	 * @return bool
	 */
	public static function is_mobile(): bool {
		// Sanitise identically to advanced-cache.php (printable ASCII, max 512 chars)
		// so the write path and the drop-in classify the same UA the same way.
		$ua = substr( preg_replace( '/[^\x20-\x7E]/', '', (string) ( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) ?? '', 0, 512 );
		return (bool) preg_match( '/Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi|webOS/i', $ua );
	}

	/**
	 * Build the cache filename stem for the current request, e.g. "index",
	 * "index-mobile", "index--<hash>", "index-mobile--<hash>".
	 *
	 * @return string Filename stem (no extension).
	 */
	public static function file_stem(): string {
		$stem = 'index';
		// Default false to match the drop-in (! empty()) when the key is unset.
		if ( BCBOOST_get_option( 'cache_mobile_separate', false ) && self::is_mobile() ) {
			$stem .= '-mobile';
		}
		$seg = self::variant_segment();
		if ( '' !== $seg ) {
			$stem .= '--' . $seg;
		}
		return $stem;
	}

	/**
	 * Normalise a raw country value to an uppercase alpha-2 code, or '' if it is
	 * empty/unknown ("XX" and "T1" are Cloudflare's unknown/Tor markers).
	 *
	 * @param string $raw Raw country value.
	 * @return string
	 */
	private static function normalize_country( string $raw ): string {
		$code = strtoupper( preg_replace( '/[^A-Za-z]/', '', $raw ) ?? '' );
		if ( strlen( $code ) < 2 ) {
			return '';
		}
		$code = substr( $code, 0, 2 );
		if ( 'XX' === $code || 'T1' === $code ) {
			return '';
		}
		return $code;
	}
}
