<?php
/**
 * Cache module — Critical CSS + RUCSS
 *
 * Three-layer strategy:
 *
 *   Layer 1 (preferred): browser-side generation. Admin opens an iframe
 *   sandbox on the target URL with a fixed viewport (mobile: 375x812,
 *   desktop: 1280x720), runs a JS extractor that walks document.styleSheets
 *   and keeps the rules whose selector matches an element with
 *   getBoundingClientRect().top < innerHeight. Result is POSTed back via
 *   AJAX and stored in `cache/critical/templates/{template}-{viewport}.css`.
 *
 *   Layer 2 (fallback): async CSS loading. Stylesheets are emitted with
 *   `rel="preload" as="style" onload="this.rel='stylesheet'"` + a noscript
 *   fallback, so the browser parses HTML without waiting on CSS. Brief FOUC
 *   trade-off.
 *
 *   Layer 3 (RUCSS): the same extractor can return ALL applied rules, not
 *   just above-the-fold, producing a "used CSS" sheet. The PHP side stores
 *   it next to the critical file and toggle on/off. Safelist is mandatory
 *   for hover/JS-toggled state.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Critical_Css {

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

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

	public function register_hooks() {
		// Frontend: inline critical CSS, async-load the rest.
		add_action( 'wp_head', array( $this, 'inject_critical_inline' ), 1 );
		add_filter( 'style_loader_tag', array( $this, 'maybe_async_stylesheet' ), 9999, 4 );
		add_action( 'wp_head', array( $this, 'output_async_loader_polyfill' ), 99 );

		// Admin AJAX endpoints (JS extractor → PHP storage).
		add_action( 'wp_ajax_ap_cache_critical_save', array( $this, 'ajax_save' ) );
		add_action( 'wp_ajax_ap_cache_critical_clear', array( $this, 'ajax_clear' ) );
		add_action( 'wp_ajax_ap_cache_critical_url_for_template', array( $this, 'ajax_url_for_template' ) );
		add_action( 'wp_ajax_ap_cache_critical_list', array( $this, 'ajax_list' ) );

		// Invalidate on theme switch.
		add_action( 'switch_theme', array( $this, 'invalidate_all' ) );

		// Enqueue the collector script when on the module's settings page.
		add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_collector_helper' ) );
	}

	/**
	 * @return array<string, string> template_id => human label
	 */
	public function templates() {
		$templates = array(
			'home'     => __( 'Home page', 'blaminhor-essentials' ),
			'single'   => __( 'Single post', 'blaminhor-essentials' ),
			'page'     => __( 'Page', 'blaminhor-essentials' ),
			'archive'  => __( 'Archive (category, tag, author, date)', 'blaminhor-essentials' ),
			'404'      => __( '404 page', 'blaminhor-essentials' ),
		);
		if ( class_exists( 'WooCommerce' ) ) {
			$templates['product'] = __( 'WooCommerce product', 'blaminhor-essentials' );
			$templates['shop']    = __( 'WooCommerce shop', 'blaminhor-essentials' );
		}
		return $templates;
	}

	/**
	 * Detect the template id of the CURRENT request.
	 *
	 * @return string
	 */
	public function detect_current_template() {
		if ( is_404() ) {
			return '404';
		}
		if ( is_front_page() || is_home() ) {
			return 'home';
		}
		if ( function_exists( 'is_shop' ) && is_shop() ) {
			return 'shop';
		}
		if ( function_exists( 'is_product' ) && is_product() ) {
			return 'product';
		}
		if ( is_singular( 'page' ) ) {
			return 'page';
		}
		if ( is_singular( 'post' ) ) {
			return 'single';
		}
		if ( is_archive() ) {
			return 'archive';
		}
		if ( is_singular() ) {
			return 'single';
		}
		return 'fallback';
	}

	/**
	 * Echo the critical CSS for the current template (mobile + desktop merged).
	 */
	public function inject_critical_inline() {
		if ( ! $this->parent->get_setting( 'critical_css_enabled', false ) ) {
			return;
		}
		// Logged-in admins (and any logged-in visitor when cache_logged_in is OFF)
		// must see the page exactly as it would render without the module — that
		// includes NOT injecting an alternate critical stylesheet, since it could
		// hide a regression they are trying to debug.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return;
		}
		$template = $this->detect_current_template();
		$variant  = wp_is_mobile() ? 'mobile' : 'desktop';

		$css = $this->load_critical( $template, $variant );
		if ( '' === $css ) {
			// Try a generic fallback set if specific is missing.
			$css = $this->load_critical( 'fallback', $variant );
		}
		if ( '' === $css ) {
			return;
		}

		// wp_strip_all_tags removes any HTML the extractor may have captured; the
		// remaining CSS is safe to embed verbatim. HTML-escaping would break valid
		// CSS combinators (>, ~) which is the wrong sanitization for a <style> block.
		$safe_css = wp_strip_all_tags( $css );
		echo '<style id="be-critical-css">' . $safe_css . '</style>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS sanitized above; HTML escaping would break CSS combinators.
	}

	/**
	 * If async CSS loading is enabled, transform the stylesheet tag so the
	 * browser parses HTML without waiting on it.
	 *
	 * @param string $tag    Original <link rel="stylesheet"> tag.
	 * @param string $handle Style handle.
	 * @param string $href   URL.
	 * @param string $media  Media query.
	 * @return string
	 */
	public function maybe_async_stylesheet( $tag, $handle, $href, $media ) {
		if ( ! $this->parent->get_setting( 'critical_async_loading', false ) ) {
			return $tag;
		}
		// Bypass for admins / logged-in users (when cache_logged_in is OFF) and
		// the standard non-cacheable contexts (AJAX, REST, customizer, feeds).
		// Without this, an admin browsing the front-end receives the same
		// preload-swap CSS as anonymous visitors, which masks page-builder
		// regressions and breaks the standard "log in to see the site as-is"
		// support workflow. Replaces the previous is_admin()-only check.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return $tag;
		}
		// Safety gate: never swap stylesheets to async preload when no critical
		// CSS has been generated for the current template+variant. Without an
		// inlined above-the-fold sheet, the browser parses the document with no
		// styles applied at all until the preload swap fires — visitors see a
		// flash of unstyled content (Elementor Pro nav menus, Avada containers,
		// hero LCP) and JS that measures positions at DOMContentReady (Elementor
		// Pro Nav, Slider Revolution...) computes wrong offsets. The user sees a
		// "broken" site even though every individual setting looked fine in the
		// admin. Better to fall back to the unaltered tag and keep the page
		// styled, which matches the "Generate critical CSS first, then enable
		// async loading" warning shown in the UI.
		if ( ! $this->has_critical_for_current_template() ) {
			return $tag;
		}
		// Don't async essential WP-emitted styles (admin-bar, dashicons in frontend).
		$essentials = array( 'admin-bar', 'dashicons' );
		if ( in_array( $handle, $essentials, true ) ) {
			return $tag;
		}
		// Some themes ship critical inline; skip those handles.
		if ( false !== strpos( $handle, 'critical' ) ) {
			return $tag;
		}

		// Skip print stylesheets (already non-blocking).
		if ( 'print' === $media ) {
			return $tag;
		}

		// Theme-/builder-contributed handles whose cascade order is mandatory.
		// The theme_compat manager merges contributions from Avada, Elementor,
		// Divi, ... — entries match exactly OR as a prefix (any handle whose
		// string starts with the entry, used for families like 'fusion-*').
		// This is what keeps the async loader from inverting the order of
		// fusion-stylesheet → child-style → fusion-dynamic-css and stripping
		// container max-widths off Avada's `.fusion-row`.
		$excluded = apply_filters( 'blaminhor_essentials_cache_async_css_excluded_handles', array() );
		if ( is_array( $excluded ) ) {
			foreach ( $excluded as $entry ) {
				if ( ! is_string( $entry ) || '' === $entry ) {
					continue;
				}
				if ( $handle === $entry ) {
					return $tag;
				}
				if ( 0 === strpos( $handle, $entry ) ) {
					return $tag;
				}
			}
		}

		// Build the swap. This is a style_loader_tag FILTER — the input $tag
		// is already enqueued via wp_enqueue_style(); we only rewrite it into
		// preload + noscript fallback. The <link rel="stylesheet"> inside the
		// noscript is the same enqueued sheet, not a new one.
		// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- noscript fallback for an already-enqueued stylesheet that we are async-loading.
		$noscript = '<noscript><link rel="stylesheet" href="' . esc_attr( $href ) . '" media="' . esc_attr( $media ) . '"></noscript>';
		$async    = '<link rel="preload" as="style" href="' . esc_attr( $href ) . '" onload="this.onload=null;this.rel=\'stylesheet\';" data-be-async="' . esc_attr( $handle ) . '">';

		return $async . $noscript;
	}

	/**
	 * Output a fallback for browsers that don't fire onload on preloaded
	 * stylesheets (some old versions of Firefox). Ensures the rel swap still
	 * happens so styles eventually apply.
	 */
	public function output_async_loader_polyfill() {
		if ( ! $this->parent->get_setting( 'critical_async_loading', false ) ) {
			return;
		}
		// No async stylesheets are emitted for skip-optimisation requests, so
		// the polyfill has nothing to revive — emit nothing to keep <head> clean.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return;
		}
		?>
<script>(function(){var l=document.querySelectorAll('link[rel="preload"][as="style"][data-be-async]');for(var i=0;i<l.length;i++){if(l[i].getAttribute('rel')==='preload'){(function(node){node.rel='stylesheet';})(l[i]);}}}());</script>
		<?php
	}

	/* ------------------------------------------------------------------ */
	/*  Cell rendering (used by tab-files.php)                              */
	/* ------------------------------------------------------------------ */

	/**
	 * Render the HTML for one critical-CSS cell, given the manifest entry
	 * (or null if no entry exists yet). Three states:
	 *   - null         → "Not generated"
	 *   - state=ok     → size + "X ago"
	 *   - state=error  → red label + tooltip explaining the kind of error
	 *
	 * Wording is intentionally factual: a too-large extraction is not an
	 * incident, the site keeps working with its normal CSS pipeline. The
	 * tooltip says so explicitly.
	 *
	 * @param array|null $entry Manifest entry.
	 * @return string Escaped HTML.
	 */
	public function render_cell( $entry ) {
		if ( empty( $entry ) || ! is_array( $entry ) ) {
			return '<em>' . esc_html__( 'Not generated', 'blaminhor-essentials' ) . '</em>';
		}

		$state = isset( $entry['state'] ) ? (string) $entry['state'] : 'ok';

		if ( 'error' === $state ) {
			$kind  = isset( $entry['error_kind'] ) ? (string) $entry['error_kind'] : '';
			$bytes = isset( $entry['error_bytes'] ) ? (int) $entry['error_bytes'] : 0;
			$kb    = (int) round( $bytes / 1024 );

			switch ( $kind ) {
				case 'too_large':
					$label = sprintf(
						/* translators: %d: extracted CSS size in KB */
						__( 'Above the limit (%d KB)', 'blaminhor-essentials' ),
						$kb
					);
					$help = __( 'The extraction returned a CSS payload larger than the inliner can serve usefully. Inlining at this size would slow the page down rather than speed it up. The site continues to load its CSS the normal way — no action required.', 'blaminhor-essentials' );
					break;

				case 'write_failed':
					$label = __( 'Could not save', 'blaminhor-essentials' );
					$help  = __( 'The server could not write the critical CSS file. The most common cause is a non-writable cache directory. The site continues to load its CSS normally.', 'blaminhor-essentials' );
					break;

				case 'empty':
				default:
					$label = __( 'Nothing to inline', 'blaminhor-essentials' );
					$help  = __( 'No CSS could be extracted for this template. This often happens when the page loads its stylesheets from a different origin without CORS. The site keeps loading its CSS normally.', 'blaminhor-essentials' );
					break;
			}

			$updated_label = '';
			if ( ! empty( $entry['updated_at'] ) ) {
				$updated_label = sprintf(
					/* translators: %s: human-readable duration, e.g. "2 days" */
					__( '%s ago', 'blaminhor-essentials' ),
					human_time_diff( (int) $entry['updated_at'], time() )
				);
			}

			return '<span class="be-critical-cell-error" title="' . esc_attr( $help ) . '">'
				. esc_html( $label )
				. '</span>'
				. ( '' !== $updated_label
					? '<br><small>' . esc_html( $updated_label ) . '</small>'
					: '' );
		}

		// state=ok (or any unknown state with a positive size).
		$size       = isset( $entry['size'] ) ? (int) $entry['size'] : 0;
		$updated_at = isset( $entry['updated_at'] ) ? (int) $entry['updated_at'] : 0;

		return '<span class="be-critical-cell-size">'
			. esc_html( blaminhor_essentials_format_size( $size ) )
			. '</span>'
			. ( $updated_at > 0
				? '<br><small>' . esc_html( sprintf(
						/* translators: %s: human-readable duration, e.g. "2 days" */
						__( '%s ago', 'blaminhor-essentials' ),
						human_time_diff( $updated_at, time() )
					) ) . '</small>'
				: '' );
	}

	/* ------------------------------------------------------------------ */
	/*  Storage                                                            */
	/* ------------------------------------------------------------------ */

	private function critical_dir() {
		$dir = $this->parent->storage->critical_dir() . 'templates/';
		blaminhor_essentials_ensure_protected_directory( untrailingslashit( $dir ) );
		return $dir;
	}

	/**
	 * @param string $template Template id.
	 * @param string $variant  desktop|mobile
	 * @return string Path.
	 */
	public function critical_path( $template, $variant ) {
		$template = preg_replace( '/[^a-z0-9_-]/i', '', (string) $template );
		$variant  = ( 'mobile' === $variant ) ? 'mobile' : 'desktop';
		return $this->critical_dir() . $template . '-' . $variant . '.css';
	}

	/**
	 * Read the critical CSS file. Empty string if missing.
	 *
	 * @param string $template
	 * @param string $variant
	 * @return string
	 */
	public function load_critical( $template, $variant ) {
		$path = $this->critical_path( $template, $variant );
		if ( ! is_file( $path ) ) {
			return '';
		}
		$contents = @file_get_contents( $path );
		return false === $contents ? '' : $contents;
	}

	/**
	 * Whether a critical CSS file exists for the request currently being
	 * served (template id + viewport variant). Falls back to the generic
	 * 'fallback' template, mirroring inject_critical_inline()'s lookup
	 * order. Used by the runtime safety gate of maybe_async_stylesheet().
	 *
	 * @return bool
	 */
	public function has_critical_for_current_template() {
		$variant  = wp_is_mobile() ? 'mobile' : 'desktop';
		$template = $this->detect_current_template();
		if ( '' !== $this->load_critical( $template, $variant ) ) {
			return true;
		}
		if ( '' !== $this->load_critical( 'fallback', $variant ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Whether at least one critical CSS sheet has been generated, any
	 * template, any variant. Used by the admin UI to decide whether the
	 * "Charger les autres CSS en asynchrone" checkbox should be enabled.
	 * Cheap (one filesystem listing); no caching since the admin page
	 * renders once per request.
	 *
	 * @return bool
	 */
	public function any_critical_generated() {
		$dir = $this->critical_dir();
		if ( ! is_dir( $dir ) ) {
			return false;
		}
		$files = glob( $dir . '*.css' );
		if ( empty( $files ) ) {
			return false;
		}
		foreach ( $files as $f ) {
			if ( is_file( $f ) && filesize( $f ) > 0 ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Persist a freshly-extracted critical CSS sheet.
	 *
	 * @param string $template
	 * @param string $variant
	 * @param string $css
	 * @return bool
	 */
	public function save_critical( $template, $variant, $css ) {
		if ( ! is_string( $css ) ) {
			return false;
		}
		$css  = Blaminhor_Essentials_Cache_Css_Optimizer::minify_css( $css );
		$path = $this->critical_path( $template, $variant );
		if ( false === @file_put_contents( $path, $css, LOCK_EX ) ) {
			return false;
		}
		// Record manifest so the UI can show "last updated" timestamps.
		$this->update_manifest( $template, $variant, strlen( $css ) );
		return true;
	}

	/**
	 * Wipe ALL critical files (used on theme switch).
	 */
	public function invalidate_all() {
		$dir = $this->critical_dir();
		if ( is_dir( $dir ) ) {
			foreach ( (array) glob( $dir . '*.css' ) as $f ) {
				@unlink( $f );
			}
		}
		delete_option( 'blaminhor_essentials_cache_critical_manifest' );
	}

	private function update_manifest( $template, $variant, $size ) {
		$manifest = (array) get_option( 'blaminhor_essentials_cache_critical_manifest', array() );
		$key      = $template . '-' . $variant;
		$manifest[ $key ] = array(
			'template'   => $template,
			'variant'    => $variant,
			'updated_at' => time(),
			'size'       => (int) $size,
			'theme'      => get_stylesheet(),
			'state'      => 'ok',
		);
		update_option( 'blaminhor_essentials_cache_critical_manifest', $manifest, false );
	}

	/**
	 * Persist a failed extraction in the manifest so the UI can show the
	 * cell as "above the limit" / "could not save" the next time the page is
	 * loaded, instead of resetting to a misleading "Not generated".
	 *
	 * @param string $template
	 * @param string $variant
	 * @param string $kind 'too_large' | 'write_failed' | 'empty'
	 * @param int    $size_bytes
	 */
	private function update_manifest_error( $template, $variant, $kind, $size_bytes = 0 ) {
		$manifest = (array) get_option( 'blaminhor_essentials_cache_critical_manifest', array() );
		$key      = $template . '-' . $variant;
		$manifest[ $key ] = array(
			'template'    => $template,
			'variant'     => $variant,
			'updated_at'  => time(),
			'size'        => 0,
			'theme'       => get_stylesheet(),
			'state'       => 'error',
			'error_kind'  => (string) $kind,
			'error_bytes' => max( 0, (int) $size_bytes ),
		);
		update_option( 'blaminhor_essentials_cache_critical_manifest', $manifest, false );
	}

	/* ------------------------------------------------------------------ */
	/*  AJAX                                                               */
	/* ------------------------------------------------------------------ */

	/**
	 * Receive extracted CSS from the browser and persist it.
	 */
	public function ajax_save() {
		$this->parent_verify_request();

		$template = isset( $_POST['template'] ) ? sanitize_key( wp_unslash( $_POST['template'] ) ) : '';
		$variant  = isset( $_POST['variant'] ) ? sanitize_key( wp_unslash( $_POST['variant'] ) ) : 'desktop';
		// CSS comes raw — sanitization happens via Css_Optimizer::minify_css() on the way in.
		$css = isset( $_POST['css'] ) ? wp_unslash( (string) $_POST['css'] ) : '';

		if ( '' === $template || '' === $css ) {
			blaminhor_essentials_log(
				'ajax_save called with empty CSS payload — likely all stylesheets blocked by CORS in the iframe',
				'warning',
				'cache',
				array(
					'event'      => 'critical_save_empty_payload',
					'template'   => $template,
					'variant'    => $variant,
					'css_length' => strlen( $css ),
				)
			);
			if ( '' !== $template ) {
				$this->update_manifest_error( $template, $variant, 'empty', strlen( $css ) );
			}
			blaminhor_essentials_ajax_error(
				__( 'No CSS could be extracted from this template. The site keeps loading its CSS the normal way — no action required.', 'blaminhor-essentials' ),
				array( 'error_kind' => 'empty', 'bytes' => strlen( $css ) )
			);
		}

		// Hard cap at 200 KB. Anything bigger is almost certainly not "critical" and
		// hints at the extractor returning the full stylesheet.
		if ( strlen( $css ) > 200 * 1024 ) {
			blaminhor_essentials_log(
				'ajax_save rejected — extracted CSS exceeds 200 KB cap',
				'warning',
				'cache',
				array(
					'event'      => 'critical_save_too_large',
					'template'   => $template,
					'variant'    => $variant,
					'css_length' => strlen( $css ),
				)
			);
			$this->update_manifest_error( $template, $variant, 'too_large', strlen( $css ) );
			blaminhor_essentials_ajax_error(
				__( 'The extracted CSS is larger than the inliner can serve usefully. The site keeps loading its CSS normally.', 'blaminhor-essentials' ),
				array( 'error_kind' => 'too_large', 'bytes' => strlen( $css ) )
			);
		}

		$ok = $this->save_critical( $template, $variant, $css );
		if ( ! $ok ) {
			$cache_dir = trailingslashit( wp_upload_dir()['basedir'] ?? '' ) . 'blaminhor-essentials/cache/critical/templates';
			blaminhor_essentials_log(
				'ajax_save could not write critical CSS file — filesystem write failed',
				'error',
				'cache',
				array(
					'event'           => 'critical_save_write_failed',
					'template'        => $template,
					'variant'         => $variant,
					'css_length'      => strlen( $css ),
					'target_dir'      => $cache_dir,
					'dir_exists'      => is_dir( $cache_dir ),
					'dir_writable'    => is_dir( $cache_dir ) ? is_writable( $cache_dir ) : null,
					'uploads_basedir' => wp_upload_dir()['basedir'] ?? '',
					'uploads_writable'=> is_writable( wp_upload_dir()['basedir'] ?? '' ),
				)
			);
			$this->update_manifest_error( $template, $variant, 'write_failed', strlen( $css ) );
			blaminhor_essentials_ajax_error(
				__( 'The server could not write the critical CSS file. The site keeps loading its CSS normally.', 'blaminhor-essentials' ),
				array( 'error_kind' => 'write_failed', 'bytes' => strlen( $css ) )
			);
		}

		blaminhor_essentials_log(
			'Critical CSS saved for template "' . $template . '" / ' . $variant,
			'info',
			'cache',
			array( 'event' => 'critical_save_ok', 'template' => $template, 'variant' => $variant, 'bytes' => strlen( $css ) )
		);

		// Return data the JS can paint into the row immediately, so the page
		// does NOT need to reload (which would discard unsaved settings on
		// other tabs).
		blaminhor_essentials_ajax_success(
			__( 'Critical CSS saved.', 'blaminhor-essentials' ),
			array(
				'template' => $template,
				'variant'  => $variant,
				'bytes'    => strlen( $css ),
				'size'     => blaminhor_essentials_format_size( strlen( $css ) ),
				/* translators: shown after a critical CSS row was just generated */
				'label'    => __( 'just now', 'blaminhor-essentials' ),
			)
		);
	}

	/**
	 * Delete one or all critical files.
	 */
	public function ajax_clear() {
		$this->parent_verify_request();

		$template = isset( $_POST['template'] ) ? sanitize_key( wp_unslash( $_POST['template'] ) ) : '';
		$variant  = isset( $_POST['variant'] ) ? sanitize_key( wp_unslash( $_POST['variant'] ) ) : '';

		if ( '' === $template ) {
			$this->invalidate_all();
			blaminhor_essentials_ajax_success( __( 'All critical CSS files cleared.', 'blaminhor-essentials' ) );
		}

		// Specific (template, variant) pair.
		if ( '' === $variant ) {
			foreach ( array( 'desktop', 'mobile' ) as $v ) {
				$path = $this->critical_path( $template, $v );
				if ( is_file( $path ) ) {
					@unlink( $path );
				}
			}
		} else {
			$path = $this->critical_path( $template, $variant );
			if ( is_file( $path ) ) {
				@unlink( $path );
			}
		}

		// Refresh manifest.
		$manifest = (array) get_option( 'blaminhor_essentials_cache_critical_manifest', array() );
		foreach ( array_keys( $manifest ) as $k ) {
			if ( 0 === strpos( $k, $template . '-' ) ) {
				unset( $manifest[ $k ] );
			}
		}
		update_option( 'blaminhor_essentials_cache_critical_manifest', $manifest, false );

		blaminhor_essentials_ajax_success( __( 'Critical CSS cleared.', 'blaminhor-essentials' ) );
	}

	/**
	 * Return a representative URL for the requested template (used by the
	 * extractor to load it in a sandboxed iframe).
	 */
	public function ajax_url_for_template() {
		$this->parent_verify_request();

		$template = isset( $_POST['template'] ) ? sanitize_key( wp_unslash( $_POST['template'] ) ) : '';
		$url      = $this->representative_url( $template );

		if ( '' === $url ) {
			blaminhor_essentials_log(
				'No representative URL found for Critical CSS template "' . $template . '"',
				'error',
				'cache',
				array(
					'event'             => 'critical_no_representative_url',
					'template'          => $template,
					'home_url'          => home_url( '/' ),
					'has_published_post'    => count( get_posts( array( 'numberposts' => 1, 'post_type' => 'post', 'fields' => 'ids', 'post_status' => 'publish' ) ) ) > 0,
					'has_published_page'    => count( get_posts( array( 'numberposts' => 1, 'post_type' => 'page', 'fields' => 'ids', 'post_status' => 'publish' ) ) ) > 0,
					'has_category'          => count( (array) get_categories( array( 'number' => 1, 'hide_empty' => true ) ) ) > 0,
					'woocommerce_active'    => class_exists( 'WooCommerce' ),
				)
			);
			blaminhor_essentials_ajax_error( __( 'No representative URL found for this template.', 'blaminhor-essentials' ) );
		}

		blaminhor_essentials_log(
			'Critical CSS URL resolved for template "' . $template . '"',
			'debug',
			'cache',
			array( 'event' => 'critical_url_resolved', 'template' => $template, 'url' => $url )
		);
		blaminhor_essentials_ajax_success( __( 'OK', 'blaminhor-essentials' ), array( 'url' => $url ) );
	}

	/**
	 * List manifest entries for the UI.
	 */
	public function ajax_list() {
		$this->parent_verify_request();
		$manifest = (array) get_option( 'blaminhor_essentials_cache_critical_manifest', array() );
		blaminhor_essentials_ajax_success( __( 'OK', 'blaminhor-essentials' ), array( 'manifest' => $manifest ) );
	}

	/**
	 * Pick a real URL for a template id. Falls back to home if the chosen
	 * type has no published content.
	 *
	 * @param string $template
	 * @return string
	 */
	private function representative_url( $template ) {
		// Generic content templates always fall back to home_url('/') so that
		// a brand-new install with one published page (and no posts, no
		// categories, etc.) still produces something useful. Returning '' would
		// silently abort the whole extraction with no feedback.
		$home = home_url( '/' );

		switch ( $template ) {
			case 'home':
				return $home;

			case 'single':
				$ids = get_posts( array( 'numberposts' => 1, 'post_type' => 'post', 'fields' => 'ids', 'post_status' => 'publish' ) );
				if ( ! empty( $ids ) ) {
					$link = get_permalink( (int) $ids[0] );
					return is_string( $link ) && '' !== $link ? $link : $home;
				}
				return $home;

			case 'page':
				$ids = get_posts( array( 'numberposts' => 1, 'post_type' => 'page', 'fields' => 'ids', 'post_status' => 'publish' ) );
				if ( ! empty( $ids ) ) {
					$link = get_permalink( (int) $ids[0] );
					return is_string( $link ) && '' !== $link ? $link : $home;
				}
				return $home;

			case 'archive':
				$cats = get_categories( array( 'number' => 1, 'hide_empty' => true ) );
				if ( ! empty( $cats ) ) {
					$link = get_category_link( $cats[0]->term_id );
					if ( is_string( $link ) && '' !== $link ) {
						return $link;
					}
				}
				return $home;

			case '404':
				return home_url( '/' . md5( time() ) . '-' . wp_generate_password( 8, false, false ) );

			case 'product':
				if ( ! class_exists( 'WooCommerce' ) ) {
					return '';
				}
				$ids = get_posts( array( 'numberposts' => 1, 'post_type' => 'product', 'fields' => 'ids' ) );
				return ! empty( $ids ) ? (string) get_permalink( (int) $ids[0] ) : '';

			case 'shop':
				if ( ! function_exists( 'wc_get_page_id' ) ) {
					return '';
				}
				$shop_id = wc_get_page_id( 'shop' );
				return $shop_id > 0 ? (string) get_permalink( $shop_id ) : '';
		}
		return '';
	}

	/* ------------------------------------------------------------------ */
	/*  Helpers                                                            */
	/* ------------------------------------------------------------------ */

	/**
	 * Bridge to the parent's verify_ajax_request() helper. Component classes
	 * don't extend Blaminhor_Essentials_Module so we duplicate the check here.
	 */
	private function parent_verify_request() {
		check_ajax_referer( 'blaminhor_essentials_admin', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized', 'blaminhor-essentials' ) ) );
		}
	}

	/**
	 * Enqueue critical-collector.js on the module page so the UI can drive it.
	 *
	 * @param string $hook
	 */
	public function maybe_enqueue_collector_helper( $hook ) {
		if ( false === strpos( (string) $hook, 'blaminhor-essentials-cache' ) ) {
			return;
		}
		// The collector file is loaded on demand by cache-admin.js inside the
		// sandbox iframe — registering here just makes the URL discoverable.
		wp_register_script(
			'blaminhor-essentials-cache-critical-collector',
			BLAMINHOR_ESSENTIALS_PLUGIN_URL . 'assets/js/frontend/critical-collector.js',
			array(),
			BLAMINHOR_ESSENTIALS_VERSION,
			true
		);
	}
}
