<?php
/**
 * BaseCloud Boost — Critical CSS (per-template store + injector)
 *
 * The WP Rocket / NitroPack-lite model: critical (above-the-fold) CSS is stored
 * PER TEMPLATE, not per URL, so the store stays small while still covering every
 * layout a builder site renders. Resolution order at runtime:
 *
 *   1. per-URL override (exact path, rare — hand-tuned pages)
 *   2. template CSS   (front_page → home → product → single_post → single_page → archive)
 *   3. 'default' template CSS (fallback bucket)
 *   4. global `critical_css` option (the legacy single textarea — site-wide fallback,
 *      still inlined by Resource_Hints; this class supersedes it only when a more
 *      specific level exists)
 *   5. none — and with none, BCBOOST_async_css_active() stays FALSE, so all CSS
 *      remains render-blocking. That gate is the single source of truth: async CSS
 *      without critical CSS broke Elementor per-page styling (monitornet Careers,
 *      2026-06-18) and must never re-open without provable coverage.
 *
 * Storage is a single non-autoloaded option (settings, NOT cache — it must survive
 * every purge). A "may be stale" flag is raised when the theme switches or a
 * builder (Divi/Elementor) regenerates its CSS, because stored critical CSS was
 * extracted from the old stylesheets.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

use BaseCloud\Boost\Core\Cache_Purge;

/**
 * Per-template critical CSS store, resolver, injector, and generation endpoints.
 */
class Critical_CSS {

	/** @var string Option key for the per-template/per-URL critical CSS store. */
	public const STORE_KEY = 'BCBOOST_critical_css_store';

	/**
	 * Recognised template buckets, in no particular order (resolution order is
	 * decided by detect_template()). 'default' is the fallback bucket used when
	 * no specific template matches or the matching template has no CSS stored.
	 *
	 * @var string[]
	 */
	public const TEMPLATES = [
		'front_page',
		'home',
		'single_post',
		'single_page',
		'product',
		'archive',
		'default',
	];

	/** @var array|null Per-request store cache. */
	private static ?array $store_cache = null;

	/** @var bool Debounce: one stale-flag write per request. */
	private static bool $flagged_this_request = false;

	// ─── Boot ─────────────────────────────────────────────────────────────────

	/**
	 * Boot the module. Safe in every context — it self-selects what to register:
	 * stale-flag listeners always (builder saves fire in admin/AJAX), admin-post
	 * handlers in admin, the <head> injector on optimizable front-end requests.
	 *
	 * @return void
	 */
	public function boot(): void {
		// Stale detection — the stored critical CSS was extracted from the theme's
		// / builder's stylesheets of the moment; when those regenerate, flag it.
		add_action( 'switch_theme',                          [ $this, 'flag_stale_from_hook' ] );
		add_action( 'customize_save_after',                  [ $this, 'flag_stale_from_hook' ] );
		// Divi: static-CSS (et-cache) regeneration + Theme Options writes
		// (same verified hooks Divi_Compat piggybacks for purges).
		add_action( 'et_core_page_resource_auto_clear',      [ $this, 'flag_stale_from_hook' ], 30 );
		add_action( 'update_option_et_divi',                 [ $this, 'flag_stale_from_hook' ] );
		add_action( 'update_option_et_pb_builder_options',   [ $this, 'flag_stale_from_hook' ] );
		// Elementor: fires whenever Elementor clears/regenerates its CSS files.
		add_action( 'elementor/core/files/clear_cache',      [ $this, 'flag_stale_from_hook' ] );

		if ( is_admin() ) {
			add_action( 'admin_post_BCBOOST_save_critical_css',       [ $this, 'handle_save_templates' ] );
			add_action( 'admin_post_BCBOOST_delete_critical_css_url', [ $this, 'handle_delete_url' ] );
			add_action( 'admin_post_BCBOOST_dismiss_ccss_stale',      [ $this, 'handle_dismiss_stale' ] );
			add_action( 'admin_post_BCBOOST_generate_critical_css',   [ $this, 'handle_generate_remote' ] );
			return;
		}

		if ( ! BCBOOST_get_option( 'critical_css_enabled', false ) ) {
			return;
		}

		// Priority 3: AFTER Resource_Hints::enqueue_critical_css (priority 2) so a
		// more specific per-URL/template result can supersede the global inline.
		add_action( 'wp_enqueue_scripts', [ $this, 'inject_critical_css' ], 3 );
	}

	// ─── Front-end injection ──────────────────────────────────────────────────

	/**
	 * Inline the resolved critical CSS in <head> via wp_add_inline_style.
	 *
	 * The global-fallback level is already inlined by Resource_Hints (handle
	 * `bcboost-global-critical-css`); this injector only acts when a MORE SPECIFIC
	 * level (per-URL or template) resolves, and then dequeues the global handle so
	 * exactly one critical CSS block is ever printed.
	 *
	 * @return void
	 */
	public function inject_critical_css(): void {
		if ( ! BCBOOST_is_optimizable_request() ) {
			return; // Builder editors / previews load real stylesheets — nothing to do.
		}

		$resolved = self::resolve_for_current_request();
		if ( '' === $resolved['css'] || ! in_array( $resolved['level'], [ 'url', 'template' ], true ) ) {
			return; // none, or global (Resource_Hints owns that).
		}

		// Supersede the site-wide fallback — one critical CSS block only.
		wp_dequeue_style( 'bcboost-global-critical-css' );

		wp_register_style( 'bcboost-critical-css', false, [], BCBOOST_VERSION ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters
		wp_enqueue_style( 'bcboost-critical-css' );
		// Store writes are already sanitized; strip again on output as defense in depth.
		wp_add_inline_style( 'bcboost-critical-css', wp_strip_all_tags( $resolved['css'] ) );
	}

	// ─── Resolution ───────────────────────────────────────────────────────────

	/**
	 * Resolve the critical CSS covering the current request.
	 *
	 * Never throws; on any failure returns the 'none' result so callers (including
	 * the BCBOOST_async_css_active() gate) fail SAFE — no critical CSS means CSS
	 * stays render-blocking.
	 *
	 * @return array{css:string, level:string, key:string} level ∈ none|url|template|global.
	 */
	public static function resolve_for_current_request(): array {
		$none = [ 'css' => '', 'level' => 'none', 'key' => '' ];

		try {
			$store = self::get_store();

			// 1. Per-URL override (exact normalized path).
			$path = self::current_path();
			if ( '' !== $path && isset( $store['urls'][ $path ]['css'] ) && '' !== $store['urls'][ $path ]['css'] ) {
				return [ 'css' => (string) $store['urls'][ $path ]['css'], 'level' => 'url', 'key' => $path ];
			}

			// 2. Template bucket, then the 'default' fallback bucket. Only when the
			//    query is parseable — before that we cannot know the template, and
			//    guessing would inline the wrong CSS (worse than none).
			$template = self::detect_template();
			if ( '' !== $template ) {
				foreach ( array_unique( [ $template, 'default' ] ) as $bucket ) {
					if ( isset( $store['templates'][ $bucket ]['css'] ) && '' !== $store['templates'][ $bucket ]['css'] ) {
						return [ 'css' => (string) $store['templates'][ $bucket ]['css'], 'level' => 'template', 'key' => $bucket ];
					}
				}
			}

			// 3. Global site-wide fallback (legacy single textarea).
			$global = trim( (string) BCBOOST_get_option( 'critical_css', '' ) );
			if ( '' !== $global ) {
				return [ 'css' => $global, 'level' => 'global', 'key' => 'global' ];
			}
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Critical CSS resolution failed: ' . $e->getMessage() );
			}
		}

		return $none;
	}

	/**
	 * Whether ANY critical CSS covers the current request (per-URL, template,
	 * default bucket, or global). This is what BCBOOST_async_css_active() consults:
	 * async CSS opens per request, only where above-the-fold coverage exists.
	 *
	 * @return bool
	 */
	public static function css_exists_for_current_request(): bool {
		$resolved = self::resolve_for_current_request();
		return '' !== $resolved['css'];
	}

	/**
	 * Whether any critical CSS is configured at all (any store entry or the
	 * global textarea). Used by the admin UI to explain the async-CSS gate.
	 *
	 * @return bool
	 */
	public static function any_css_configured(): bool {
		try {
			$store = self::get_store();
			foreach ( [ 'templates', 'urls' ] as $group ) {
				foreach ( $store[ $group ] as $entry ) {
					if ( ! empty( $entry['css'] ) ) {
						return true;
					}
				}
			}
			return '' !== trim( (string) BCBOOST_get_option( 'critical_css', '' ) );
		} catch ( \Throwable $e ) {
			return false;
		}
	}

	/**
	 * Map the current main query to a template bucket. Returns '' when the main
	 * query is not available yet (too early) — callers must treat '' as unknown,
	 * never as 'default'.
	 *
	 * @return string
	 */
	public static function detect_template(): string {
		if ( ! isset( $GLOBALS['wp_query'] )
			|| ! $GLOBALS['wp_query'] instanceof \WP_Query
			|| ! function_exists( 'is_front_page' ) ) {
			return '';
		}

		if ( is_front_page() ) {
			return 'front_page';
		}
		if ( is_home() ) {
			return 'home';
		}
		if ( class_exists( 'WooCommerce' ) && function_exists( 'is_product' ) && is_product() ) {
			return 'product';
		}
		if ( is_singular( 'post' ) ) {
			return 'single_post';
		}
		if ( is_page() ) {
			return 'single_page';
		}
		if ( is_archive() || is_search() ) {
			return 'archive';
		}

		return 'default';
	}

	/**
	 * Human-readable template labels for the admin UI / CLI.
	 *
	 * @return array<string, string>
	 */
	public static function template_labels(): array {
		return [
			'front_page'  => __( 'Front page', 'basecloud-boost' ),
			'home'        => __( 'Blog / posts page', 'basecloud-boost' ),
			'single_post' => __( 'Single post', 'basecloud-boost' ),
			'single_page' => __( 'Single page', 'basecloud-boost' ),
			'product'     => __( 'Product (WooCommerce)', 'basecloud-boost' ),
			'archive'     => __( 'Archive / category / search', 'basecloud-boost' ),
			'default'     => __( 'Everything else (fallback)', 'basecloud-boost' ),
		];
	}

	// ─── Store ────────────────────────────────────────────────────────────────

	/**
	 * Read the store (normalized shape, per-request cached).
	 *
	 * @return array{templates:array, urls:array, stale:?array}
	 */
	public static function get_store(): array {
		if ( null !== self::$store_cache ) {
			return self::$store_cache;
		}

		$raw   = get_option( self::STORE_KEY, [] );
		$store = [ 'templates' => [], 'urls' => [], 'stale' => null ];

		if ( is_array( $raw ) ) {
			if ( isset( $raw['templates'] ) && is_array( $raw['templates'] ) ) {
				$store['templates'] = $raw['templates'];
			}
			if ( isset( $raw['urls'] ) && is_array( $raw['urls'] ) ) {
				$store['urls'] = $raw['urls'];
			}
			if ( isset( $raw['stale'] ) && is_array( $raw['stale'] ) ) {
				$store['stale'] = $raw['stale'];
			}
		}

		self::$store_cache = $store;
		return $store;
	}

	/**
	 * Persist the store. NOT autoloaded: entries can be tens of KB each and are
	 * only needed when resolving — keep them out of alloptions.
	 *
	 * @param array $store Store array.
	 * @return void
	 */
	private static function save_store( array $store ): void {
		self::$store_cache = $store;
		if ( false === get_option( self::STORE_KEY, false ) ) {
			add_option( self::STORE_KEY, $store, '', 'no' );
		} else {
			update_option( self::STORE_KEY, $store, 'no' );
		}
	}

	/**
	 * Store critical CSS for a template bucket. Empty CSS deletes the entry.
	 *
	 * @param string $template One of self::TEMPLATES.
	 * @param string $css      Raw CSS (will be sanitized).
	 * @param string $source   Provenance label: manual|cli|remote.
	 * @return true|\WP_Error
	 */
	public static function set_template_css( string $template, string $css, string $source = 'manual' ) {
		if ( ! in_array( $template, self::TEMPLATES, true ) ) {
			return new \WP_Error( 'bcboost_bad_template', sprintf( 'Unknown template "%s". Valid: %s', $template, implode( ', ', self::TEMPLATES ) ) );
		}

		$css = self::sanitize_css( $css );
		$max = self::max_bytes();
		if ( strlen( $css ) > $max ) {
			return new \WP_Error( 'bcboost_css_too_large', sprintf( 'Critical CSS for "%s" is %s — larger than the %s cap. Critical CSS should be a small above-the-fold subset, not the full stylesheet.', $template, size_format( strlen( $css ) ), size_format( $max ) ) );
		}

		$store = self::get_store();
		if ( '' === $css ) {
			unset( $store['templates'][ $template ] );
		} else {
			$store['templates'][ $template ] = [
				'css'     => $css,
				'updated' => time(),
				'source'  => sanitize_key( $source ),
			];
		}
		self::maybe_clear_stale( $store );
		self::save_store( $store );
		return true;
	}

	/**
	 * Delete a template bucket's CSS.
	 *
	 * @param string $template Template key.
	 * @return bool True if an entry existed and was removed.
	 */
	public static function delete_template_css( string $template ): bool {
		$store = self::get_store();
		if ( ! isset( $store['templates'][ $template ] ) ) {
			return false;
		}
		unset( $store['templates'][ $template ] );
		self::maybe_clear_stale( $store );
		self::save_store( $store );
		return true;
	}

	/**
	 * Store a per-URL critical CSS override. Empty CSS deletes it.
	 *
	 * @param string $url    Full URL or path (only the path is kept).
	 * @param string $css    Raw CSS (will be sanitized).
	 * @param string $source Provenance label.
	 * @return true|\WP_Error
	 */
	public static function set_url_css( string $url, string $css, string $source = 'manual' ) {
		$path = self::normalize_path( $url );
		if ( '' === $path ) {
			return new \WP_Error( 'bcboost_bad_url', 'Could not derive a path from the given URL.' );
		}

		$css = self::sanitize_css( $css );
		$max = self::max_bytes();
		if ( strlen( $css ) > $max ) {
			return new \WP_Error( 'bcboost_css_too_large', sprintf( 'Critical CSS for %s exceeds the %s cap.', $path, size_format( $max ) ) );
		}

		$store = self::get_store();
		if ( '' === $css ) {
			unset( $store['urls'][ $path ] );
		} else {
			// Keep the per-URL layer small — it is an override mechanism, not a
			// page cache. 50 entries is far beyond any sane hand-tuned set.
			if ( ! isset( $store['urls'][ $path ] ) && count( $store['urls'] ) >= 50 ) {
				return new \WP_Error( 'bcboost_url_store_full', 'Per-URL override store is full (50 entries). Use template CSS instead.' );
			}
			$store['urls'][ $path ] = [
				'css'     => $css,
				'updated' => time(),
				'source'  => sanitize_key( $source ),
			];
		}
		self::maybe_clear_stale( $store );
		self::save_store( $store );
		return true;
	}

	/**
	 * Delete a per-URL override.
	 *
	 * @param string $url URL or path.
	 * @return bool
	 */
	public static function delete_url_css( string $url ): bool {
		$path  = self::normalize_path( $url );
		$store = self::get_store();
		if ( '' === $path || ! isset( $store['urls'][ $path ] ) ) {
			return false;
		}
		unset( $store['urls'][ $path ] );
		self::maybe_clear_stale( $store );
		self::save_store( $store );
		return true;
	}

	// ─── Stale tracking ───────────────────────────────────────────────────────

	/**
	 * Flag the stored critical CSS as possibly stale. Records the trigger and
	 * time; entries saved AFTER this time are considered fresh again.
	 *
	 * @param string $reason Human-readable trigger description.
	 * @return void
	 */
	public static function flag_stale( string $reason ): void {
		if ( self::$flagged_this_request || ! self::any_css_configured() ) {
			return;
		}
		self::$flagged_this_request = true;

		$store          = self::get_store();
		$store['stale'] = [
			'reason' => sanitize_text_field( $reason ),
			'time'   => time(),
		];
		self::save_store( $store );

		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			error_log( '[BaseCloud Boost] Critical CSS flagged possibly stale: ' . $reason );
		}
	}

	/**
	 * Stale-flag listener; maps the firing hook to a readable reason.
	 * Extra hook args (update_option_* passes old/new values) are ignored.
	 *
	 * @return void
	 */
	public function flag_stale_from_hook(): void {
		$reasons = [
			'switch_theme'                        => __( 'Theme switched', 'basecloud-boost' ),
			'customize_save_after'                => __( 'Customizer settings saved', 'basecloud-boost' ),
			'et_core_page_resource_auto_clear'    => __( 'Divi regenerated its static CSS', 'basecloud-boost' ),
			'update_option_et_divi'               => __( 'Divi Theme Options changed', 'basecloud-boost' ),
			'update_option_et_pb_builder_options' => __( 'Divi Builder options changed', 'basecloud-boost' ),
			'elementor/core/files/clear_cache'    => __( 'Elementor regenerated its CSS files', 'basecloud-boost' ),
		];
		$hook = (string) current_filter();
		self::flag_stale( $reasons[ $hook ] ?? $hook );
	}

	/**
	 * Current stale record, or null when not flagged.
	 *
	 * @return array{reason:string, time:int}|null
	 */
	public static function get_stale(): ?array {
		$stale = self::get_store()['stale'];
		if ( is_array( $stale ) && ! empty( $stale['time'] ) ) {
			return [ 'reason' => (string) ( $stale['reason'] ?? '' ), 'time' => (int) $stale['time'] ];
		}
		return null;
	}

	/**
	 * Whether a specific store entry predates the stale event (= needs regenerating).
	 *
	 * @param array $entry Store entry.
	 * @return bool
	 */
	public static function is_entry_stale( array $entry ): bool {
		$stale = self::get_stale();
		return null !== $stale && (int) ( $entry['updated'] ?? 0 ) < $stale['time'];
	}

	/**
	 * Clear the stale flag explicitly (admin "dismiss").
	 *
	 * @return void
	 */
	public static function clear_stale(): void {
		$store = self::get_store();
		if ( null !== $store['stale'] ) {
			$store['stale'] = null;
			self::save_store( $store );
		}
	}

	/**
	 * Auto-clear the stale flag once every remaining entry is fresher than the
	 * stale event (mutates the passed store; caller saves).
	 *
	 * @param array $store Store array (by reference).
	 * @return void
	 */
	private static function maybe_clear_stale( array &$store ): void {
		if ( empty( $store['stale']['time'] ) ) {
			return;
		}
		$stale_time = (int) $store['stale']['time'];
		foreach ( [ 'templates', 'urls' ] as $group ) {
			foreach ( $store[ $group ] as $entry ) {
				if ( (int) ( $entry['updated'] ?? 0 ) < $stale_time ) {
					return; // Something is still older than the stale event.
				}
			}
		}
		$store['stale'] = null;
	}

	// ─── Sanitization ─────────────────────────────────────────────────────────

	/**
	 * Sanitize a critical CSS blob: strip markup, remove remote-import and legacy
	 * script vectors. Deliberately NOT sanitize_textarea_field — that deletes
	 * every %xx octet and corrupts url()/SVG-data-URI values (the 1.1.2 lesson).
	 *
	 * @param string $css Raw CSS.
	 * @return string
	 */
	public static function sanitize_css( string $css ): string {
		$css = wp_strip_all_tags( $css );
		// No @import: critical CSS must be self-contained — a remote @import would
		// reintroduce a render-blocking (and attacker-controllable) request.
		$css = (string) preg_replace( '/@import[^;{]*;?/i', '', $css );
		$css = (string) preg_replace( '/@charset[^;]*;?/i', '', $css );
		// Legacy IE script-in-CSS vectors.
		$css = (string) preg_replace( '/expression\s*\(|javascript\s*:|behavior\s*:/i', '', $css );
		return trim( $css );
	}

	/**
	 * Maximum stored size per entry (bytes). Filter: BCBOOST_critical_css_max_bytes.
	 *
	 * @return int
	 */
	public static function max_bytes(): int {
		return max( 1024, (int) apply_filters( 'BCBOOST_critical_css_max_bytes', 1048576 ) );
	}

	// ─── Admin handlers ───────────────────────────────────────────────────────

	/**
	 * Save the per-template textareas posted from the Advanced tab.
	 *
	 * @return void
	 */
	public function handle_save_templates(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'basecloud-boost' ) );
		}
		check_admin_referer( 'BCBOOST_critical_css' );

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- each value is sanitized in set_template_css().
		$posted = isset( $_POST['BCBOOST_ccss_tpl'] ) && is_array( $_POST['BCBOOST_ccss_tpl'] )
			? wp_unslash( $_POST['BCBOOST_ccss_tpl'] )
			: [];

		$errors  = [];
		$changed = false;
		foreach ( self::TEMPLATES as $template ) {
			if ( ! array_key_exists( $template, $posted ) ) {
				continue; // Field not on the form — leave the stored value alone.
			}
			$before = self::get_store()['templates'][ $template ]['css'] ?? '';
			$result = self::set_template_css( $template, (string) $posted[ $template ], 'manual' );
			if ( is_wp_error( $result ) ) {
				$errors[] = $result->get_error_message();
				continue;
			}
			$after = self::get_store()['templates'][ $template ]['css'] ?? '';
			if ( $before !== $after ) {
				$changed = true;
			}
		}

		if ( $changed ) {
			$this->purge_pages_after_change();
		}

		$arg = empty( $errors ) ? [ 'BCBOOST_ccss' => 'saved' ] : [ 'BCBOOST_ccss' => 'error' ];
		if ( ! empty( $errors ) ) {
			set_transient( 'bcboost_ccss_notice', implode( ' ', array_map( 'sanitize_text_field', $errors ) ), 60 );
		}
		wp_safe_redirect( add_query_arg( $arg, wp_get_referer() ?: admin_url( 'admin.php?page=basecloud-boost' ) ) );
		exit;
	}

	/**
	 * Delete a per-URL override from the admin list.
	 *
	 * @return void
	 */
	public function handle_delete_url(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'basecloud-boost' ) );
		}
		check_admin_referer( 'BCBOOST_critical_css' );

		$path = isset( $_GET['ccss_url'] ) ? (string) wp_unslash( $_GET['ccss_url'] ) : '';
		if ( '' !== $path && self::delete_url_css( $path ) ) {
			$this->purge_pages_after_change();
		}
		wp_safe_redirect( add_query_arg( 'BCBOOST_ccss', 'saved', wp_get_referer() ?: admin_url( 'admin.php?page=basecloud-boost' ) ) );
		exit;
	}

	/**
	 * Dismiss the "possibly stale" banner.
	 *
	 * @return void
	 */
	public function handle_dismiss_stale(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'basecloud-boost' ) );
		}
		check_admin_referer( 'BCBOOST_critical_css' );

		self::clear_stale();
		wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=basecloud-boost' ) );
		exit;
	}

	// ─── Generation (OFF by default — a deliberate user opt-in either way) ────

	/**
	 * Generate critical CSS for each template's representative URL.
	 *
	 * Two engines, both explicit opt-ins that the user chooses in Advanced
	 * settings — nothing generates until one is switched on:
	 *
	 *   1. REMOTE (highest fidelity): a configured real-render service
	 *      (`critical_css_remote_endpoint`). Always takes precedence when set.
	 *   2. LOCAL (Beta): the in-plugin PHP extractor
	 *      (Optimizer\Critical_CSS_Generator) — gated by `critical_css_auto`.
	 *
	 * Generation only FILLS THE STORE. It never enables Critical CSS inlining
	 * (`critical_css_enabled`) and never enables Async CSS (`lazy_css`) — those
	 * remain separate, user-controlled switches, and the async gate keeps
	 * requiring per-request coverage.
	 *
	 * @return void
	 */
	public function handle_generate_remote(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'basecloud-boost' ) );
		}
		check_admin_referer( 'BCBOOST_critical_css' );

		$endpoint = self::remote_endpoint();
		$local    = (bool) BCBOOST_get_option( 'critical_css_auto', false );

		if ( '' === $endpoint && ! $local ) {
			set_transient( 'bcboost_ccss_notice', __( 'Nothing is enabled to generate with: switch on the built-in generator (Beta) or configure a generation service endpoint first.', 'basecloud-boost' ), 60 );
			wp_safe_redirect( add_query_arg( 'BCBOOST_ccss', 'error', wp_get_referer() ?: admin_url( 'admin.php?page=basecloud-boost' ) ) );
			exit;
		}

		// Several sequential renders/fetches can exceed a shared host's 30s limit.
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 300 ); // phpcs:ignore
		}

		$results = [];
		$changed = false;
		foreach ( self::representative_urls() as $template => $url ) {
			if ( '' === $url ) {
				$results[] = $template . ': skipped (no representative URL)';
				continue;
			}
			// Remote engine wins when configured; the local extractor is the
			// fallback the user opted into.
			$css = ( '' !== $endpoint )
				? self::fetch_remote_critical_css( $endpoint, $url )
				: Critical_CSS_Generator::generate_for_url( $url );
			if ( is_wp_error( $css ) ) {
				$results[] = $template . ': FAILED — ' . $css->get_error_message();
				continue;
			}
			$saved = self::set_template_css( $template, $css, '' !== $endpoint ? 'remote' : 'local' );
			if ( is_wp_error( $saved ) ) {
				$results[] = $template . ': FAILED — ' . $saved->get_error_message();
				continue;
			}
			$changed   = true;
			$results[] = $template . ': OK (' . size_format( strlen( self::get_store()['templates'][ $template ]['css'] ?? '' ) ) . ')';
		}

		if ( $changed ) {
			$this->purge_pages_after_change();
		}

		set_transient( 'bcboost_ccss_notice', implode( ' · ', array_map( 'sanitize_text_field', $results ) ), 120 );
		wp_safe_redirect( add_query_arg( 'BCBOOST_ccss', $changed ? 'generated' : 'error', wp_get_referer() ?: admin_url( 'admin.php?page=basecloud-boost' ) ) );
		exit;
	}

	/**
	 * The configured remote generation endpoint ('' = feature off).
	 *
	 * @return string
	 */
	public static function remote_endpoint(): string {
		$endpoint = trim( (string) BCBOOST_get_option( 'critical_css_remote_endpoint', '' ) );

		/**
		 * Filter the critical-CSS generation service endpoint.
		 *
		 * @param string $endpoint Endpoint URL ('' disables remote generation).
		 */
		$endpoint = (string) apply_filters( 'BCBOOST_critical_css_endpoint', $endpoint );

		return ( '' !== $endpoint && false !== wp_http_validate_url( $endpoint ) ) ? $endpoint : '';
	}

	/**
	 * Representative URL per template for remote generation ('' = skip).
	 * Filter: BCBOOST_critical_css_representative_urls.
	 *
	 * @return array<string, string>
	 */
	public static function representative_urls(): array {
		$urls = array_fill_keys( self::TEMPLATES, '' );

		$urls['front_page'] = home_url( '/' );

		$posts_page = (int) get_option( 'page_for_posts' );
		if ( $posts_page > 0 ) {
			$urls['home'] = (string) get_permalink( $posts_page );
		}

		$latest_post = get_posts( [ 'numberposts' => 1, 'post_status' => 'publish', 'fields' => 'ids' ] );
		if ( ! empty( $latest_post ) ) {
			$urls['single_post'] = (string) get_permalink( (int) $latest_post[0] );
		}

		$page = get_posts( [
			'numberposts' => 1,
			'post_type'   => 'page',
			'post_status' => 'publish',
			'fields'      => 'ids',
			'exclude'     => array_filter( [ (int) get_option( 'page_on_front' ), $posts_page ] ),
		] );
		if ( ! empty( $page ) ) {
			$urls['single_page'] = (string) get_permalink( (int) $page[0] );
		}

		if ( class_exists( 'WooCommerce' ) ) {
			$product = get_posts( [ 'numberposts' => 1, 'post_type' => 'product', 'post_status' => 'publish', 'fields' => 'ids' ] );
			if ( ! empty( $product ) ) {
				$urls['product'] = (string) get_permalink( (int) $product[0] );
			}
		}

		$cats = get_terms( [ 'taxonomy' => 'category', 'orderby' => 'count', 'order' => 'DESC', 'number' => 1, 'hide_empty' => true ] );
		if ( is_array( $cats ) && ! empty( $cats ) ) {
			$link = get_term_link( $cats[0] );
			if ( ! is_wp_error( $link ) ) {
				$urls['archive'] = (string) $link;
			}
		}

		// 'default' has no canonical URL — manual/CLI only.

		/**
		 * Filter the representative URL used to generate each template's critical CSS.
		 *
		 * @param array<string,string> $urls template => URL ('' = skip).
		 */
		$filtered = apply_filters( 'BCBOOST_critical_css_representative_urls', $urls );

		return is_array( $filtered ) ? array_intersect_key( array_map( 'strval', $filtered ), $urls ) : $urls;
	}

	/**
	 * Defensively fetch critical CSS for a URL from the remote service.
	 *
	 * Accepts either JSON `{"css": "..."}` or a raw `text/css` body. Applies a
	 * hard transfer cap, then sanitize_css(), then structural sanity checks —
	 * a compromised or buggy service must never be able to inject markup or
	 * oversized blobs into the store.
	 *
	 * @param string $endpoint Service endpoint.
	 * @param string $url      Page URL to generate for.
	 * @return string|\WP_Error Sanitized CSS.
	 */
	public static function fetch_remote_critical_css( string $endpoint, string $url ) {
		$response = wp_remote_post( $endpoint, [
			'timeout'             => 20,
			'redirection'         => 2,
			'limit_response_size' => 2 * 1048576, // 2 MB transfer cap.
			'headers'             => [ 'Content-Type' => 'application/json' ],
			'body'                => wp_json_encode( [
				'url'      => esc_url_raw( $url ),
				'viewport' => [ 'width' => 412, 'height' => 915 ], // Mobile-first, matches the Node recipe.
			] ),
		] );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return new \WP_Error( 'bcboost_ccss_http', 'Service returned HTTP ' . $code );
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === trim( $body ) ) {
			return new \WP_Error( 'bcboost_ccss_empty', 'Service returned an empty body' );
		}

		$css     = '';
		$decoded = json_decode( $body, true );
		if ( is_array( $decoded ) && isset( $decoded['css'] ) && is_string( $decoded['css'] ) ) {
			$css = $decoded['css'];
		} else {
			$content_type = (string) wp_remote_retrieve_header( $response, 'content-type' );
			if ( false !== stripos( $content_type, 'text/css' ) ) {
				$css = $body;
			}
		}

		if ( '' === trim( $css ) ) {
			return new \WP_Error( 'bcboost_ccss_format', 'Service response was neither {"css": "..."} JSON nor text/css' );
		}

		$css = self::sanitize_css( $css );

		// Structural sanity: must look like CSS, must contain no residual markup.
		if ( strpos( $css, '{' ) === false || strpos( $css, '}' ) === false || false !== strpos( $css, '</' ) ) {
			return new \WP_Error( 'bcboost_ccss_invalid', 'Service response does not look like CSS after sanitization' );
		}
		if ( strlen( $css ) > self::max_bytes() ) {
			return new \WP_Error( 'bcboost_ccss_too_large', 'Service response exceeds the stored-CSS size cap' );
		}

		return $css;
	}

	// ─── Helpers ──────────────────────────────────────────────────────────────

	/**
	 * Purge cached pages after the stored critical CSS changed — cached HTML
	 * contains the OLD inline block. Fail open: a purge failure never blocks the save.
	 *
	 * @return void
	 */
	private function purge_pages_after_change(): void {
		try {
			( new Cache_Purge() )->purge_all();
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Purge after critical CSS change failed: ' . $e->getMessage() );
			}
		}
	}

	/**
	 * Normalize a URL or URI to the path key used by the per-URL store:
	 * leading slash, trailing slash, query/fragment stripped.
	 *
	 * @param string $url Full URL, path, or REQUEST_URI.
	 * @return string Normalized path, '' when underivable.
	 */
	public static function normalize_path( string $url ): string {
		$url = trim( $url );
		if ( '' === $url ) {
			return '';
		}
		$path = (string) wp_parse_url( $url, PHP_URL_PATH );
		if ( '' === $path ) {
			$path = '/';
		}
		// Decode percent-encoding so a CLI-supplied path and a live REQUEST_URI
		// produce the same store key regardless of encoding.
		$path = rawurldecode( $path );
		if ( '/' !== $path[0] ) {
			$path = '/' . $path;
		}
		return trailingslashit( $path );
	}

	/**
	 * Normalized path of the current request ('' outside HTTP context).
	 *
	 * @return string
	 */
	private static function current_path(): string {
		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
			return '';
		}
		return self::normalize_path( sanitize_text_field( (string) wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
	}
}
