<?php
/**
 * BaseCloud Boost — Browser Cache Handler
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Adds browser cache headers and manages .htaccess rules for static assets.
 */
class Browser_Cache {

	/**
	 * Cache-Control for content-fingerprinted assets ONLY — Boost's minify
	 * output, whose filename embeds an md5 of the file content
	 * (e.g. bcboost-bundle-a1b2c3d4e5f6.min.js). The bytes at such a URL can
	 * never change (new content ⇒ new filename ⇒ new URL), so a year of
	 * `immutable` is safe and self-busting.
	 */
	public const CACHE_CONTROL_FINGERPRINTED = 'public, max-age=31536000, immutable';

	/**
	 * Cache-Control for generic (non-fingerprinted) theme/plugin CSS/JS. These
	 * keep the same filename when edited, so they must carry revalidation
	 * semantics — a moderate lifetime plus stale-while-revalidate, with ETags
	 * left enabled so an edit is picked up via a cheap 304. NEVER `immutable`.
	 */
	public const CACHE_CONTROL_STATIC_ASSETS = 'public, max-age=2592000, stale-while-revalidate=86400';

	/**
	 * Cache-Control for long-lived binary media (images/fonts). Long max-age is
	 * fine (media edits almost always arrive under a new filename), but no
	 * `immutable`: a same-name replacement (logo swap, regenerated thumbnail)
	 * must still be able to propagate.
	 */
	public const CACHE_CONTROL_MEDIA = 'public, max-age=31536000';

	/**
	 * Apache FilesMatch regex matching Boost's content-hashed minify output
	 * (CSS_Minifier / JS_Minifier write `<name>-<12-hex-md5>.min.css|js`).
	 */
	public const FINGERPRINT_FILE_PATTERN = '-[0-9a-f]{12}\.min\.(css|js)$';

	/**
	 * The single source of truth for asset Cache-Control values.
	 *
	 * GUARD (CLAUDE.md §6.1): `immutable` is a promise that the bytes at a URL
	 * will never change — only true for content-fingerprinted filenames. If a
	 * future edit ever adds `immutable` to the non-fingerprinted policies, it is
	 * stripped here and logged, because combined with a removed/rewritten ?ver=
	 * it freezes stale CSS/JS in every visitor's browser for a year with no
	 * server-side cure. Do not bypass this method when emitting asset headers.
	 *
	 * @param string $policy 'fingerprinted' | 'assets' | 'media'.
	 * @return string Cache-Control header value.
	 */
	public static function asset_cache_control( string $policy ): string {
		switch ( $policy ) {
			case 'fingerprinted':
				return self::CACHE_CONTROL_FINGERPRINTED;
			case 'media':
				$value = self::CACHE_CONTROL_MEDIA;
				break;
			default:
				$value = self::CACHE_CONTROL_STATIC_ASSETS;
		}

		if ( false !== stripos( $value, 'immutable' ) ) {
			$value = trim( (string) preg_replace( '/,?\s*immutable/i', '', $value ), ", \t" );
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Guard: stripped "immutable" from a non-fingerprinted asset Cache-Control policy — immutable is only safe on content-hashed filenames.' );
			}
		}

		return $value;
	}

	/**
	 * Boot browser cache module.
	 *
	 * @return void
	 */
	public function boot(): void {
		// Always register: HTML Cache-Control is correctness, not an optimization.
		// Logged-in users must be protected from browser caching, and anonymous
		// HTML must carry must-revalidate semantics, regardless of any setting —
		// the browser_cache toggle gates only the static-asset .htaccess rules.
		add_action( 'send_headers', [ $this, 'add_cache_headers' ] );
	}

	/**
	 * Send Cache-Control headers for HTML pages.
	 *
	 * HTML documents are NEVER cached long-term by the browser. Doing so was the
	 * cause of "I cleared the cache but still see the old page": the browser kept
	 * serving its own copy for hours and a server-side purge could not reach it.
	 *
	 * Instead the browser is told to always revalidate (a cheap 304 against the
	 * ETag when nothing changed). The server-side page cache — and any CDN edge
	 * cache via s-maxage — keep responses fast. Static assets are cached by the
	 * .htaccess rules below: content-fingerprinted bundles long + immutable,
	 * everything else with revalidation semantics.
	 *
	 * Sent UNCONDITIONALLY on the front end. The `browser_cache` option gates
	 * only the static-asset .htaccess rules (Htaccess_Manager) — it must never
	 * suppress this header: with no Cache-Control at all, browsers
	 * heuristic-cache the HTML and serve stale pages that no server-side purge
	 * can reach (12cutssteakhouse, 2026-07-17).
	 *
	 * @return void
	 */
	public function add_cache_headers(): void {
		if ( is_admin() ) {
			return;
		}

		// Logged-in users (admins/editors) must always see live, uncached content.
		if ( is_user_logged_in() ) {
			header( 'Cache-Control: no-cache, no-store, must-revalidate, max-age=0' );
			header( 'Pragma: no-cache' );
			return;
		}

		// Anonymous visitors: store but always revalidate before reuse.
		header( 'Cache-Control: public, max-age=0, must-revalidate' );
		header( 'Vary: Accept-Encoding' );
		header( 'X-BaseCloud-Boost: ACTIVE' );
	}

	/**
	 * Get the .htaccess rules for browser caching of static assets.
	 *
	 * Correctness contract (CLAUDE.md §6.1):
	 * - `immutable` ONLY on content-fingerprinted filenames (Boost's minify
	 *   output) — never by bare .css/.js extension. A theme/plugin asset keeps
	 *   its filename when edited; marking it immutable froze the old bytes in
	 *   visitors' browsers for a year, beyond the reach of any server purge.
	 * - Generic CSS/JS: moderate lifetime + stale-while-revalidate, ETags left
	 *   enabled, so edits are picked up on revalidation.
	 * - HTML is not touched here: it stays `max-age=0, must-revalidate` via
	 *   add_cache_headers() and the page-cache FilesMatch rules.
	 *
	 * Block order matters: fingerprinted bundles match both CSS/JS FilesMatch
	 * patterns, and the LAST matching `Header set` wins — keep the fingerprint
	 * block below the generic one.
	 *
	 * @return string
	 */
	public static function get_htaccess_rules(): string {
		$media         = self::asset_cache_control( 'media' );
		$assets        = self::asset_cache_control( 'assets' );
		$fingerprinted = self::asset_cache_control( 'fingerprinted' );

		return '
# BEGIN BaseCloud Boost Browser Cache
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/gif "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/webp "access plus 1 year"
    ExpiresByType image/avif "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
    ExpiresByType image/x-icon "access plus 1 year"
    ExpiresByType video/mp4 "access plus 1 year"
    ExpiresByType text/css "access plus 1 month"
    ExpiresByType text/javascript "access plus 1 month"
    ExpiresByType application/javascript "access plus 1 month"
    ExpiresByType application/pdf "access plus 1 month"
    ExpiresByType application/x-font-woff "access plus 1 year"
    ExpiresByType application/font-woff2 "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
</IfModule>

<IfModule mod_headers.c>
    # Long-lived media: long max-age, but NO "immutable" — a same-name
    # replacement (logo swap, regenerated thumbnail) must still propagate.
    <FilesMatch "\.(ico|jpg|jpeg|png|gif|webp|avif|svg|woff|woff2|ttf|eot)$">
        Header set Cache-Control "' . $media . '"
    </FilesMatch>
    # Generic theme/plugin CSS/JS keep their filename when edited — NEVER
    # "immutable" here. Moderate lifetime + revalidation; ETags stay enabled.
    <FilesMatch "\.(css|js)$">
        Header set Cache-Control "' . $assets . '"
    </FilesMatch>
    # BaseCloud Boost minified output embeds an md5 of the content in the
    # filename: bytes can never change at these URLs, so immutable is safe
    # and self-busting (new content = new filename = new URL).
    <FilesMatch "' . self::FINGERPRINT_FILE_PATTERN . '">
        Header set Cache-Control "' . $fingerprinted . '"
    </FilesMatch>
</IfModule>
# END BaseCloud Boost Browser Cache
';
	}
}
