<?php
/**
 * Cache & Performance Module
 *
 * Orchestrator for the Cache module: loads sub-components (page cache, purge,
 * file optimizers, audit, web vitals, etc.) and exposes the 8-tab settings UI.
 *
 * @package BlaminhorEssentials
 */

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

/**
 * Cache module main class.
 *
 * Architecture: components are instantiated and stored as properties so they
 * can collaborate via $this->parent->{component}. Each component owns its
 * own register_hooks() call, dispatched from the orchestrator's register_hooks().
 */
class Blaminhor_Essentials_Module_Cache extends Blaminhor_Essentials_Module {

	/**
	 * Module ID. Must match the array key used in blaminhor-essentials.php.
	 *
	 * @var string
	 */
	protected $id = 'cache';

	/**
	 * @var string
	 */
	protected $name = 'Cache & Performance';

	/**
	 * Database schema version. Bump when changing be_web_vitals* tables.
	 *
	 * @var string
	 */
	private $db_version = '1.0';

	/**
	 * Set to true within on_settings_updated() whenever a settings change
	 * triggered an automatic cache purge in the current request. Read by the
	 * settings POST handler so the success notice can tell the user the cache
	 * was regenerated, instead of the bare "Settings saved." that hid the
	 * side-effect.
	 *
	 * @var bool
	 */
	private $cache_purged_on_save = false;

	/* --- Components (lazily instantiated in init()) --- */

	/** @var Blaminhor_Essentials_Cache_Environment */
	public $environment;

	/** @var Blaminhor_Essentials_Cache_Multisite */
	public $multisite;

	/** @var Blaminhor_Essentials_Cache_Storage */
	public $storage;

	/** @var Blaminhor_Essentials_Cache_Safe_Mode */
	public $safe_mode;

	/** @var Blaminhor_Essentials_Cache_Htaccess_Writer */
	public $htaccess_writer;

	/** @var Blaminhor_Essentials_Cache_Conflict_Detector */
	public $conflict_detector;

	/** @var Blaminhor_Essentials_Cache_Dropin_Installer */
	public $dropin_installer;

	/** @var Blaminhor_Essentials_Cache_Page_Cache */
	public $page_cache;

	/** @var Blaminhor_Essentials_Cache_Purge */
	public $purge;

	/** @var Blaminhor_Essentials_Cache_Heartbeat */
	public $heartbeat;

	/** @var Blaminhor_Essentials_Cache_Css_Optimizer */
	public $css_optimizer;

	/** @var Blaminhor_Essentials_Cache_Js_Optimizer */
	public $js_optimizer;

	/** @var Blaminhor_Essentials_Cache_Image_Dimensions */
	public $image_dimensions;

	/** @var Blaminhor_Essentials_Cache_Local_Google_Fonts */
	public $local_google_fonts;

	/** @var Blaminhor_Essentials_Cache_Auto_Resource_Hints */
	public $auto_resource_hints;

	/** @var Blaminhor_Essentials_Cache_Host_Detector */
	public $host_detector;

	/** @var Blaminhor_Essentials_Cache_Cloudflare */
	public $cloudflare;

	/** @var Blaminhor_Essentials_Cache_Theme_Compat_Manager */
	public $theme_compat;

	/** @var Blaminhor_Essentials_Cache_Media_Lazyload */
	public $media_lazyload;

	/** @var Blaminhor_Essentials_Cache_Resource_Hints */
	public $resource_hints;

	/** @var Blaminhor_Essentials_Cache_Cdn_Rewriter */
	public $cdn_rewriter;

	/** @var Blaminhor_Essentials_Cache_Preloader */
	public $preloader;

	/** @var Blaminhor_Essentials_Cache_Critical_Css */
	public $critical_css;

	/** @var Blaminhor_Essentials_Cache_Audit_PSI */
	public $audit_psi;

	/** @var Blaminhor_Essentials_Cache_Audit_Internal */
	public $audit_internal;

	/** @var Blaminhor_Essentials_Cache_Audit_Recommender */
	public $audit_recommender;

	/** @var Blaminhor_Essentials_Cache_Webvitals_Collector */
	public $webvitals_collector;

	/** @var Blaminhor_Essentials_Cache_Webvitals_Aggregator */
	public $webvitals_aggregator;

	/** @var Blaminhor_Essentials_Cache_Health_Check */
	public $health_check;

	/** @var Blaminhor_Essentials_Cache_Render_Watchdog */
	public $render_watchdog;

	/* ------------------------------------------------------------------ */
	/*  Activation hooks (called by Blaminhor_Essentials::activate_module) */
	/* ------------------------------------------------------------------ */

	/**
	 * Static pre-activation check. Refuses activation if another cache plugin
	 * is active to avoid double-cache disasters.
	 *
	 * Static so the orchestrator does not have to be instantiated for the test
	 * (instantiation has side effects: hooks, table creation, etc.).
	 *
	 * @return true|WP_Error True to proceed, WP_Error to refuse.
	 */
	public static function pre_activation_check() {
		require_once __DIR__ . '/includes/class-cache-conflict-detector.php';
		$detector  = new Blaminhor_Essentials_Cache_Conflict_Detector();
		$conflicts = $detector->detect_active_conflicts();

		if ( ! empty( $conflicts ) ) {
			$names = $detector->format_conflict_list( $conflicts );
			return new WP_Error(
				'cache_conflict',
				sprintf(
					/* translators: %s: comma-separated list of conflicting plugin names */
					__( 'Another cache plugin is active: %s. Please deactivate it before enabling Cache & Performance to avoid double-caching issues.', 'blaminhor-essentials' ),
					$names
				),
				array( 'conflicts' => $conflicts )
			);
		}
		return true;
	}

	/**
	 * Module-level activation: create tables, ensure dirs, install drop-in,
	 * write .htaccess blocks (browser cache, GZIP).
	 *
	 * @return true|WP_Error
	 */
	public function on_activate() {
		$this->maybe_create_tables();
		$this->storage->ensure_directories();

		// On heavy themes (Avada, Divi, …) the aggressive HTML/CSS rewriters
		// shipped on by default produce a visually broken cache. Flip them OFF
		// for first-time activation so the cache is usable out of the box.
		// Idempotent: subsequent activations respect the user's choices.
		$this->apply_safe_defaults_for_heavy_theme();

		// Install advanced-cache.php drop-in + flip WP_CACHE on (page cache enabled by default).
		// Force takeover: a residual foreign drop-in (e.g. WP Rocket left behind
		// after deactivation) gets backed up to advanced-cache.php.be-bak-{ts}
		// instead of blocking activation.
		if ( $this->get_setting( 'page_cache_enabled', true ) ) {
			$result = $this->dropin_installer->install( $this->dropin_install_params(), true );
			if ( is_wp_error( $result ) ) {
				// Surface a soft warning rather than aborting activation: the
				// module still works without the drop-in (slower path, no early
				// PHP bypass).
				blaminhor_essentials_log( 'Cache drop-in install failed: ' . $result->get_error_message(), 'warning' );
			}
		}

		// Sync .htaccess directive blocks for the current settings.
		$this->sync_htaccess();

		return true;
	}

	/**
	 * Heavy themes (Avada/Fusion, Divi) ship their own CSS compilers, inline
	 * JSON payloads and dynamic stylesheets. Stacking aggressive HTML/CSS
	 * optimisations on top tends to produce a visually broken cache —
	 * confirmed in production logs 2026-05 where the cached page lost the
	 * Avada parent stylesheet entirely once Critical CSS + async loading were
	 * combined with the theme's own dynamic CSS pipeline.
	 *
	 * One-shot, idempotent per stamp version. Bumping the stamp lets us
	 * extend the risky-default list in a later release and re-apply it to
	 * sites that already shipped the previous version.
	 *
	 * @return string|null Theme id when defaults were patched, null otherwise.
	 */
	public function apply_safe_defaults_for_heavy_theme() {
		// Module settings live at 'blaminhor_essentials_module_cache', NOT
		// at the base class' get_option_key() suffix — see load_settings()
		// in class-blaminhor-essentials-module.php.
		$option_key = 'blaminhor_essentials_module_' . $this->id;
		$settings   = (array) get_option( $option_key, array() );

		// v2 adds critical_css/critical_async/rucss to the risky list because
		// production reports on Avada Child themes show the parent stylesheet
		// disappearing once async-load swaps it out. The watchdog flagged the
		// breakage but its rollback couldn't recover because the page rendered
		// strictly via the child stylesheet (no `fusion-stylesheet` URL ever
		// present), tripping the Avada detector regardless of toggle state.
		$current_stamp = isset( $settings['safe_defaults_for_heavy_theme_v1'] )
			? (string) $settings['safe_defaults_for_heavy_theme_v1']
			: '';
		$current_v2    = isset( $settings['safe_defaults_for_heavy_theme_v2'] )
			? (string) $settings['safe_defaults_for_heavy_theme_v2']
			: '';
		if ( '' !== $current_v2 ) {
			return null;
		}

		$theme_id = $this->detect_heavy_theme();
		if ( null === $theme_id ) {
			$settings['safe_defaults_for_heavy_theme_v1'] = 'none';
			$settings['safe_defaults_for_heavy_theme_v2'] = 'none';
			update_option( $option_key, $settings, false );
			return null;
		}

		// v1 risky baseline — applied either on first activation or
		// retroactively here when only v2 still needs to run.
		$risky = array(
			'minify_html'                 => false,
			'minify_css'                  => false,
			'image_dimensions_enabled'    => false,
			'local_google_fonts_enabled'  => false,
			'auto_resource_hints_enabled' => false,
			// v2 additions — observed to break Avada/Divi out of the box.
			'critical_css_enabled'        => false,
			'critical_async_loading'      => false,
			'rucss_enabled'               => false,
		);
		foreach ( $risky as $key => $value ) {
			$settings[ $key ] = $value;
		}
		if ( '' === $current_stamp ) {
			$settings['safe_defaults_for_heavy_theme_v1'] = $theme_id;
		}
		$settings['safe_defaults_for_heavy_theme_v2'] = $theme_id;
		update_option( $option_key, $settings, false );

		// Clear any stale "unrecovered" notice — the user is now in a state
		// where the watchdog should pass on the next probe.
		delete_option( 'blaminhor_essentials_cache_watchdog_auto_disabled' );

		// Critical: purge every static HTML file the previous (broken)
		// configuration baked into the page cache. Without this the drop-in
		// keeps serving the unstyled version that triggered this safe-defaults
		// rollout in the first place — visible to anonymous visitors until
		// either the TTL expires or the user manually purges.
		if ( $this->purge && method_exists( $this->purge, 'purge_all' ) ) {
			$this->purge->purge_all();
		} else {
			$this->purge_pages_directly();
		}

		blaminhor_essentials_log(
			sprintf(
				'Cache: heavy theme detected (%s); safe-defaults v2 applied — minify_html, minify_css, image_dimensions, local_google_fonts, auto_resource_hints, critical_css, critical_async_loading and rucss turned off to prevent layout breakage. Page cache purged so anonymous visitors get the fresh HTML on the next request.',
				$theme_id
			),
			'info',
			'cache'
		);
		return $theme_id;
	}

	/**
	 * Direct disk-level purge of the page cache. Used when the Purge component
	 * is not yet bootstrapped (e.g. invoked from `apply_safe_defaults_for_heavy_theme`
	 * during early `init`).
	 */
	private function purge_pages_directly() {
		$base = WP_CONTENT_DIR . '/uploads/blaminhor-essentials/cache/pages';
		if ( ! is_dir( $base ) ) {
			return;
		}
		$it = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $base, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::CHILD_FIRST
		);
		foreach ( $it as $entry ) {
			$path = (string) $entry->getPathname();
			if ( $entry->isFile() ) {
				$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
				if ( in_array( $ext, array( 'html', 'gz', 'meta' ), true ) ) {
					@unlink( $path );
				}
			}
		}
	}

	/**
	 * @return string|null 'avada' | 'divi' | null
	 */
	private function detect_heavy_theme() {
		if ( ! function_exists( 'wp_get_theme' ) ) {
			return null;
		}
		$theme      = wp_get_theme();
		$template   = strtolower( (string) $theme->get_template() );
		$stylesheet = strtolower( (string) $theme->get_stylesheet() );

		if ( 'avada' === $template || 'avada' === $stylesheet || false !== strpos( $stylesheet, 'avada' ) ) {
			return 'avada';
		}
		if ( 'divi' === $template || 'divi' === $stylesheet || false !== strpos( $stylesheet, 'divi' ) ) {
			return 'divi';
		}
		return null;
	}

	/**
	 * Module-level deactivation: clean up safe-mode flag, htaccess sections, drop-in,
	 * WP_CACHE constant, and the entire pages cache.
	 *
	 * Note: BDD tables are NOT dropped here. They are removed only in
	 * uninstall.php if the user opted into "Delete all plugin data".
	 */
	public function on_deactivate() {
		// Remove drop-in + revert WP_CACHE.
		if ( $this->dropin_installer ) {
			$this->dropin_installer->uninstall();
		}
		// Wipe pages cache so a re-activation gives a fresh state.
		if ( $this->purge ) {
			$this->purge->purge_all();
		}
		// Remove .safe_mode flag if present.
		if ( $this->safe_mode ) {
			$this->safe_mode->disable();
		}
		// Remove all our htaccess blocks.
		if ( $this->htaccess_writer ) {
			$this->htaccess_writer->remove_all( $this->managed_htaccess_blocks() );
		}
		// Cancel the render watchdog cron events so the deactivated plugin
		// doesn't keep firing them.
		if ( $this->render_watchdog ) {
			$this->render_watchdog->on_deactivate();
		}
	}

	/**
	 * Retry the WP_CACHE define on every admin page load when it should be
	 * present but isn't. Cheap enough to run on admin_init and lets the plugin
	 * recover from a hosting environment that fooled the activation-time
	 * write (e.g. a transient permission glitch). Bails on AJAX, autosave,
	 * and CLI-driven contexts to avoid spurious writes.
	 */
	/**
	 * Reinstall the drop-in template when the bundled DROPIN_VERSION has been
	 * bumped in a plugin release. Lets ship in-drop-in changes (new sentinel
	 * files, new bypass rules, etc.) without asking the user to toggle a
	 * setting first. Idempotent and silent.
	 */
	public function maybe_upgrade_dropin() {
		if ( wp_doing_ajax() || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
			return;
		}
		if ( ! $this->dropin_installer || ! $this->dropin_installer->is_outdated() ) {
			return;
		}
		if ( ! $this->get_setting( 'page_cache_enabled', true ) ) {
			return;
		}
		$this->dropin_installer->install( $this->dropin_install_params() );
	}

	/**
	 * Register a single direct test in WordPress' Site Health screen
	 * (Tools → Site Health). The test reuses the embedded diagnostic so the
	 * admin can see at-a-glance whether the cache is in good shape, even
	 * without opening the Cache module.
	 *
	 * @param array $tests
	 * @return array
	 */
	public function register_site_health_tests( $tests ) {
		if ( ! is_array( $tests ) ) {
			$tests = array();
		}
		$tests['direct']['blaminhor_essentials_cache'] = array(
			'label' => __( 'Cache & Performance — health check', 'blaminhor-essentials' ),
			'test'  => array( $this, 'site_health_run_diagnostic' ),
		);
		return $tests;
	}

	/**
	 * Run the embedded diagnostic and translate the result into the format
	 * Site Health expects (label, status, badge, description, actions).
	 *
	 * @return array
	 */
	public function site_health_run_diagnostic() {
		$base = array(
			'label'       => __( 'Cache & Performance is healthy', 'blaminhor-essentials' ),
			'status'      => 'good',
			'badge'       => array(
				'label' => __( 'Performance', 'blaminhor-essentials' ),
				'color' => 'blue',
			),
			'description' => '<p>' . esc_html__( 'Every internal check of the Cache module passed. The page cache, the .htaccess sections, the purge cycle, the Web Vitals endpoint and the scheduled tasks are all working as expected.', 'blaminhor-essentials' ) . '</p>',
			'actions'     => '<p><a href="' . esc_url( admin_url( 'admin.php?page=blaminhor-essentials-cache#advanced' ) ) . '">' . esc_html__( 'Open the full diagnostic report', 'blaminhor-essentials' ) . '</a></p>',
			'test'        => 'blaminhor_essentials_cache',
		);

		if ( ! $this->health_check ) {
			return $base;
		}
		$report  = $this->health_check->run();
		$summary = isset( $report['summary'] ) && is_array( $report['summary'] ) ? $report['summary'] : array();
		$fail    = isset( $summary['fail'] ) ? (int) $summary['fail'] : 0;
		$warn    = isset( $summary['warn'] ) ? (int) $summary['warn'] : 0;
		$pass    = isset( $summary['pass'] ) ? (int) $summary['pass'] : 0;

		if ( $fail > 0 ) {
			$base['status']      = 'critical';
			$base['label']       = __( 'Cache & Performance — issues detected', 'blaminhor-essentials' );
			$base['description'] = '<p>' . esc_html(
				sprintf(
					/* translators: 1: failed checks, 2: warning checks, 3: passed checks */
					__( 'The Cache module diagnostic reports %1$d failed check(s), %2$d warning(s) and %3$d passed check(s). The cache may not be serving correctly until these are resolved.', 'blaminhor-essentials' ),
					$fail,
					$warn,
					$pass
				)
			) . '</p>';
		} elseif ( $warn > 0 ) {
			$base['status']      = 'recommended';
			$base['label']       = __( 'Cache & Performance — minor warnings', 'blaminhor-essentials' );
			$base['description'] = '<p>' . esc_html(
				sprintf(
					/* translators: 1: warning checks, 2: passed checks */
					__( 'The Cache module is working but reports %1$d warning(s) you may want to look at. %2$d check(s) passed.', 'blaminhor-essentials' ),
					$warn,
					$pass
				)
			) . '</p>';
		}

		return $base;
	}

	public function maybe_retry_wp_cache_define() {
		if ( wp_doing_ajax() ) {
			return;
		}
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! $this->get_setting( 'page_cache_enabled', true ) ) {
			return;
		}
		if ( defined( 'WP_CACHE' ) && WP_CACHE ) {
			return;
		}
		if ( ! $this->dropin_installer ) {
			return;
		}
		// One try per request — silent. If wp-config really can't be written,
		// the notice on the Page Cache tab tells the user how to add the line.
		$this->dropin_installer->ensure_wp_cache_constant( true );
	}

	/**
	 * Resolve drop-in installation parameters from current settings.
	 *
	 * Public so the plugin bootstrap can re-install the drop-in on a detected
	 * version bump without going through the admin upgrade hook (which never
	 * fires for anonymous visitors).
	 *
	 * @return array
	 */
	public function dropin_install_params() {
		return array(
			'cache_dir'       => untrailingslashit( $this->storage->pages_dir() ),
			'safe_flag'       => $this->storage->safe_mode_flag_path(),
			'ttl'             => (int) $this->get_setting( 'cache_ttl', 86400 ),
			'mobile_separate' => (bool) $this->get_setting( 'cache_mobile_separate', false ),
		);
	}

	/**
	 * List of htaccess block IDs we are responsible for. Used by on_deactivate().
	 *
	 * @return string[]
	 */
	private function managed_htaccess_blocks() {
		return array(
			'page-cache',
			'browser-cache',
			'gzip',
		);
	}

	/* ------------------------------------------------------------------ */
	/*  Module bootstrap                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Initialize module — loads sub-classes and instantiates components.
	 */
	protected function init() {
		// Translatable strings (resolved at runtime).
		$this->name        = __( 'Cache & Performance', 'blaminhor-essentials' );
		$this->description = __( 'Page cache, asset optimization, lazy load, performance audit, and Web Vitals dashboard.', 'blaminhor-essentials' );

		$this->load_dependencies();

		// Build component graph. Order matters: storage depends on multisite,
		// safe_mode depends on storage, page_cache + dropin_installer depend
		// on storage + safe_mode + environment.
		$this->environment       = new Blaminhor_Essentials_Cache_Environment();
		$this->multisite         = new Blaminhor_Essentials_Cache_Multisite();
		$this->storage           = new Blaminhor_Essentials_Cache_Storage( $this->multisite );
		$this->safe_mode         = new Blaminhor_Essentials_Cache_Safe_Mode( $this->storage );
		$this->htaccess_writer   = new Blaminhor_Essentials_Cache_Htaccess_Writer();
		$this->conflict_detector = new Blaminhor_Essentials_Cache_Conflict_Detector();
		$this->dropin_installer  = new Blaminhor_Essentials_Cache_Dropin_Installer( $this->storage, $this->environment );
		$this->page_cache        = new Blaminhor_Essentials_Cache_Page_Cache( $this, $this->storage, $this->safe_mode );
		$this->purge             = new Blaminhor_Essentials_Cache_Purge( $this->storage );
		$this->heartbeat         = new Blaminhor_Essentials_Cache_Heartbeat( $this );
		$this->css_optimizer     = new Blaminhor_Essentials_Cache_Css_Optimizer( $this );
		$this->js_optimizer      = new Blaminhor_Essentials_Cache_Js_Optimizer( $this );
		$this->image_dimensions   = new Blaminhor_Essentials_Cache_Image_Dimensions( $this );
		$this->local_google_fonts  = new Blaminhor_Essentials_Cache_Local_Google_Fonts( $this );
		$this->auto_resource_hints = new Blaminhor_Essentials_Cache_Auto_Resource_Hints( $this );
		$this->host_detector       = new Blaminhor_Essentials_Cache_Host_Detector();
		$this->cloudflare          = new Blaminhor_Essentials_Cache_Cloudflare( $this );
		$this->theme_compat        = new Blaminhor_Essentials_Cache_Theme_Compat_Manager( $this );
		$this->media_lazyload      = new Blaminhor_Essentials_Cache_Media_Lazyload( $this );
		$this->resource_hints    = new Blaminhor_Essentials_Cache_Resource_Hints( $this );
		$this->cdn_rewriter      = new Blaminhor_Essentials_Cache_Cdn_Rewriter( $this );
		$this->preloader         = new Blaminhor_Essentials_Cache_Preloader( $this );
		$this->critical_css      = new Blaminhor_Essentials_Cache_Critical_Css( $this );
		$this->audit_psi         = new Blaminhor_Essentials_Cache_Audit_PSI( $this );
		$this->audit_internal    = new Blaminhor_Essentials_Cache_Audit_Internal( $this );
		$this->audit_recommender = new Blaminhor_Essentials_Cache_Audit_Recommender( $this );
		$this->webvitals_collector  = new Blaminhor_Essentials_Cache_Webvitals_Collector( $this );
		$this->webvitals_aggregator = new Blaminhor_Essentials_Cache_Webvitals_Aggregator( $this );
		$this->health_check         = new Blaminhor_Essentials_Cache_Health_Check( $this );
		$this->render_watchdog      = new Blaminhor_Essentials_Cache_Render_Watchdog( $this );

		$this->maybe_create_tables();
	}

	/**
	 * @return string Absolute path of the module's includes directory.
	 */
	private function includes_dir() {
		return __DIR__ . '/includes/';
	}

	/**
	 * Load required PHP files. Components are require_once'd up-front to keep
	 * autoload-free and predictable (matches existing module conventions).
	 */
	private function load_dependencies() {
		$base = $this->includes_dir();
		require_once $base . 'class-cache-environment.php';
		require_once $base . 'class-cache-multisite.php';
		require_once $base . 'class-cache-storage.php';
		require_once $base . 'class-cache-safe-mode.php';
		require_once $base . 'class-cache-htaccess-writer.php';
		require_once $base . 'class-cache-conflict-detector.php';
		require_once $base . 'class-cache-dropin-installer.php';
		require_once $base . 'class-cache-pages-index.php';
		require_once $base . 'class-cache-page-cache.php';
		require_once $base . 'class-cache-purge.php';
		require_once $base . 'class-cache-html-minifier.php';
		require_once $base . 'class-cache-heartbeat.php';
		require_once $base . 'class-cache-css-optimizer.php';
		require_once $base . 'class-cache-js-optimizer.php';
		require_once $base . 'class-cache-image-dimensions.php';
		require_once $base . 'class-cache-local-google-fonts.php';
		require_once $base . 'class-cache-auto-resource-hints.php';
		require_once $base . 'class-cache-host-detector.php';
		require_once $base . 'class-cache-cloudflare.php';
		require_once $base . 'theme-compat/class-theme-compat-base.php';
		require_once $base . 'theme-compat/class-theme-compat-manager.php';
		require_once $base . 'class-cache-media-lazyload.php';
		require_once $base . 'class-cache-resource-hints.php';
		require_once $base . 'class-cache-cdn-rewriter.php';
		require_once $base . 'class-cache-preloader.php';
		require_once $base . 'class-cache-critical-css.php';
		require_once $base . 'class-cache-audit-psi.php';
		require_once $base . 'class-cache-audit-internal.php';
		require_once $base . 'class-cache-audit-recommender.php';
		require_once $base . 'class-cache-webvitals-collector.php';
		require_once $base . 'class-cache-webvitals-aggregator.php';
		require_once $base . 'class-cache-health-check.php';
		require_once $base . 'class-cache-render-watchdog.php';
	}

	/**
	 * Whether runtime HTML/CSS/JS optimisations must be SKIPPED for the current
	 * request.
	 *
	 * The page-cache uses a similar gate, but only to decide whether to WRITE
	 * the cache file. This helper is the gate for the OTHER family of mutations:
	 * the ones that change what the visitor receives in real time —
	 *   - style_loader_tag → async CSS preload swap
	 *   - script_loader_tag → defer / delay-until-interaction
	 *   - blaminhor_essentials_cache_html_buffer → lazy load, YouTube facade,
	 *     image dimensions injection, CDN rewrite, Google Fonts swap, resource
	 *     hints, HTML minify
	 *   - wp_head injectors → critical CSS, async-loader polyfill
	 *
	 * The most common reason this returns true is "the visitor is logged in
	 * and the user has not opted-in to caching authenticated traffic". Without
	 * this gate, an administrator logging into a production site receives the
	 * same transformed HTML as anonymous visitors, which both (a) breaks the
	 * standard "log in to see the site as-is" debug workflow that every host
	 * support page tells admins to use, and (b) hides page-builder regressions
	 * (Elementor Pro, Avada, Divi...) from the very person who could fix them.
	 *
	 * @return bool True when the request must be served WITHOUT runtime
	 *              optimisations, false when optimisations may run.
	 */
	public function should_skip_runtime_optimizations() {
		$skip = false;

		if ( defined( 'BE_CACHE_DISABLE_OPTIMIZATIONS' ) && BE_CACHE_DISABLE_OPTIMIZATIONS ) {
			$skip = true;
		} elseif ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			$skip = true;
		} elseif ( is_admin() ) {
			$skip = true;
		} elseif ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
			$skip = true;
		} elseif ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			$skip = true;
		} elseif ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) {
			$skip = true;
		} elseif ( function_exists( 'is_feed' ) && is_feed() ) {
			$skip = true;
		} elseif ( is_user_logged_in() && ! $this->get_setting( 'cache_logged_in', false ) ) {
			$skip = true;
		}

		/**
		 * Filter the runtime optimisation bypass decision.
		 *
		 * Return true to force-bypass all CSS/JS/HTML transformations for the
		 * current request. Useful when 'cache_logged_in' is enabled site-wide
		 * (e.g. on a membership site that wants logged-in members to benefit
		 * from optimisations) while still bypassing for one specific role like
		 * 'shop_manager' that needs to debug.
		 *
		 * @param bool $skip Decision computed by the helper.
		 */
		return (bool) apply_filters( 'blaminhor_essentials_cache_skip_runtime_optimizations', $skip );
	}

	/**
	 * Register WordPress hooks owned by this module.
	 */
	protected function register_hooks() {
		// One-shot retroactive safe-defaults patch for sites that activated the
		// module before this protection existed (≤ 1.9.8.13). Idempotent —
		// stamped flag in settings short-circuits subsequent calls.
		add_action( 'init', array( $this, 'apply_safe_defaults_for_heavy_theme' ), 1 );

		add_action( 'admin_init', array( $this, 'handle_settings_submission' ) );
		// Best-effort retry of the WP_CACHE define if it's missing while the
		// page cache is enabled (some hosts ship wp-config.php with perms that
		// fool a single-shot write at activation time; same approach as WP
		// Rocket's `maybe_set_wp_cache`).
		add_action( 'admin_init', array( $this, 'maybe_retry_wp_cache_define' ) );
		// Auto-upgrade the drop-in when its DROPIN_VERSION constant has been
		// bumped in a plugin release (e.g. new sentinel checks added).
		add_action( 'admin_init', array( $this, 'maybe_upgrade_dropin' ) );
		// WordPress Site Health integration: surface our diagnostic in
		// Tools → Site Health so administrators see cache issues alongside
		// the rest of WP's health overview, without having to dig into the
		// plugin tab.
		add_filter( 'site_status_tests', array( $this, 'register_site_health_tests' ) );
		// Settings-page assets are loaded by the parent's enqueue_assets($hook)
		// dispatch (see class-blaminhor-essentials-admin.php).
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		// Slim admin-bar JS, loaded everywhere the admin bar is visible so
		// "Purge cache" works site-wide (admin or frontend).
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_bar_bridge' ), 20 );
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_admin_bar_bridge' ), 20 );

		// AJAX endpoints.
		add_action( 'wp_ajax_ap_cache_safe_mode_toggle', array( $this, 'ajax_safe_mode_toggle' ) );
		add_action( 'wp_ajax_ap_cache_get_stats', array( $this, 'ajax_get_stats' ) );
		add_action( 'wp_ajax_ap_cache_purge_all', array( $this, 'ajax_purge_all' ) );
		add_action( 'wp_ajax_ap_cache_purge_url', array( $this, 'ajax_purge_url' ) );
		add_action( 'wp_ajax_ap_cache_preload_now', array( $this, 'ajax_preload_now' ) );
		add_action( 'wp_ajax_ap_cache_preload_start', array( $this, 'ajax_preload_start' ) );
		add_action( 'wp_ajax_ap_cache_preload_warm_one', array( $this, 'ajax_preload_warm_one' ) );
		add_action( 'wp_ajax_ap_cache_preload_status', array( $this, 'ajax_preload_status' ) );
		add_action( 'wp_ajax_ap_cache_pages_index_list', array( $this, 'ajax_pages_index_list' ) );
		add_action( 'wp_ajax_ap_cache_resync_dropin', array( $this, 'ajax_resync_dropin' ) );

		// Cloudflare integration AJAX endpoints (Dev Mode, recommended settings,
		// snapshot restore, token capability probe). All require manage_options
		// + ap_cache nonce, enforced via verify_ajax_request() in each handler.
		add_action( 'wp_ajax_ap_cache_cf_dev_mode_toggle', array( $this, 'ajax_cf_dev_mode_toggle' ) );
		add_action( 'wp_ajax_ap_cache_cf_apply_recommended', array( $this, 'ajax_cf_apply_recommended' ) );
		add_action( 'wp_ajax_ap_cache_cf_reset_settings', array( $this, 'ajax_cf_reset_settings' ) );
		add_action( 'wp_ajax_ap_cache_cf_capabilities', array( $this, 'ajax_cf_capabilities' ) );

		// Settings export / import. Lets the user move a tuned configuration
		// between sites, share it with support, or restore after a misconfigured
		// session. JSON envelope only — no asset files (critical CSS sheets are
		// site-specific and are regenerated locally).
		add_action( 'wp_ajax_ap_cache_export_settings', array( $this, 'ajax_export_settings' ) );
		add_action( 'wp_ajax_ap_cache_import_settings', array( $this, 'ajax_import_settings' ) );

		add_action( 'wp_ajax_ap_cache_audit_run', array( $this, 'ajax_audit_run' ) );
		add_action( 'wp_ajax_ap_cache_audit_save_psi_key', array( $this, 'ajax_audit_save_psi_key' ) );
		add_action( 'wp_ajax_ap_cache_audit_test_psi_key', array( $this, 'ajax_audit_test_psi_key' ) );

		add_action( 'wp_ajax_ap_cache_health_check', array( $this, 'ajax_health_check' ) );

		// Admin bar purge button.
		add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_purge' ), 200 );

		// Re-sync drop-in if settings change after activation.
		add_action( 'update_option_blaminhor_essentials_module_cache', array( $this, 'on_settings_updated' ), 10, 2 );

		// Conflict awareness — surface a notice and refuse to wire HTML optimisation
		// hooks when a competing cache plugin / foreign drop-in is in charge.
		add_action( 'admin_notices', array( $this, 'maybe_render_conflict_notice' ) );
		// One-shot success notice after the auto-takeover lifted a previous suspension.
		add_action( 'admin_notices', array( $this, 'maybe_render_takeover_notice' ) );
		// Re-detect conflicts each time a plugin is activated/deactivated so the
		// admin notice reflects reality without requiring a manual page refresh.
		add_action( 'activated_plugin', array( $this, 'on_plugin_state_changed' ) );
		add_action( 'deactivated_plugin', array( $this, 'on_plugin_state_changed' ), 10, 2 );

		// Auto-takeover: when a foreign drop-in is the only obstacle (no other
		// cache plugin actually active), reclaim wp-content/advanced-cache.php
		// silently. Prevents the "I disabled WP Rocket but BE stays suspended"
		// trap users hit when WP Rocket leaves its drop-in behind.
		add_action( 'admin_init', array( $this, 'maybe_take_over_foreign_dropin' ), 5 );

		// Component-owned hooks.
		$this->safe_mode->register_hooks();
		$this->purge->register_hooks();
		$this->heartbeat->register_hooks();
		$this->webvitals_collector->register_hooks();
		$this->webvitals_aggregator->register_hooks();
		// Render watchdog runs even when optimisations are suspended (so the
		// daily cron + manual UI run still work). The probe targets the live
		// site whether BE is buffering or not.
		$this->render_watchdog->register_hooks();

		// HTML optimisation hooks — gated by the master toggle so the user can
		// stop ALL cache & performance mutations with a single switch. Two
		// gates:
		//   1. Conflict detector: another active cache plugin would cause double
		//      filters / double output buffers; we suspend in that case and let
		//      the user pick which plugin is in charge via an admin notice.
		//   2. page_cache_enabled: the master toggle of the Cache & Performance
		//      module. When OFF, none of the per-feature toggles below take
		//      effect — no defer JS, no minify, no critical CSS, no lazyload,
		//      etc. Per-feature toggles remain wired and respected when the
		//      master is ON.
		if ( ! $this->conflict_detector->should_suspend_optimizations()
			&& $this->get_setting( 'page_cache_enabled', true ) ) {
			$this->page_cache->register_hooks();
			$this->css_optimizer->register_hooks();
			$this->js_optimizer->register_hooks();
			$this->image_dimensions->register_hooks();
			$this->local_google_fonts->register_hooks();
			$this->auto_resource_hints->register_hooks();
			$this->cloudflare->register_hooks();
			$this->theme_compat->register_hooks();
			$this->media_lazyload->register_hooks();
			$this->resource_hints->register_hooks();
			$this->cdn_rewriter->register_hooks();
			$this->preloader->register_hooks();
			$this->critical_css->register_hooks();
		}
	}

	/**
	 * Render an admin notice on every plugin admin screen when a competing
	 * cache plugin / foreign drop-in is active. Includes a one-click link to
	 * deactivate the Cache module so the user can resolve the coexistence
	 * without leaving the screen.
	 */
	public function maybe_render_conflict_notice() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$reason = $this->conflict_detector->get_suspension_reason();
		if ( '' === $reason ) {
			return;
		}
		$deactivate_url = wp_nonce_url(
			add_query_arg(
				array(
					'page'   => 'blaminhor-essentials',
					'action' => 'toggle_module',
					'module' => $this->id,
					'enable' => '0',
				),
				admin_url( 'admin.php' )
			),
			'blaminhor_essentials_toggle_module_' . $this->id
		);
		?>
		<div class="notice notice-warning">
			<p>
				<strong><?php esc_html_e( 'Cache & Performance — module suspended.', 'blaminhor-essentials' ); ?></strong>
				<?php echo esc_html( $reason ); ?>
				<?php esc_html_e( 'To avoid double-caching and broken renders, all HTML optimisations from this module are disabled while the conflict persists. Pick one cache solution and deactivate the other.', 'blaminhor-essentials' ); ?>
			</p>
			<p>
				<a href="<?php echo esc_url( admin_url( 'plugins.php' ) ); ?>" class="button">
					<?php esc_html_e( 'Open the Plugins screen', 'blaminhor-essentials' ); ?>
				</a>
				<a href="<?php echo esc_url( $deactivate_url ); ?>" class="button">
					<?php esc_html_e( 'Disable the Cache module', 'blaminhor-essentials' ); ?>
				</a>
			</p>
		</div>
		<?php
	}

	/**
	 * Re-evaluate conflict state on plugin activation/deactivation. When a
	 * known cache plugin gets deactivated we proactively try to take over
	 * its leftover advanced-cache.php drop-in so the user does not have to
	 * touch SVN/SFTP to recover. Cheap and safe: install() is a no-op when
	 * the drop-in is already ours.
	 *
	 * @param string $plugin_file Plugin slug (relative to plugins dir).
	 */
	public function on_plugin_state_changed( $plugin_file ) {
		$known = $this->conflict_detector->get_known_cache_plugins();
		if ( ! isset( $known[ $plugin_file ] ) ) {
			return;
		}
		// Force the takeover synchronously: a deactivation hook is the right
		// moment because the foreign plugin's deactivation routine has just
		// run, so a leftover drop-in is now safe to backup and replace.
		if ( $this->get_setting( 'page_cache_enabled', true ) ) {
			$this->dropin_installer->install( $this->dropin_install_params(), true );
		}
	}

	/**
	 * On every admin page load, if a foreign drop-in is sitting in
	 * wp-content/advanced-cache.php while NO other cache plugin is active,
	 * reclaim it. This is the safety net for the deactivation path: not all
	 * plugins call register_deactivation_hook, and a `deactivated_plugin`
	 * may have run before we wired our handler (race on first 1.9.6 load).
	 */
	public function maybe_take_over_foreign_dropin() {
		if ( wp_doing_ajax() ) {
			return;
		}
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! $this->get_setting( 'page_cache_enabled', true ) ) {
			return;
		}
		if ( ! $this->conflict_detector->needs_dropin_takeover() ) {
			return;
		}
		$result = $this->dropin_installer->install( $this->dropin_install_params(), true );
		if ( ! is_wp_error( $result ) ) {
			// Surface a one-time "we took over" notice so the user understands
			// why the previous suspension banner has disappeared.
			set_transient( 'blaminhor_essentials_cache_takeover_notice', '1', 60 );
		}
	}

	/**
	 * Render a transient "drop-in takeover succeeded" notice once after the
	 * auto-takeover kicked in.
	 */
	public function maybe_render_takeover_notice() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( '1' !== get_transient( 'blaminhor_essentials_cache_takeover_notice' ) ) {
			return;
		}
		delete_transient( 'blaminhor_essentials_cache_takeover_notice' );
		?>
		<div class="notice notice-success is-dismissible">
			<p>
				<strong><?php esc_html_e( 'Cache & Performance — drop-in reclaimed.', 'blaminhor-essentials' ); ?></strong>
				<?php esc_html_e( 'A leftover advanced-cache.php drop-in from a previous cache plugin was backed up next to it (.be-bak-{timestamp}) and replaced by ours. Page caching is now active again.', 'blaminhor-essentials' ); ?>
			</p>
		</div>
		<?php
	}

	/* ------------------------------------------------------------------ */
	/*  Default settings                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Default settings for ALL tabs. Components read what they need.
	 *
	 * @return array
	 */
	protected function get_default_settings() {
		return array(
			// Page Cache.
			'page_cache_enabled'       => true,
			'cache_ttl'                => 86400,
			'cache_mobile_separate'    => false,
			'cache_logged_in'          => false,
			// Browser Cache & GZIP.
			'browser_cache_enabled'    => true,
			'gzip_enabled'             => true,
			// Heartbeat.
			'heartbeat_frontend'       => 'disabled',
			'heartbeat_admin'          => 'throttle',
			'heartbeat_edit'           => 'enabled',
			// HTML.
			'minify_html'              => true,
			// CSS.
			'minify_css'               => true,
			'css_exclude'              => '',
			// JS.
			'defer_js'                 => false,
			'delay_js'                 => false,
			'delay_js_exclude'         => "gtag\nanalytics\ngtm\nfbevents",
			'js_exclude'               => "jquery\njquery-core\njquery-migrate\nwp-\nelementor\nadmin-bar\nbackbone\nmarionette\nunderscore\nlodash\nimagesloaded\nmasonry",
			// Media.
			'image_dimensions_enabled' => true,
			'lazy_load_images'         => true,
			'lazy_load_iframes'        => true,
			'youtube_facade'           => true,
			'lazy_exclude_classes'     => '',
			// Preload.
			'preload_sitemap_enabled'  => false,
			'preload_sitemap_url'      => '',
			'preload_batch_size'       => 30,
			'preload_links_hover'      => true,
			'fonts_display_swap'       => true,
			'local_google_fonts_enabled'  => true,
			'auto_resource_hints_enabled' => true,
			'cloudflare_enabled'          => false,
			'cloudflare_api_token'        => '',
			'cloudflare_zone_id'          => '',
			'theme_compat_disabled'       => array(),
			// Resource hints.
			'preconnect_domains'       => "https://fonts.gstatic.com\nhttps://fonts.googleapis.com",
			'dns_prefetch_domains'     => '',
			'preload_fonts'            => '',
			// CDN.
			'cdn_enabled'              => false,
			'cdn_url'                  => '',
			'cdn_excluded_extensions'  => '',
			// Critical CSS / RUCSS.
			'critical_css_enabled'     => false,
			'critical_async_loading'   => false,
			'rucss_enabled'            => false,
			// Defaults cover the most common dynamic-class patterns we know to be
			// JS-driven or framework-injected. Removing any of these tends to
			// break Gutenberg, Elementor, WPML, WooCommerce — the long tail that
			// makes RUCSS dangerous on a fresh activation.
			'rucss_safelist'           => implode(
				"\n",
				array(
					// Generic JS state classes (modals, dropdowns, sliders, …).
					'.is-*',
					'.has-*',
					'.js-*',
					// Default user picks (modals/popups/menus).
					'.modal',
					'.popup',
					'.tooltip',
					'.dropdown',
					'.menu-mobile',
					// Gutenberg blocks (dynamic inline styles / data attrs).
					'.wp-block-*',
					// Elementor frontend & widgets.
					'.elementor-*',
					'.e-*',
					// WPML / Polylang language switcher injections.
					'.lang-*',
					'.wpml-*',
					// WooCommerce dynamic states.
					'.product-added-to-cart',
					'.variations_loaded',
					'.woocommerce-*',
					// Common admin-bar leakage when user logs out without purge.
					'#wpadminbar',
					'.admin-bar',
				)
			),
			// Audit (`audit_psi_enabled` / `audit_internal_enabled` are coupled
			// to `audit_schedule` — exposing them as separate flags only invited
			// dead-code questions; the schedule selector already gates everything).
			'audit_schedule'           => 'never',
			// Web Vitals RUM.
			'webvitals_enabled'        => false,
			'webvitals_sample_rate'    => 100,
			'webvitals_retention_days' => 30,
			// Advanced — exclusions.
			'exclude_urls'             => '',
			'exclude_cookies'          => '',
			'exclude_user_agents'      => '',
			// Persisted UI state.
			'active_tab'               => 'page-cache',
		);
	}

	/* ------------------------------------------------------------------ */
	/*  Database schema                                                   */
	/* ------------------------------------------------------------------ */

	/**
	 * Create or upgrade the Web Vitals RUM tables. Idempotent via dbDelta().
	 *
	 * Two tables:
	 *   - {prefix}be_web_vitals      (raw samples, retention configurable)
	 *   - {prefix}be_web_vitals_p75  (daily aggregates, used for fast charts)
	 */
	private function maybe_create_tables() {
		$installed = get_option( 'blaminhor_essentials_cache_db_version', '0' );
		if ( version_compare( $installed, $this->db_version, '>=' ) ) {
			return;
		}

		global $wpdb;
		$charset = $wpdb->get_charset_collate();
		$t_raw   = $wpdb->prefix . 'be_web_vitals';
		$t_p75   = $wpdb->prefix . 'be_web_vitals_p75';

		$sql_raw = "CREATE TABLE {$t_raw} (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			blog_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
			url_hash CHAR(40) NOT NULL,
			url VARCHAR(2048) NOT NULL,
			device VARCHAR(10) NOT NULL,
			metric VARCHAR(10) NOT NULL,
			value DOUBLE NOT NULL,
			rating VARCHAR(10) DEFAULT NULL,
			navigation_type VARCHAR(20) DEFAULT NULL,
			user_agent_hash CHAR(40) DEFAULT NULL,
			recorded_at DATETIME NOT NULL,
			PRIMARY KEY  (id),
			KEY metric_recorded (metric, recorded_at),
			KEY url_metric (url_hash, metric),
			KEY device_metric (device, metric),
			KEY blog_id (blog_id)
		) {$charset};";

		$sql_p75 = "CREATE TABLE {$t_p75} (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			blog_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
			period_date DATE NOT NULL,
			url_hash CHAR(40) NOT NULL,
			url VARCHAR(2048) NOT NULL,
			device VARCHAR(10) NOT NULL,
			metric VARCHAR(10) NOT NULL,
			p75_value DOUBLE NOT NULL,
			sample_count INT UNSIGNED NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY u_period_url_dev_metric (period_date, url_hash, device, metric, blog_id),
			KEY period_metric (period_date, metric)
		) {$charset};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql_raw );
		dbDelta( $sql_p75 );

		update_option( 'blaminhor_essentials_cache_db_version', $this->db_version );
	}

	/* ------------------------------------------------------------------ */
	/*  Admin assets                                                      */
	/* ------------------------------------------------------------------ */

	/**
	 * Enqueue cache-admin.js + inline config.
	 *
	 * Called by the parent admin's enqueue_assets() ONLY on plugin pages.
	 *
	 * @param string $hook Current admin page hook.
	 */
	public function enqueue_assets( $hook ) {
		if ( false === strpos( (string) $hook, 'blaminhor-essentials-cache' ) ) {
			return;
		}
		$this->register_cache_admin_script();
	}

	/**
	 * Slim companion that loads the script on every admin/frontend screen
	 * where the admin bar is visible, so the "Purge cache" admin-bar button
	 * works site-wide. Settings-page-specific bindings gate themselves on
	 * the presence of `.be-cache-*` elements.
	 */
	public function enqueue_admin_bar_bridge() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! is_admin() && ! is_admin_bar_showing() ) {
			return;
		}
		// Avoid double-registration when the parent already enqueued us.
		if ( wp_script_is( 'blaminhor-essentials-cache-admin', 'enqueued' ) || wp_script_is( 'blaminhor-essentials-cache-admin', 'registered' ) ) {
			return;
		}
		$this->register_cache_admin_script();
	}

	/**
	 * Single source of truth for the cache-admin.js enqueue + localization.
	 */
	private function register_cache_admin_script() {
		wp_enqueue_script(
			'blaminhor-essentials-cache-admin',
			BLAMINHOR_ESSENTIALS_PLUGIN_URL . 'assets/js/cache-admin.js',
			array( 'jquery' ),
			BLAMINHOR_ESSENTIALS_VERSION,
			true
		);

		wp_localize_script( 'blaminhor-essentials-cache-admin', 'blaminhorEssentialsCache', array(
			'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
			'nonce'        => wp_create_nonce( 'blaminhor_essentials_admin' ),
			'settingsUrl'  => admin_url( 'admin.php?page=blaminhor-essentials-' . $this->id ),
			'collectorUrl' => BLAMINHOR_ESSENTIALS_PLUGIN_URL . 'assets/js/frontend/critical-collector.js',
			// True only when the admin has explicitly enabled log collection in
			// Blaminhor → Logs. JS uses this to decide whether to ping the
			// client-side log AJAX endpoint at all.
			'logsEnabled'  => class_exists( 'Blaminhor_Essentials_Logs' ) && Blaminhor_Essentials_Logs::instance()->is_enabled(),
			// Last persisted audits (per engine). The Audit tab renders these
			// at page load so the user sees the most recent score without
			// having to re-run the audit. PSI takes precedence over Internal
			// when both exist (PSI is the authoritative metric).
			'lastAudit'    => $this->get_last_audits_for_render(),
			'strings'     => array(
				'safeModeOn'        => __( 'Safe mode is ON. The cache is fully bypassed.', 'blaminhor-essentials' ),
				'safeModeOff'       => __( 'Safe mode is OFF. The cache serves normally.', 'blaminhor-essentials' ),
				'confirmEnableSafe' => __( 'Bypass the cache for everyone? You can re-enable it from this page.', 'blaminhor-essentials' ),
				'confirmPurgeAll'   => __( 'Purge the entire page cache now?', 'blaminhor-essentials' ),
				'genericError'      => __( 'An error occurred. Please try again.', 'blaminhor-essentials' ),
				'loading'           => __( 'Loading…', 'blaminhor-essentials' ),
				'healthRun'         => __( 'Run health check', 'blaminhor-essentials' ),
				'healthRunning'     => __( 'Running…', 'blaminhor-essentials' ),
				'healthCopied'      => __( 'Copied!', 'blaminhor-essentials' ),
				// Critical CSS extractor — per-row feedback (no page reload).
				'generating'        => __( 'Generating…', 'blaminhor-essentials' ),
				'allDone'           => __( 'Done.', 'blaminhor-essentials' ),
				'skipped'           => __( 'Skipped', 'blaminhor-essentials' ),
				'notGenerated'      => __( 'Not generated', 'blaminhor-essentials' ),
				'justNow'           => __( 'just now', 'blaminhor-essentials' ),
				'confirmClearAll'   => __( 'Clear ALL critical CSS files?', 'blaminhor-essentials' ),
				// Audit — engine labels, group titles, and empty-state.
				'runningInternal'    => __( 'Running internal audit…', 'blaminhor-essentials' ),
				'runningPsi'         => __( 'Running PageSpeed Insights audit…', 'blaminhor-essentials' ),
				'auditInternalLabel' => __( 'Built-in audit', 'blaminhor-essentials' ),
				'noRecommendations'  => __( 'No actionable recommendations — looking good.', 'blaminhor-essentials' ),
				'recoGroupPlugin'    => __( 'Leads this plugin can probably address', 'blaminhor-essentials' ),
				'recoGroupMixed'     => __( 'Leads the plugin can help with — root cause likely elsewhere', 'blaminhor-essentials' ),
				'recoGroupTheme'     => __( 'Leads possibly related to the theme or content', 'blaminhor-essentials' ),
				'recoGroupHosting'   => __( 'Leads possibly related to hosting', 'blaminhor-essentials' ),
				// Audit persistence — labels for the "regenerate" buttons and
				// for the badge displayed on already-actioned recommendations.
				'regeneratePsi'      => __( 'Regenerate the PSI audit', 'blaminhor-essentials' ),
				'regenerateInternal' => __( 'Regenerate the internal audit', 'blaminhor-essentials' ),
				'alreadyDone'        => __( 'Already addressed', 'blaminhor-essentials' ),
				// Audit relative-time labels next to the score. Why: a static
				// score number says nothing about whether you are looking at a
				// stale 2-week-old audit or one that just finished.
				'auditTimeJustNow'    => __( 'just now', 'blaminhor-essentials' ),
				/* translators: %d: number of minutes since the audit ran */
				'auditTimeMinutesAgo' => __( '%d min ago', 'blaminhor-essentials' ),
				/* translators: %d: number of hours since the audit ran */
				'auditTimeHoursAgo'   => __( '%d h ago', 'blaminhor-essentials' ),
				/* translators: %d: number of days since the audit ran */
				'auditTimeDaysAgo'    => __( '%d d ago', 'blaminhor-essentials' ),
				// Render watchdog — UI feedback while the manual probe runs
				// and after it completes.
				'watchdogRunning'    => __( 'Running render check…', 'blaminhor-essentials' ),
				'watchdogHealthy'    => __( 'Healthy — page renders correctly to anonymous visitors.', 'blaminhor-essentials' ),
				/* translators: 1: comma-separated detection flags, 2: comma-separated settings auto-disabled */
				'watchdogRecovered'  => __( 'Issues detected (%1$s) — auto-disabled %2$s to recover.', 'blaminhor-essentials' ),
				/* translators: %s: comma-separated detection flags */
				'watchdogBroken'     => __( 'Issues detected: %s', 'blaminhor-essentials' ),
				// Preload — live table feedback (rendered by bindPreloadNow).
				'preloadBuilding'    => __( 'Building queue…', 'blaminhor-essentials' ),
				'preloadEmpty'       => __( 'No URLs to preload — check your sitemap.', 'blaminhor-essentials' ),
				'preloadQueued'      => __( 'Queued', 'blaminhor-essentials' ),
				'preloadProcessing'  => __( 'Processing…', 'blaminhor-essentials' ),
				/* translators: 1: current URL index, 2: total URL count */
				'preloadCrawling'    => __( 'Crawling %1$d / %2$d…', 'blaminhor-essentials' ),
				'preloadDone'        => __( 'Done', 'blaminhor-essentials' ),
				'preloadStopped'     => __( 'Stopped', 'blaminhor-essentials' ),
				'preloadProcessed'   => __( 'processed,', 'blaminhor-essentials' ),
				'preloadOk'          => __( 'OK', 'blaminhor-essentials' ),
				'preloadFailed'      => __( 'Failed', 'blaminhor-essentials' ),
				// Cached pages table — Preload tab.
				'purge'              => __( 'Purge', 'blaminhor-essentials' ),
				'purging'            => __( 'Purging…', 'blaminhor-essentials' ),
				'ago'                => __( 'ago', 'blaminhor-essentials' ),
				'kindHtml'           => __( 'page', 'blaminhor-essentials' ),
				'kindRedirect'       => __( 'redirect', 'blaminhor-essentials' ),
				'cachedPagesEmpty'   => __( 'No pages cached yet. They will appear here as visitors hit them or as the preloader warms them up.', 'blaminhor-essentials' ),
				/* translators: %count% will be replaced by the number of cached pages */
				'cachedPagesCount'   => __( '%count% pages cached', 'blaminhor-essentials' ),
				'cachedPagesCapped'  => __( 'showing the 1000 most recent', 'blaminhor-essentials' ),
				// Critical CSS — cell-level error feedback (replaces flash notice).
				/* translators: %s: extracted CSS size in KB */
				'criticalTooLarge'   => __( 'Above the 200 KB inliner cap (%s KB extracted)', 'blaminhor-essentials' ),
				'criticalTooLargeHelp' => __( 'The extraction returned more than 200 KB of CSS — too large to inline usefully (the HTML would weigh that much on every visit, slowing the page instead of speeding it up). This is common on visual builders such as Divi, Avada or Elementor which ship large stylesheets by design. No action required: the site continues to load its CSS the normal way.', 'blaminhor-essentials' ),
				'criticalSaveFailed' => __( 'Could not save', 'blaminhor-essentials' ),
				'criticalSaveFailedHelp' => __( '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' ),
				'criticalAjaxFailed' => __( 'Network error during generation', 'blaminhor-essentials' ),
				'criticalAjaxFailedHelp' => __( 'The browser could not reach the server while preparing this template. Try again from a freshly reloaded admin page (the security token may have expired).', 'blaminhor-essentials' ),
				// Settings export / import (Advanced tab).
				'settingsExporting'    => __( 'Preparing download…', 'blaminhor-essentials' ),
				'settingsExportFailed' => __( 'Could not prepare the export.', 'blaminhor-essentials' ),
				'settingsImportConfirm' => __( 'Replace the current cache settings with the contents of this file? Your existing settings will be overwritten.', 'blaminhor-essentials' ),
				'settingsImportEmpty'  => __( 'Choose a file first.', 'blaminhor-essentials' ),
				'settingsImportTooLarge' => __( 'File is too large to be a settings export.', 'blaminhor-essentials' ),
				'settingsImportInvalidJson' => __( 'This file is not valid JSON.', 'blaminhor-essentials' ),
				/* translators: 1: number of imported keys, 2: number of skipped keys */
				'settingsImportDone'   => __( 'Imported %1$d settings, skipped %2$d unknown entries. Reloading…', 'blaminhor-essentials' ),
			),
		) );
	}

	/* ------------------------------------------------------------------ */
	/*  Settings page (8 tabs)                                            */
	/* ------------------------------------------------------------------ */

	/**
	 * Render the module settings page. Built around 8 data-tab tabs as per
	 * the project's existing pattern (see fatal-error-recovery for reference).
	 */
	public function render_settings_page() {
		$active_tab = isset( $this->settings['active_tab'] ) ? $this->settings['active_tab'] : 'page-cache';
		$tabs       = $this->get_tabs();
		?>
		<div class="wrap blaminhor-essentials-wrap">
			<div class="blaminhor-essentials-header">
				<h1><?php echo esc_html( $this->get_name() ); ?></h1>
				<p class="description"><?php echo esc_html( $this->get_description() ); ?></p>
			</div>

			<?php $this->maybe_render_environment_notices(); ?>
			<?php $this->render_preload_status_banner(); ?>

			<div class="blaminhor-essentials-tabs-wrapper">
				<div class="blaminhor-essentials-tabs">
					<?php foreach ( $tabs as $tab_id => $label ) : ?>
						<button
							type="button"
							class="blaminhor-essentials-tab<?php echo $active_tab === $tab_id ? ' active' : ''; ?>"
							data-tab="<?php echo esc_attr( $tab_id ); ?>"
						><?php echo esc_html( $label ); ?></button>
					<?php endforeach; ?>
				</div>

				<form method="post" action="">
					<?php $this->render_nonce_field(); ?>
					<input type="hidden" name="active_tab" value="<?php echo esc_attr( $active_tab ); ?>" class="be-cache-active-tab">

					<?php foreach ( $tabs as $tab_id => $label ) : ?>
						<div
							class="blaminhor-essentials-tab-content<?php echo $active_tab === $tab_id ? ' active' : ''; ?>"
							data-tab-content="<?php echo esc_attr( $tab_id ); ?>"
						>
							<?php $this->render_tab( $tab_id ); ?>
						</div>
					<?php endforeach; ?>

					<p class="submit">
						<button type="submit" name="submit" class="button button-primary">
							<?php esc_html_e( 'Save Changes', 'blaminhor-essentials' ); ?>
						</button>
					</p>
				</form>
			</div>
		</div>
		<?php
	}

	/**
	 * Render the preload status banner at the top of the cache module page.
	 * Shows the queue size, last batch, next cron tick, and a soft warning
	 * when WP-Cron seems stuck. Wording is intentionally factual and
	 * reassuring — the worst case here is "the cache won't auto-warm",
	 * which is a degraded mode, not an incident.
	 */
	private function render_preload_status_banner() {
		$status = $this->preloader->get_status();

		// Silent banner when the user has not opted into auto-warmup AND no
		// recent run is on file: nothing to report, don't add noise.
		if ( ! $status['enabled'] && 0 === $status['last_run_ts'] ) {
			return;
		}

		$queue_size  = (int) $status['queue_size'];
		$queue_total = max( (int) $status['queue_total'], $queue_size );
		$warmed      = max( 0, $queue_total - $queue_size );
		$progress    = $queue_total > 0 ? min( 100, (int) round( ( $warmed / $queue_total ) * 100 ) ) : 0;

		?>
		<div class="be-preload-banner be-preload-banner-collapsed"
			data-status-url="<?php echo esc_attr( admin_url( 'admin-ajax.php' ) ); ?>"
			data-progress="<?php echo esc_attr( $progress ); ?>">
			<div class="be-preload-banner-header">
				<span class="be-preload-banner-icon" aria-hidden="true"></span>
				<div class="be-preload-banner-text">
					<strong class="be-preload-banner-title">
						<?php
						if ( ! $status['enabled'] ) {
							esc_html_e( 'Automatic preloading is off', 'blaminhor-essentials' );
						} elseif ( $queue_size > 0 ) {
							/* translators: 1: pages warmed so far, 2: total pages in queue */
							printf( esc_html__( 'Preloading: %1$d of %2$d pages ready', 'blaminhor-essentials' ), (int) $warmed, (int) $queue_total );
						} else {
							esc_html_e( 'Cache fully warmed', 'blaminhor-essentials' );
						}
						?>
					</strong>
					<span class="be-preload-banner-meta">
						<?php
						if ( $status['last_run_ts'] > 0 ) {
							printf(
								/* translators: 1: relative time ago, 2: number of pages warmed in last batch */
								esc_html__( 'Last batch %1$s ago, %2$d pages.', 'blaminhor-essentials' ),
								esc_html( human_time_diff( (int) $status['last_run_ts'], time() ) ),
								(int) $status['last_batch_count']
							);
							echo ' ';
						}
						if ( $status['enabled'] && $status['next_run_ts'] > 0 && ! $status['cron_late'] ) {
							if ( $status['next_run_ts'] > time() ) {
								printf(
									/* translators: %s: relative time until next run */
									esc_html__( 'Next run in %s.', 'blaminhor-essentials' ),
									esc_html( human_time_diff( time(), (int) $status['next_run_ts'] ) )
								);
							} else {
								esc_html_e( 'Next run shortly.', 'blaminhor-essentials' );
							}
						}
						?>
					</span>
				</div>
				<button type="button" class="button-link be-preload-banner-toggle" aria-expanded="false">
					<span class="be-preload-banner-toggle-show"><?php esc_html_e( 'Details', 'blaminhor-essentials' ); ?></span>
					<span class="be-preload-banner-toggle-hide"><?php esc_html_e( 'Hide', 'blaminhor-essentials' ); ?></span>
				</button>
			</div>

			<?php if ( $queue_total > 0 ) : ?>
				<div class="be-preload-banner-progress">
					<div class="be-preload-banner-progress-bar" style="width:<?php echo (int) $progress; ?>%;"></div>
				</div>
			<?php endif; ?>

			<div class="be-preload-banner-details">
				<?php if ( ! $status['enabled'] ) : ?>
					<p class="description">
						<?php esc_html_e( 'Turn on automatic warmup in the Preload tab so the cache fills itself from your sitemap, hourly, ten pages at a time. Visitors then always get a ready-made page.', 'blaminhor-essentials' ); ?>
					</p>
				<?php elseif ( $status['cron_late'] ) : ?>
					<p class="description">
						<?php esc_html_e( 'The next preload run was scheduled more than an hour ago and has not started. WP-Cron may not be triggering on this site (some hosts disable it and require a system cron). The site continues to work normally — visitors generate the cache on first visit instead of in advance.', 'blaminhor-essentials' ); ?>
					</p>
				<?php else : ?>
					<p class="description">
						<?php esc_html_e( 'The preloader walks your sitemap and asks the server for each page so its cache file is ready before any visitor needs it. It runs every hour, ten pages per batch, in the background.', 'blaminhor-essentials' ); ?>
					</p>
				<?php endif; ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Display contextual notices about the runtime environment (e.g. Nginx
	 * does not support .htaccess so browser cache must be configured server-side).
	 */
	private function maybe_render_environment_notices() {
		if ( $this->safe_mode->is_active() ) {
			?>
			<div class="notice notice-warning">
				<p><strong><?php esc_html_e( 'Safe mode is enabled.', 'blaminhor-essentials' ); ?></strong>
				<?php esc_html_e( 'The cache is fully bypassed. Disable safe mode in the Advanced tab when you are ready to resume normal operation.', 'blaminhor-essentials' ); ?></p>
			</div>
			<?php
		}

		if ( ! $this->environment->supports_htaccess() ) {
			?>
			<div class="notice notice-info">
				<p>
					<?php
					printf(
						/* translators: %s: detected server software (e.g. Nginx) */
						esc_html__( 'Your server (%s) does not use .htaccess. Browser cache, GZIP and page cache rewrite rules must be configured at the server level. The plugin will still optimize HTML and serve cached pages via PHP.', 'blaminhor-essentials' ),
						esc_html( ucfirst( $this->environment->get_server_software() ) )
					);
					?>
				</p>
			</div>
			<?php
		}
	}

	/**
	 * Tab definitions: ordered map of tab ID => translated label.
	 *
	 * @return array<string, string>
	 */
	private function get_tabs() {
		return array(
			'page-cache' => __( 'Page Cache', 'blaminhor-essentials' ),
			'files'      => __( 'Files', 'blaminhor-essentials' ),
			'media'      => __( 'Media', 'blaminhor-essentials' ),
			'preload'    => __( 'Preload', 'blaminhor-essentials' ),
			'cdn'        => __( 'CDN', 'blaminhor-essentials' ),
			'audit'      => __( 'Audit', 'blaminhor-essentials' ),
			'webvitals'  => __( 'Web Vitals', 'blaminhor-essentials' ),
			'advanced'   => __( 'Advanced', 'blaminhor-essentials' ),
		);
	}

	/**
	 * Render a tab's contents by delegating to a view file.
	 *
	 * Each view file receives `$this` (the module instance) and `$settings`
	 * (the current settings array) in scope.
	 *
	 * @param string $tab_id Tab identifier.
	 */
	private function render_tab( $tab_id ) {
		$file = __DIR__ . '/views/tab-' . $tab_id . '.php';
		if ( ! file_exists( $file ) ) {
			echo '<p class="description">' . esc_html__( 'This section will be populated in an upcoming release.', 'blaminhor-essentials' ) . '</p>';
			return;
		}
		$module   = $this;
		$settings = wp_parse_args( $this->settings, $this->get_default_settings() );
		include $file;
	}

	/* ------------------------------------------------------------------ */
	/*  Settings POST handler                                             */
	/* ------------------------------------------------------------------ */

	/**
	 * Process settings submission. Sanitises every known field and persists
	 * the active tab so the user lands back where they were after submit.
	 *
	 * @param array $data Raw $_POST data (already nonce-verified by parent).
	 */
	protected function process_settings( $data ) {
		$defaults = $this->get_default_settings();
		$new      = $this->settings; // Start from current to preserve unknown fields.

		// Boolean toggles. Keys without an exposed UI input are intentionally
		// omitted (concat_css, minify_js, audit_psi_enabled, audit_internal_enabled,
		// concat_js): they remain at their default value and are not part of the
		// settings POST contract.
		$bool_keys = array(
			'page_cache_enabled', 'cache_mobile_separate', 'cache_logged_in',
			'browser_cache_enabled', 'gzip_enabled',
			'minify_html', 'minify_css',
			'defer_js', 'delay_js',
			'image_dimensions_enabled',
			'lazy_load_images', 'lazy_load_iframes', 'youtube_facade',
			'preload_sitemap_enabled', 'preload_links_hover', 'fonts_display_swap', 'local_google_fonts_enabled', 'auto_resource_hints_enabled',
			'cdn_enabled', 'cloudflare_enabled', 'critical_css_enabled', 'critical_async_loading', 'rucss_enabled',
			'webvitals_enabled',
		);
		foreach ( $bool_keys as $k ) {
			$new[ $k ] = ! empty( $data[ $k ] );
		}

		// Integers with bounds.
		$int_specs = array(
			'cache_ttl'                => array( 60, 31536000 ),       // 1min .. 1y
			'webvitals_sample_rate'    => array( 1, 100 ),
			'webvitals_retention_days' => array( 7, 365 ),
			'preload_batch_size'       => array( 1, 200 ),             // 1 url at a time .. 200 in one go
		);
		foreach ( $int_specs as $k => $bounds ) {
			if ( isset( $data[ $k ] ) ) {
				$v = absint( $data[ $k ] );
				if ( $v < $bounds[0] ) { $v = $bounds[0]; }
				if ( $v > $bounds[1] ) { $v = $bounds[1]; }
				$new[ $k ] = $v;
			}
		}

		// Enums.
		$enum_specs = array(
			'heartbeat_frontend' => array( 'disabled', 'throttle', 'enabled' ),
			'heartbeat_admin'    => array( 'disabled', 'throttle', 'enabled' ),
			'heartbeat_edit'     => array( 'disabled', 'throttle', 'enabled' ),
			'audit_schedule'     => array( 'never', 'weekly', 'daily' ),
		);
		foreach ( $enum_specs as $k => $allowed ) {
			if ( isset( $data[ $k ] ) ) {
				$v = sanitize_key( wp_unslash( $data[ $k ] ) );
				if ( in_array( $v, $allowed, true ) ) {
					$new[ $k ] = $v;
				}
			}
		}

		// Multi-line textareas (one entry per line).
		$textarea_keys = array(
			'css_exclude', 'js_exclude', 'delay_js_exclude',
			'lazy_exclude_classes',
			'preconnect_domains', 'dns_prefetch_domains', 'preload_fonts',
			'cdn_excluded_extensions',
			'rucss_safelist',
			'exclude_urls', 'exclude_cookies', 'exclude_user_agents',
		);
		foreach ( $textarea_keys as $k ) {
			if ( isset( $data[ $k ] ) ) {
				$new[ $k ] = sanitize_textarea_field( wp_unslash( $data[ $k ] ) );
			}
		}

		// Free-form text.
		if ( isset( $data['preload_sitemap_url'] ) ) {
			$new['preload_sitemap_url'] = esc_url_raw( wp_unslash( $data['preload_sitemap_url'] ) );
		}
		if ( isset( $data['cdn_url'] ) ) {
			$new['cdn_url'] = esc_url_raw( wp_unslash( $data['cdn_url'] ) );
		}
		if ( isset( $data['theme_compat_disabled'] ) && is_array( $data['theme_compat_disabled'] ) ) {
			$cleaned = array();
			foreach ( wp_unslash( $data['theme_compat_disabled'] ) as $entry ) {
				$entry = sanitize_key( (string) $entry );
				if ( '' !== $entry ) {
					$cleaned[] = $entry;
				}
			}
			$new['theme_compat_disabled'] = array_values( array_unique( $cleaned ) );
		} elseif ( ! isset( $data['theme_compat_disabled'] ) ) {
			// Form submitted with no checkboxes ticked = empty array.
			$new['theme_compat_disabled'] = array();
		}
		if ( isset( $data['cloudflare_zone_id'] ) ) {
			$new['cloudflare_zone_id'] = preg_replace( '/[^a-zA-Z0-9]/', '', (string) wp_unslash( $data['cloudflare_zone_id'] ) );
		}
		if ( isset( $data['cloudflare_api_token'] ) ) {
			$incoming = trim( (string) wp_unslash( $data['cloudflare_api_token'] ) );
			if ( '' === $incoming ) {
				$new['cloudflare_api_token'] = '';
			} elseif ( '__BE_KEEP__' === $incoming ) {
				// Sentinel sent by the form when the user did not retype the
				// token. Keep the existing encrypted value untouched.
				$new['cloudflare_api_token'] = isset( $this->settings['cloudflare_api_token'] )
					? (string) $this->settings['cloudflare_api_token']
					: '';
			} else {
				$new['cloudflare_api_token'] = function_exists( 'blaminhor_essentials_encrypt' )
					? blaminhor_essentials_encrypt( $incoming )
					: $incoming;
			}
		}

		// Active tab (returned to the user).
		if ( isset( $data['active_tab'] ) ) {
			$tab = sanitize_key( wp_unslash( $data['active_tab'] ) );
			if ( array_key_exists( $tab, $this->get_tabs() ) ) {
				$new['active_tab'] = $tab;
			}
		}

		// Make sure every default key exists (forward-compatibility).
		$new = wp_parse_args( $new, $defaults );

		$this->save_settings( $new );

		// save_settings() → update_option() → on_settings_updated() (synchronous)
		// has already fired by this point, so cache_purged_on_save is set if a
		// purge actually happened. Choose the message accordingly.
		$message = $this->cache_purged_on_save
			? __( 'Settings saved. Cache regenerated automatically.', 'blaminhor-essentials' )
			: __( 'Settings saved.', 'blaminhor-essentials' );

		add_action( 'admin_notices', function () use ( $message ) {
			blaminhor_essentials_admin_notice( $message, 'success' );
		} );
	}

	/* ------------------------------------------------------------------ */
	/*  AJAX endpoints (J1 minimal)                                       */
	/* ------------------------------------------------------------------ */

	/**
	 * Toggle the safe-mode file flag.
	 */
	public function ajax_safe_mode_toggle() {
		$this->verify_ajax_request();
		$enable = ! empty( $_POST['enable'] );
		if ( $enable ) {
			$reason = isset( $_POST['reason'] ) ? sanitize_text_field( wp_unslash( $_POST['reason'] ) ) : '';
			$ok     = $this->safe_mode->enable( $reason );
		} else {
			$ok = $this->safe_mode->disable();
		}
		if ( ! $ok ) {
			blaminhor_essentials_ajax_error( __( 'Could not update safe mode flag. Check that the cache directory is writable.', 'blaminhor-essentials' ) );
		}
		blaminhor_essentials_ajax_success(
			$enable ? __( 'Safe mode enabled — cache fully bypassed.', 'blaminhor-essentials' )
			        : __( 'Safe mode disabled — cache serves normally.', 'blaminhor-essentials' ),
			array( 'is_active' => $this->safe_mode->is_active() )
		);
	}

	/**
	 * Return basic stats about disk usage. Used by the Advanced tab.
	 */
	public function ajax_get_stats() {
		$this->verify_ajax_request();
		$bytes = $this->storage->disk_usage();
		blaminhor_essentials_ajax_success(
			__( 'Stats refreshed.', 'blaminhor-essentials' ),
			array(
				'disk_usage_bytes'    => $bytes,
				'disk_usage_readable' => blaminhor_essentials_format_size( $bytes ),
				'safe_mode_active'    => $this->safe_mode->is_active(),
				'server_software'     => $this->environment->get_server_software(),
				'dropin_installed'    => $this->dropin_installer->is_installed(),
			)
		);
	}

	/**
	 * Purge the entire pages/ directory.
	 */
	public function ajax_purge_all() {
		$this->verify_ajax_request();
		$this->purge->purge_all();
		blaminhor_essentials_ajax_success(
			__( 'Cache purged.', 'blaminhor-essentials' ),
			array( 'disk_usage_bytes' => $this->storage->disk_usage() )
		);
	}

	/**
	 * Run a preload batch on demand. Lets the user kick off the warmup
	 * without waiting for the cron — useful right after a content edit or
	 * the first time the site is configured.
	 */
	public function ajax_preload_now() {
		$this->verify_ajax_request();

		// Always rebuild the queue when the user clicks "Preload now" so the
		// list reflects the current sitemap (SEO/GEO post types may have
		// changed since the last run). run_batch( true ) bypasses the
		// "preload_sitemap_enabled" toggle so the manual button works even
		// when the scheduled cron is off.
		$this->preloader->refill_queue();
		$queued_total = count( (array) get_transient( Blaminhor_Essentials_Cache_Preloader::QUEUE_TRANSIENT ) );

		$this->preloader->run_batch( true );

		$after  = (array) get_transient( Blaminhor_Essentials_Cache_Preloader::QUEUE_TRANSIENT );
		$warmed = max( 0, $queued_total - count( $after ) );

		blaminhor_essentials_ajax_success(
			__( 'Preload batch executed.', 'blaminhor-essentials' ),
			array(
				'queued_total' => $queued_total,
				'remaining'    => count( $after ),
				'warmed'       => $warmed,
			)
		);
	}

	/**
	 * Build the preload queue and return the full list of URLs to the JS so
	 * it can iterate one by one with a live table. Used by the new on-demand
	 * preload UI in tab-preload.php.
	 */
	public function ajax_preload_start() {
		$this->verify_ajax_request();

		// Wipe any persisted queue first so a corrupted entry from an older
		// build (e.g. when a stale OPcache was still serving the old code)
		// can't leak into the fresh run.
		delete_transient( Blaminhor_Essentials_Cache_Preloader::QUEUE_TRANSIENT );

		$this->preloader->refill_queue();
		$queue = (array) get_transient( Blaminhor_Essentials_Cache_Preloader::QUEUE_TRANSIENT );

		// Final defensive sanitisation right before we hand the list to JS:
		// only http(s) URLs, deduped. Anything else is silently dropped.
		$queue = array_values( array_unique( array_filter( $queue, function ( $u ) {
			return is_string( $u ) && '' !== $u && preg_match( '#^https?://#i', $u );
		} ) ) );

		blaminhor_essentials_log(
			'Preload queue built',
			'info',
			'cache',
			array(
				'event'      => 'preload_queue_built',
				'count'      => count( $queue ),
				'first_3'    => array_slice( $queue, 0, 3 ),
				'seo_active' => function_exists( 'blaminhor_essentials' ) && blaminhor_essentials()->is_module_active( 'seo-manager' ),
				'home_url'   => home_url( '/' ),
			)
		);

		blaminhor_essentials_ajax_success(
			__( 'Queue ready.', 'blaminhor-essentials' ),
			array( 'urls' => $queue )
		);
	}

	/**
	 * Warm a single URL. Returns timing + HTTP status so the JS can update
	 * its row in the live table. Cheap, idempotent, safe to call in loop
	 * from the JS side.
	 */
	public function ajax_preload_warm_one() {
		$this->verify_ajax_request();
		$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
		if ( '' === $url ) {
			blaminhor_essentials_ajax_error( __( 'Missing URL.', 'blaminhor-essentials' ) );
		}

		$t0       = microtime( true );
		$response = wp_remote_get( $url, array(
			'timeout'    => 20,
			'redirection'=> 5,
			'sslverify'  => false,
			'user-agent' => 'Blaminhor-Cache-Preloader/1.0',
			'blocking'   => true,
		) );
		$duration_ms = (int) round( ( microtime( true ) - $t0 ) * 1000 );

		if ( is_wp_error( $response ) ) {
			blaminhor_essentials_ajax_success(
				__( 'OK', 'blaminhor-essentials' ),
				array(
					'url'         => $url,
					'http_code'   => 0,
					'duration_ms' => $duration_ms,
					'error'       => $response->get_error_message(),
				)
			);
		}

		blaminhor_essentials_ajax_success(
			__( 'OK', 'blaminhor-essentials' ),
			array(
				'url'         => $url,
				'http_code'   => (int) wp_remote_retrieve_response_code( $response ),
				'duration_ms' => $duration_ms,
				'error'       => '',
			)
		);
	}

	/**
	 * Return the current preload status (queue size, last batch, next cron tick).
	 * Used by the admin banner to refresh in place.
	 */
	public function ajax_preload_status() {
		$this->verify_ajax_request();
		blaminhor_essentials_ajax_success(
			__( 'OK', 'blaminhor-essentials' ),
			$this->preloader->get_status()
		);
	}

	/**
	 * Page through the cached-pages index, optionally filtered by a substring
	 * search on the URL. Used by the "Cached pages" table on the Preload tab.
	 */
	public function ajax_pages_index_list() {
		$this->verify_ajax_request();

		$page    = max( 1, isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : 1 );
		$per     = 25;
		$search  = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';

		$index   = Blaminhor_Essentials_Cache_Pages_Index::instance();
		$entries = $index->get_entries();

		if ( '' !== $search ) {
			$needle  = strtolower( $search );
			$entries = array_values( array_filter( $entries, static function ( $e ) use ( $needle ) {
				return isset( $e['url'] ) && false !== strpos( strtolower( (string) $e['url'] ), $needle );
			} ) );
		}

		$total       = count( $entries );
		$total_pages = (int) ceil( $total / $per );
		$slice       = array_slice( $entries, ( $page - 1 ) * $per, $per );

		// Format on the way out so the JS does not have to know how to render
		// timestamps / sizes — keeps the client simple.
		$rows = array();
		foreach ( $slice as $e ) {
			$rows[] = array(
				'url'        => isset( $e['url'] ) ? (string) $e['url'] : '',
				'variant'    => isset( $e['variant'] ) ? (string) $e['variant'] : '',
				'size'       => isset( $e['size'] ) ? (int) $e['size'] : 0,
				'size_human' => isset( $e['size'] ) ? blaminhor_essentials_format_size( (int) $e['size'] ) : '',
				'mtime'      => isset( $e['mtime'] ) ? (int) $e['mtime'] : 0,
				'mtime_human'=> isset( $e['mtime'] ) ? human_time_diff( (int) $e['mtime'], time() ) : '',
				'kind'       => isset( $e['kind'] ) ? (string) $e['kind'] : 'html',
			);
		}

		blaminhor_essentials_ajax_success(
			__( 'OK', 'blaminhor-essentials' ),
			array(
				'rows'         => $rows,
				'page'         => $page,
				'per_page'     => $per,
				'total'        => $total,
				'total_pages'  => $total_pages,
				'index_count'  => $index->get_count(),
				'index_capped' => $index->is_capped(),
				'index_bytes'  => $index->get_total_bytes(),
				'index_size'   => blaminhor_essentials_format_size( $index->get_total_bytes() ),
			)
		);
	}

	/**
	 * Purge one URL (all variants).
	 */
	public function ajax_purge_url() {
		$this->verify_ajax_request();
		$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
		if ( '' === $url ) {
			blaminhor_essentials_ajax_error( __( 'Missing URL.', 'blaminhor-essentials' ) );
		}
		$count = $this->purge->purge_url( $url );
		blaminhor_essentials_ajax_success(
			sprintf(
				/* translators: 1: number of files purged, 2: URL purged */
				_n( 'Purged %1$d cached file for %2$s.', 'Purged %1$d cached files for %2$s.', $count, 'blaminhor-essentials' ),
				$count,
				$url
			),
			array( 'count' => $count )
		);
	}

	/**
	 * Build the lastAudit payload for wp_localize_script. Re-runs the
	 * recommender on the stored raw audits so `is_resolved` reflects the
	 * current state of the toggles, not the state when the audit was run.
	 *
	 * @return array
	 */
	private function get_last_audits_for_render() {
		$store = (array) get_option( 'blaminhor_essentials_cache_last_audit', array() );
		$out   = array();
		foreach ( array( 'psi', 'internal' ) as $engine ) {
			if ( empty( $store[ $engine ]['result']['audits'] ) ) {
				continue;
			}
			$result = $store[ $engine ]['result'];
			// Recompute recommendations against current settings so resolved
			// toggles surface as greyed-out cards. Cheap operation.
			$result['recommendations'] = $this->audit_recommender->recommend( $result['audits'] );
			$out[ $engine ] = array(
				'url'       => isset( $store[ $engine ]['url'] ) ? (string) $store[ $engine ]['url'] : '',
				'timestamp' => isset( $store[ $engine ]['timestamp'] ) ? (int) $store[ $engine ]['timestamp'] : 0,
				'result'    => $result,
			);
		}
		return $out;
	}

	/**
	 * Run an audit — engine = psi|internal.
	 */
	public function ajax_audit_run() {
		$this->verify_ajax_request();
		$engine = isset( $_POST['engine'] ) ? sanitize_key( wp_unslash( $_POST['engine'] ) ) : 'internal';
		$url    = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : home_url( '/' );

		if ( 'psi' === $engine ) {
			$result = $this->audit_psi->run( $url, 'mobile' );
		} else {
			$result = $this->audit_internal->run( $url );
		}

		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error( $result->get_error_message() );
		}

		$result['recommendations'] = $this->audit_recommender->recommend( $result['audits'] );

		// Persist per engine so PSI and Internal each remember their last
		// run independently. The render layer prefers PSI when both exist.
		$store               = (array) get_option( 'blaminhor_essentials_cache_last_audit', array() );
		$store[ $engine ]    = array(
			'url'       => $url,
			'result'    => $result,
			'timestamp' => time(),
		);
		update_option( 'blaminhor_essentials_cache_last_audit', $store, false );

		// Append a single data point to the history time-series so the Audit
		// tab can show how the score evolves run-over-run. Only the score and
		// the timestamp are kept — the full audit blob would balloon the
		// option. Capped at 30 entries per engine to bound storage.
		if ( isset( $result['score'] ) && is_numeric( $result['score'] ) ) {
			$history_opt = 'blaminhor_essentials_cache_audit_history';
			$history     = (array) get_option( $history_opt, array() );
			if ( ! isset( $history[ $engine ] ) || ! is_array( $history[ $engine ] ) ) {
				$history[ $engine ] = array();
			}
			$history[ $engine ][] = array(
				'ts'    => time(),
				'score' => (int) round( (float) $result['score'] ),
			);
			if ( count( $history[ $engine ] ) > 30 ) {
				$history[ $engine ] = array_slice( $history[ $engine ], -30 );
			}
			update_option( $history_opt, $history, false );
		}

		blaminhor_essentials_ajax_success( __( 'Audit complete.', 'blaminhor-essentials' ), $result );
	}

	/**
	 * Save the PSI API key (encrypted).
	 */
	public function ajax_audit_save_psi_key() {
		$this->verify_ajax_request();
		$key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
		// Refuse the masked placeholder.
		if ( false !== strpos( $key, '••' ) ) {
			blaminhor_essentials_ajax_error( __( 'Please paste your full key.', 'blaminhor-essentials' ) );
		}
		$ok = $this->audit_psi->save_api_key( $key );
		if ( ! $ok ) {
			blaminhor_essentials_ajax_error( __( 'Could not save the key.', 'blaminhor-essentials' ) );
		}
		blaminhor_essentials_ajax_success( __( 'API key saved.', 'blaminhor-essentials' ) );
	}

	/**
	 * Test the validity of a PSI key (live request to PSI).
	 */
	public function ajax_audit_test_psi_key() {
		$this->verify_ajax_request();
		$key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
		if ( '' === $key || false !== strpos( $key, '••' ) ) {
			$key = $this->audit_psi->get_api_key();
		}
		if ( '' === $key ) {
			blaminhor_essentials_ajax_error( __( 'No key to test. Paste one or save it first.', 'blaminhor-essentials' ) );
		}
		$result = $this->audit_psi->test_key( $key );
		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error( $result->get_error_message() );
		}
		blaminhor_essentials_ajax_success( __( 'Key works — PSI returned a 200 response.', 'blaminhor-essentials' ) );
	}

	/**
	 * Run the embedded health check and return the report.
	 */
	public function ajax_health_check() {
		$this->verify_ajax_request();
		$report = $this->health_check->run();
		blaminhor_essentials_ajax_success(
			__( 'Health check complete.', 'blaminhor-essentials' ),
			$report
		);
	}

	/**
	 * Force re-installation of the drop-in (useful if it was overwritten or
	 * settings changed and the user wants an immediate sync).
	 */
	public function ajax_resync_dropin() {
		$this->verify_ajax_request();
		$result = $this->dropin_installer->install( $this->dropin_install_params() );
		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error( $result->get_error_message() );
		}
		blaminhor_essentials_ajax_success( __( 'Drop-in reinstalled.', 'blaminhor-essentials' ) );
	}

	/**
	 * Return the entire module configuration as a JSON-serialisable envelope.
	 * The frontend turns the payload into a Blob and triggers a download —
	 * this keeps the AJAX contract uniform (success/error envelopes) while
	 * still producing a real `.json` file on the user's machine.
	 *
	 * Excluded from the export:
	 *   - encrypted secrets (cloudflare_api_token) — useless on another host
	 *     since they are bound to a different encryption salt.
	 *   - active_tab / safe_defaults_applied_for_heavy_theme — view-state
	 *     and one-shot stamps the importing site should compute itself.
	 */
	public function ajax_export_settings() {
		$this->verify_ajax_request();
		$payload = $this->build_export_payload();
		blaminhor_essentials_ajax_success(
			__( 'Configuration exported.', 'blaminhor-essentials' ),
			array(
				'payload'  => $payload,
				'filename' => $this->build_export_filename(),
			)
		);
	}

	/**
	 * Import a previously-exported JSON envelope into the module settings.
	 * Validates the envelope (plugin/module identifiers), drops unknown keys
	 * silently, leaves encrypted secrets untouched, and respects the type of
	 * each known setting. If the plugin_version recorded inside the file is
	 * higher than the current runtime, the import still goes through but the
	 * UI is informed (newer settings may be present that this version simply
	 * does not understand — no harm done because they are dropped).
	 */
	public function ajax_import_settings() {
		$this->verify_ajax_request();

		$raw = isset( $_POST['payload'] ) ? wp_unslash( $_POST['payload'] ) : '';
		if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
			blaminhor_essentials_ajax_error( __( 'No JSON received.', 'blaminhor-essentials' ) );
		}

		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			blaminhor_essentials_ajax_error( __( 'JSON could not be parsed.', 'blaminhor-essentials' ) );
		}

		$envelope_ok = isset( $decoded['plugin'], $decoded['module'], $decoded['settings'] )
			&& 'blaminhor-essentials' === $decoded['plugin']
			&& 'cache' === $decoded['module']
			&& is_array( $decoded['settings'] );

		if ( ! $envelope_ok ) {
			blaminhor_essentials_ajax_error( __( 'This file is not a Blaminhor Essentials Cache export.', 'blaminhor-essentials' ) );
		}

		$summary = $this->apply_imported_settings( $decoded['settings'] );

		blaminhor_essentials_ajax_success(
			__( 'Configuration imported.', 'blaminhor-essentials' ),
			array(
				'imported_count' => count( $summary['imported_keys'] ),
				'skipped_count'  => count( $summary['skipped_keys'] ),
				'source_version' => isset( $decoded['plugin_version'] ) ? (string) $decoded['plugin_version'] : '',
			)
		);
	}

	/**
	 * Build the export payload (also exposed for filter consumers / future
	 * UI placements). Public on purpose so a CLI command or a custom audit
	 * could reuse the exact same shape.
	 *
	 * @return array
	 */
	public function build_export_payload() {
		$defaults = $this->get_default_settings();
		$current  = wp_parse_args( $this->settings, $defaults );

		// Strip secrets / view-state / one-shot stamps from the payload.
		$excluded = array(
			'cloudflare_api_token',
			'active_tab',
			'safe_defaults_applied_for_heavy_theme',
		);
		foreach ( $excluded as $k ) {
			unset( $current[ $k ] );
		}

		return array(
			'plugin'         => 'blaminhor-essentials',
			'module'         => 'cache',
			'plugin_version' => defined( 'BLAMINHOR_ESSENTIALS_VERSION' ) ? BLAMINHOR_ESSENTIALS_VERSION : '',
			'exported_at'    => gmdate( 'c' ),
			'site_url'       => home_url(),
			'settings'       => $current,
		);
	}

	/**
	 * Build a sensible default filename for the export so the user does not
	 * end up with a folder full of indistinguishable `cache.json` files.
	 *
	 * @return string
	 */
	public function build_export_filename() {
		$host = wp_parse_url( home_url(), PHP_URL_HOST );
		$host = preg_replace( '/[^a-z0-9.-]/i', '', (string) $host );
		if ( '' === $host ) {
			$host = 'site';
		}
		return 'be-cache-settings-' . $host . '-' . gmdate( 'Y-m-d' ) . '.json';
	}

	/**
	 * Apply imported settings to the module. Only keys present in
	 * get_default_settings() are accepted; encrypted / view-state keys are
	 * preserved from the current settings. Each imported value is coerced
	 * to the type of its default so a malformed JSON cannot end up storing
	 * an array where the runtime expects a boolean.
	 *
	 * @param array<string, mixed> $incoming
	 * @return array{imported_keys:array<int,string>, skipped_keys:array<int,string>}
	 */
	private function apply_imported_settings( array $incoming ) {
		$defaults  = $this->get_default_settings();
		$current   = wp_parse_args( $this->settings, $defaults );
		$known     = array_keys( $defaults );
		$preserved = array(
			'cloudflare_api_token',
			'active_tab',
			'safe_defaults_applied_for_heavy_theme',
		);

		$imported = array();
		$skipped  = array();

		foreach ( $incoming as $k => $v ) {
			$k = (string) $k;
			if ( ! in_array( $k, $known, true ) || in_array( $k, $preserved, true ) ) {
				$skipped[] = $k;
				continue;
			}
			$default_type = gettype( $defaults[ $k ] );
			switch ( $default_type ) {
				case 'boolean':
					$current[ $k ] = (bool) $v;
					break;
				case 'integer':
					$current[ $k ] = (int) $v;
					break;
				case 'double':
					$current[ $k ] = (float) $v;
					break;
				case 'array':
					$current[ $k ] = is_array( $v ) ? array_values( array_filter( array_map( 'sanitize_text_field', $v ), 'is_string' ) ) : array();
					break;
				default:
					$current[ $k ] = is_scalar( $v ) ? (string) $v : $defaults[ $k ];
			}
			$imported[] = $k;
		}

		$this->save_settings( $current );

		return array(
			'imported_keys' => $imported,
			'skipped_keys'  => $skipped,
		);
	}

	/* ------------------------------------------------------------------ */
	/*  Side effects                                                      */
	/* ------------------------------------------------------------------ */

	/**
	 * Whenever the settings change, re-render the drop-in (TTL / mobile separate
	 * are baked in at install time) and purge the cache so users do not see
	 * stale variants.
	 */
	public function on_settings_updated( $old, $new ) {
		// Drop-in resync if any drop-in-baked field changed.
		$dropin_keys = array( 'page_cache_enabled', 'cache_ttl', 'cache_mobile_separate' );
		$dropin_changed = false;
		foreach ( $dropin_keys as $k ) {
			if ( ( isset( $old[ $k ] ) ? $old[ $k ] : null ) !== ( isset( $new[ $k ] ) ? $new[ $k ] : null ) ) {
				$dropin_changed = true;
				break;
			}
		}
		if ( $dropin_changed ) {
			if ( empty( $new['page_cache_enabled'] ) ) {
				$this->dropin_installer->uninstall();
			} else {
				$this->dropin_installer->install( $this->dropin_install_params() );
			}
		}

		// .htaccess resync if browser-cache / gzip toggles changed.
		$htaccess_keys = array( 'browser_cache_enabled', 'gzip_enabled' );
		foreach ( $htaccess_keys as $k ) {
			if ( ( isset( $old[ $k ] ) ? $old[ $k ] : null ) !== ( isset( $new[ $k ] ) ? $new[ $k ] : null ) ) {
				$this->sync_htaccess();
				break;
			}
		}

		// Purge cache on ANY settings change. WP only fires update_option_*
		// when the value actually changed, so being here means at least one
		// setting was modified. Optimisations (minify, defer, lazy load,
		// critical CSS, RUCSS, CDN…) all bake into the cached HTML — failing
		// to purge means visitors keep seeing the old optimisations until the
		// cache TTL expires, which has been the source of "ghost" regressions
		// after toggling settings off in production. The flag is read by
		// process_settings() to surface the side-effect in the success notice.
		if ( $this->purge && $old !== $new ) {
			$this->cache_purged_on_save = true;
			$this->purge->purge_all();
		}
	}

	/**
	 * Write/erase .htaccess directive blocks for browser cache + GZIP based on
	 * the current settings. Idempotent — safe to call multiple times.
	 */
	public function sync_htaccess() {
		if ( ! $this->environment->supports_htaccess() ) {
			return;
		}
		if ( ! $this->htaccess_writer->is_writable() ) {
			return;
		}

		// Browser cache.
		if ( $this->get_setting( 'browser_cache_enabled', true ) ) {
			$lines = $this->load_htaccess_snippet( 'htaccess-browser-cache.txt' );
			if ( $lines ) {
				$this->htaccess_writer->write_block( 'browser-cache', $lines );
			}
		} else {
			$this->htaccess_writer->remove_block( 'browser-cache' );
		}

		// GZIP.
		if ( $this->get_setting( 'gzip_enabled', true ) ) {
			$lines = $this->load_htaccess_snippet( 'htaccess-gzip.txt' );
			if ( $lines ) {
				$this->htaccess_writer->write_block( 'gzip', $lines );
			}
		} else {
			$this->htaccess_writer->remove_block( 'gzip' );
		}

		update_option( 'blaminhor_essentials_cache_htaccess_modified', true );
	}

	/**
	 * Load a snippet file from dropin/snippets/ as an array of lines.
	 *
	 * @param string $filename
	 * @return string[]|null
	 */
	private function load_htaccess_snippet( $filename ) {
		$path = __DIR__ . '/dropin/snippets/' . $filename;
		if ( ! is_readable( $path ) ) {
			return null;
		}
		$lines = @file( $path, FILE_IGNORE_NEW_LINES );
		return false === $lines ? null : $lines;
	}

	/* ------------------------------------------------------------------ */
	/*  Admin bar                                                         */
	/* ------------------------------------------------------------------ */

	/**
	 * Add a "Purge cache" item under the Blaminhor admin-bar root.
	 *
	 * @param WP_Admin_Bar $bar
	 */
	public function add_admin_bar_purge( $bar ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$bar->add_node( array(
			'id'     => 'blaminhor-essentials-cache-purge',
			'parent' => 'blaminhor-essentials',
			'title'  => __( 'Purge cache', 'blaminhor-essentials' ),
			'href'   => '#be-cache-purge',
			'meta'   => array(
				'class' => 'be-cache-purge-bar-action',
				'title' => __( 'Purge all cached pages now', 'blaminhor-essentials' ),
			),
		) );
	}

	/* ------------------------------------------------------------------ */
	/*  Cloudflare AJAX handlers                                          */
	/* ------------------------------------------------------------------ */

	/**
	 * Toggle Cloudflare Development Mode (3-hour bypass auto-expires on
	 * Cloudflare's side, so we only need to flip the flag).
	 */
	public function ajax_cf_dev_mode_toggle() {
		$this->verify_ajax_request();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by verify_ajax_request() above.
		$enable = isset( $_POST['enable'] ) && '1' === sanitize_text_field( wp_unslash( (string) $_POST['enable'] ) );
		$result = $this->cloudflare->set_development_mode( (bool) $enable );
		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error(
				/* translators: %s: Cloudflare API error code */
				sprintf( __( 'Cloudflare API error: %s', 'blaminhor-essentials' ), $result->get_error_code() )
			);
		}
		$state = $this->cloudflare->get_development_mode();
		blaminhor_essentials_ajax_success(
			$enable
				? __( 'Cloudflare development mode enabled — cache bypassed for the next 3 hours.', 'blaminhor-essentials' )
				: __( 'Cloudflare development mode disabled.', 'blaminhor-essentials' ),
			is_array( $state ) ? $state : array()
		);
	}

	/**
	 * Apply the recommended Cloudflare settings for WordPress (after
	 * snapshotting the current values so they can be restored).
	 */
	public function ajax_cf_apply_recommended() {
		$this->verify_ajax_request();
		$result = $this->cloudflare->apply_recommended_settings();
		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error( $result->get_error_message() );
		}
		blaminhor_essentials_ajax_success(
			sprintf(
				/* translators: 1: number of settings applied, 2: number skipped */
				__( 'Cloudflare settings applied: %1$d updated, %2$d skipped (token permission missing or API error).', 'blaminhor-essentials' ),
				count( $result['applied'] ),
				count( $result['skipped'] )
			),
			$result
		);
	}

	/**
	 * Restore Cloudflare settings to the snapshot taken before the last
	 * "Apply recommended" run.
	 */
	public function ajax_cf_reset_settings() {
		$this->verify_ajax_request();
		$result = $this->cloudflare->reset_settings();
		if ( is_wp_error( $result ) ) {
			blaminhor_essentials_ajax_error( $result->get_error_message() );
		}
		blaminhor_essentials_ajax_success(
			sprintf(
				/* translators: 1: number of settings restored, 2: number skipped */
				__( 'Cloudflare settings restored: %1$d reverted, %2$d skipped.', 'blaminhor-essentials' ),
				count( $result['restored'] ),
				count( $result['skipped'] )
			),
			$result
		);
	}

	/**
	 * Re-probe what the configured token can do (purge, read settings,
	 * write settings) so the UI can grey out features the token lacks.
	 */
	public function ajax_cf_capabilities() {
		$this->verify_ajax_request();
		$caps = $this->cloudflare->detect_capabilities( true );
		blaminhor_essentials_ajax_success( __( 'Cloudflare token capabilities refreshed.', 'blaminhor-essentials' ), $caps );
	}
}
