# xspeed/1.0.9/includes/class-cache.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.0.9. 2,441 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.0.9/code/includes/class-cache.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.0.9/raw/includes/class-cache.php
- Modified: 2026-07-16T11:41:06+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.0.9/code/includes/class-cache.php#L10-L20`.

```php
<?php
/**
 * Page cache engine.
 *
 * @package XSpeed
 */

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

class Cache {

	/**
	 * Output-buffer nesting level at which we opened our cache buffer, so
	 * `close_buffer()` can flush ONLY our buffer and never disturb a buffer
	 * another plugin pushed on top of (or below) ours.
	 *
	 * @var int|null
	 */
	private static $buffer_level = null;

	public function __construct() {
		add_action( 'template_redirect', array( $this, 'maybe_start_cache' ), 0 );

		// Events that should invalidate cached output. Beyond posts/comments,
		// this covers user and term changes — the REST cache can serve
		// /wp/v2/users, /wp/v2/categories, /wp/v2/tags, and these also affect
		// rendered author bylines / term-archive pages. Without them, an edit
		// left the matching endpoint (and archives) stale for the full TTL.
		// (FBS-82408)
		$invalidate_hooks = array(
			'save_post', 'deleted_post', 'trashed_post',
			'comment_post', 'wp_set_comment_status',
			'switch_theme', 'activated_plugin', 'deactivated_plugin',
			// Users → /wp/v2/users + author archives.
			'profile_update', 'user_register', 'deleted_user',
			// Terms → /wp/v2/{taxonomy} + term archives.
			'created_term', 'edited_term', 'delete_term',
		);
		foreach ( $invalidate_hooks as $hook ) {
			add_action( $hook, array( __CLASS__, 'purge_all' ) );
			add_action( $hook, array( 'XSpeed\\Minifier', 'purge_minified' ) );
		}

		add_action( 'update_option_xspeed_options', array( __CLASS__, 'on_settings_change' ), 10, 2 );

		add_action( 'admin_bar_menu', array( $this, 'admin_bar_purge' ), 100 );
		add_action( 'admin_post_xspeed_purge', array( $this, 'handle_admin_bar_purge' ) );
	}

	public static function on_settings_change( $old, $new ) {
		// gzip_enabled moved to xspeed_module_gzip — GzipModule owns the
		// .htaccess flip via its own update_option_xspeed_module_gzip hook.
		// Same migration is planned for cache_expiry + excluded_urls
		// (Cache module). Keep this handler around for whatever still
		// lives in the legacy blob (cache_enabled is special and goes
		// through Cache::toggle anyway).

		// Any settings change — purge caches so changes take effect.
		self::purge_all( 'settings change' );
		Minifier::purge_minified();
	}

	public function maybe_start_cache() {
		if ( ! self::should_cache() ) {
			return;
		}

		$key  = self::cache_key();
		$file = self::cache_file_for( $key );

		if ( file_exists( $file ) && ! self::is_expired( $file ) ) {
			Hit_Counter::record_hit();
			// Emit the HIT marker on THIS path too. The drop-in
			// (advanced-cache.php) sends "HIT (php)" and the nginx static
			// rewrite sends "HIT (nginx)", but this template_redirect
			// serve path — the one that runs when the drop-in isn't loaded
			// (e.g. WP_CACHE not true) — previously streamed the cached
			// file with NO marker, so a genuine HIT looked like a MISS in
			// the response headers. Same header + value as the drop-in.
			if ( ! headers_sent() ) {
				header( 'X-XSpeed-Cache: HIT (php)' );
			}
			// Replay stored response bits so the HIT matches the original:
			// a non-HTML Content-Type (cached feeds, sitemaps) and a non-200
			// status (a cached 404 must serve 404, not 200). No-op for
			// ordinary pages, which write no .meta.
			$meta = self::read_meta( $key );
			if ( ! headers_sent() ) {
				if ( ! empty( $meta['status'] ) && function_exists( 'http_response_code' ) ) {
					http_response_code( (int) $meta['status'] );
				}
				if ( ! empty( $meta['content_type'] ) && is_string( $meta['content_type'] ) ) {
					header( 'Content-Type: ' . $meta['content_type'] );
				}
				// Conditional GET: emit Last-Modified + ETag and answer a
				// matching If-Modified-Since / If-None-Match with 304 so
				// aggregators (and browsers) skip re-downloading an unchanged
				// cached response — the bandwidth win feeds are about.
				// (FBS-82407 #5)
				if ( self::serve_not_modified( $file ) ) {
					exit; // 304 sent, no body.
				}
			}
			// Serve the precompressed Brotli sibling when the client accepts
			// it (an add-on, the Pro Brotli module, wrote <file>.br). On this
			// PHP serve path the web server never sees the .br, so without
			// this a br-capable client got the plain .html — precompression
			// did nothing here. Falls through to plain readfile otherwise.
			$br = self::maybe_serve_brotli( $file );
			if ( null !== $br ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a static cache file directly; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
				readfile( $br );
				exit;
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile is optimal for streaming a static cache file directly to the visitor; WP_Filesystem would buffer through PHP memory and is not appropriate for response streaming.
			readfile( $file );
			exit;
		}

		// Cache miss → render fresh + write cache. On LiteSpeed we send an
		// explicit "stand down" header so the server's LSCache module does
		// NOT cache + shadow our response — xSpeed's own .htaccess static
		// rewrite owns hit serving (and hit accounting) here, exactly as on
		// Apache. See maybe_emit_lscache_headers() for the full rationale.
		self::maybe_emit_lscache_headers();

		// We're about to render fresh + cache → miss for this request.
		Hit_Counter::record_miss();


		// WP < 6.9 fallback: ob_start() with a callback, paired with an
		// explicit shutdown close so the buffer lifecycle is visible to
		// reviewers and Plugin Check, instead of relying on PHP's implicit
		// request-end flush. We record our nesting level so close_buffer()
		// flushes ONLY the buffer we opened.
		ob_start( array( __CLASS__, 'finalize_buffer' ) );
		self::$buffer_level = ob_get_level();

		add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
	}

	/**
	 * Close the cache buffer opened by maybe_start_cache().
	 *
	 * Guarded by the recorded buffer level so we never flush a buffer that
	 * another plugin pushed on top of (or under) ours. If something else is
	 * currently on top, we leave the stack alone — PHP's shutdown sequence
	 * will unwind buffers in order and our finalize_buffer() callback will
	 * still run when our level becomes the topmost one.
	 */
	public static function close_buffer() {
		if ( null === self::$buffer_level ) {
			return;
		}
		if ( ob_get_level() === self::$buffer_level ) {
			ob_end_flush();
		}
		self::$buffer_level = null;
	}

	public static function should_cache() {
		$opts = Settings::get();
		if ( empty( $opts['cache_enabled'] ) ) {
			return false;
		}

		if ( is_user_logged_in() || is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return false;
		}

		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			return false;
		}

		// All exclusion knobs now owned by CacheModule.
		$cache_opts = Settings_Manager::get( 'cache' );

		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
		if ( 'GET' !== $method ) {
			return false;
		}

		// Search-results requests carry a `s` query param, which the
		// query-string gate below would normally reject as "dynamic". An
		// add-on (xspeed-pro search cache) can opt them in: when this is a
		// genuine is_search() and the filter returns true, the `s` param is
		// treated as cacheable (the search term goes into the cache key so
		// different searches stay distinct — see cache_key()).
		$cache_search = self::should_cache_search();

		// Feed opt-in is resolved BEFORE the query-string gate so query-form
		// feeds (/?feed=rss2, used on plain-permalink sites) aren't rejected
		// as "dynamic" by that gate — the `feed` param is then allowed through
		// just like the search `s` param. Feeds are excluded by default (the
		// `/feed/` pattern in excluded_urls); an add-on (xspeed-pro feed cache)
		// opts them back in via the filter. (FBS-82407 #4)
		$is_feed_request = function_exists( 'is_feed' ) && is_feed();
		/**
		 * Whether to cache the current feed request.
		 *
		 * Default false → feeds fall through to the normal URL-exclusion
		 * rules (so `/feed/` keeps them out). A listener returning true
		 * opts this feed request into caching.
		 *
		 * @param bool $cache_feed Whether to cache this feed request.
		 */
		$cache_feed = $is_feed_request && (bool) apply_filters( 'xspeed_should_cache_feed', false );

		// Query string handling: anything OUTSIDE the ignored-params
		// allow-list (utm_*, fbclid, gclid by default) means a unique
		// request that we don't want to share with the canonical cache
		// entry. Skip cache rather than poison the key.
		$query_raw = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
		if ( '' !== $query_raw ) {
			$ignored = is_array( $cache_opts['ignored_query_params'] ?? null ) ? $cache_opts['ignored_query_params'] : array();
			parse_str( $query_raw, $params );
			foreach ( $params as $key => $_ ) {
				// Allow the search param through when search caching is on.
				if ( $cache_search && 's' === $key ) {
					continue;
				}
				// Allow query-form feed params through when feed caching opted
				// this request in (?feed=rss2 / &withcomments=1 on feeds).
				if ( $cache_feed && in_array( $key, array( 'feed', 'withcomments', 'withoutcomments' ), true ) ) {
					continue;
				}
				if ( ! self::query_key_is_ignored( (string) $key, $ignored ) ) {
					return false;
				}
			}
		}

		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$path        = (string) strtok( $request_uri, '?' );

		$excluded_urls = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
		if ( ! $cache_feed && Glob_Matcher::any_match( $excluded_urls, $path ) ) {
			return false;
		}

		// Cookie-based exclusion. We only check cookie NAMES (matching
		// values would leak content-sensitive logic into the cache key
		// rules); presence of any matching cookie name skips cache.
		$excluded_cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
		if ( ! empty( $excluded_cookies ) && ! empty( $_COOKIE ) ) {
			foreach ( array_keys( $_COOKIE ) as $cookie_name ) {
				if ( Glob_Matcher::any_match( $excluded_cookies, (string) $cookie_name ) ) {
					return false;
				}
			}
		}

		// User-agent bypass list. Substring match (not glob) since UA
		// strings have so much variation that glob anchoring rarely
		// helps and confuses users.
		$bypass_uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
		if ( ! empty( $bypass_uas ) ) {
			$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
			foreach ( $bypass_uas as $needle ) {
				if ( '' !== $needle && false !== stripos( $ua, (string) $needle ) ) {
					return false;
				}
			}
		}

		// Per-post override (Phase 3.4). Honored only on singular
		// post-context requests — archives / 404s / taxonomies use the
		// global policy above.
		if ( Cache_Rules::should_skip_for_post( Cache_Rules::current_post_id() ) ) {
			return false;
		}

		/**
		 * Final say on whether the current request is cacheable.
		 *
		 * Runs at template_redirect (full WP context), so listeners may use
		 * conditional tags (is_search(), is_feed(), is_404(),
		 * wp_is_maintenance_mode(), …). The core engine has already applied
		 * its own exclusion rules and reached `true`; a listener returning
		 * false vetoes caching for this request. This is the documented
		 * extension point add-ons (xspeed-pro) hook to add their own
		 * request-level cache policy without forking the engine.
		 *
		 * Note: this gates the WRITE side. The pre-WP drop-in
		 * (advanced-cache.php) cannot run PHP filters, so request types that
		 * must never be *served* from a stale file are handled by not
		 * writing them here and/or by purging — see the conflict notes in
		 * advanced-cache.php.
		 *
		 * @param bool $should_cache Whether to cache the current request.
		 */
		return (bool) apply_filters( 'xspeed_should_cache', true );
	}

	/**
	 * Whether the current request is a 404 we may cache.
	 *
	 * True only when: it's a genuine main-query is_404(), an add-on opted
	 * in via `xspeed_should_cache_404` (default false), and the request
	 * isn't a transient 404 we must never freeze — maintenance mode or a
	 * 404 emitted while the DB/site is in an error state. The xspeed-pro
	 * 404 cache flips the filter; Free never caches 404s on its own.
	 */
	public static function should_cache_404(): bool {
		if ( ! function_exists( 'is_404' ) || ! is_404() ) {
			return false;
		}
		// Never cache a 404 served because the site is down for
		// maintenance — that screen disappears the moment maintenance
		// ends, and a cached copy would outlive it.
		if ( function_exists( 'wp_is_maintenance_mode' ) && wp_is_maintenance_mode() ) {
			return false;
		}

		/**
		 * Whether to cache the current 404 response.
		 *
		 * Default false. A listener returning true opts the (genuine)
		 * 404 into the page cache, served back for any unknown URL under
		 * one generic key. The 404 status is preserved on the HIT.
		 *
		 * @param bool $cache_404 Whether to cache this 404.
		 */
		return (bool) apply_filters( 'xspeed_should_cache_404', false );
	}

	/**
	 * Whether the current request is an internal search-results page we
	 * may cache.
	 *
	 * True only when: it's a genuine main-query is_search() with a
	 * non-empty term, and an add-on opted in via `xspeed_should_cache_search`
	 * (default false). The search term is folded into the cache key (see
	 * search_term() / cache_key()) so different searches stay distinct.
	 * The xspeed-pro search cache flips the filter; Free never caches
	 * search results on its own.
	 */
	public static function should_cache_search(): bool {
		if ( ! function_exists( 'is_search' ) || ! is_search() ) {
			return false;
		}
		// Empty search (`?s=`) renders the same as a normal archive and
		// carries no term to key on — let it fall through to the usual
		// rules rather than caching an ambiguous entry.
		if ( '' === self::search_term() ) {
			return false;
		}

		/**
		 * Whether to cache the current search-results request.
		 *
		 * Default false. A listener returning true opts the search page
		 * into the cache, keyed by the normalized search term.
		 *
		 * @param bool $cache_search Whether to cache this search request.
		 */
		return (bool) apply_filters( 'xspeed_should_cache_search', false );
	}

	/**
	 * The current request's normalized search term, or '' if none. Reads
	 * the raw `s` query param (works on the pre-WP drop-in path too, where
	 * get_search_query() isn't available), trims + lowercases so
	 * "WordPress" and "wordpress" share one entry, and collapses internal
	 * whitespace.
	 */
	public static function search_term(): string {
		$raw = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key derivation from a public search param; no state change.
		$raw = trim( $raw );
		if ( '' === $raw ) {
			return '';
		}
		$raw = preg_replace( '/\s+/', ' ', $raw );
		return function_exists( 'mb_strtolower' ) ? mb_strtolower( $raw ) : strtolower( $raw );
	}

	/**
	 * Is this query-string key on the ignored-params allow-list? Supports
	 * trailing-star globs (`utm_*` matches `utm_source`, `utm_medium`,
	 * etc.) so users don't have to enumerate every UTM variant.
	 */
	private static function query_key_is_ignored( string $key, array $ignored ): bool {
		return Glob_Matcher::any_match( $ignored, $key );
	}

	public static function cache_key() {
		$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default';

		// Cacheable 404s share ONE generic per-host entry — keying them by
		// URL would let a scanner flood (millions of random paths) bloat
		// the cache with identical 404 bodies. Both the write and the HIT
		// lookup run through here, so they agree on the key automatically.
		if ( self::should_cache_404() ) {
			return md5( $host . '|404' );
		}

		$uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
		// Strip the query string from the key so /post and /post?utm_*=…
		// share the same cache entry. should_cache() above already
		// rejected requests with non-ignored params, so by the time we
		// build the key the only params left are safe to drop.
		$uri = (string) strtok( $uri, '?' );

		// Optional device bucket: when mobile_separate is on, mobile and
		// desktop responses live in different cache files so themes that
		// serve different HTML by device (AMP, WPtouch, Jetpack mobile)
		// can't poison each other.
		$device = '';
		$opts   = Settings_Manager::get( 'cache' );
		if ( ! empty( $opts['mobile_separate'] ) ) {
			$device = self::is_mobile_request() ? '|m' : '|d';
		}

		// Search-results requests fold the normalized term into the key so
		// /?s=foo and /?s=bar get distinct entries (the query string is
		// otherwise stripped above). Only added when search caching opted
		// in, so non-search URLs are unaffected.
		$search = self::should_cache_search() ? '|s=' . self::search_term() : '';

		// Query-form feeds (/?feed=rss2 vs /?feed=atom) share the same path
		// once the query is stripped, so fold the feed type into the key to
		// keep the flavors distinct. Pretty-permalink feeds (/feed/rss/) carry
		// the type in $uri already and are unaffected. (FBS-82407 #4)
		$feed = '';
		if ( function_exists( 'is_feed' ) && is_feed() && function_exists( 'get_query_var' ) ) {
			$feed_type = (string) get_query_var( 'feed' );
			if ( '' !== $feed_type ) {
				$feed = '|feed=' . preg_replace( '/[^a-z0-9]/i', '', $feed_type );
			}
		}

		return md5( $host . $uri . $device . $search . $feed );
	}

	/**
	 * Server-side mobile detection. Prefers WordPress's `wp_is_mobile()`
	 * which uses the same UA tokens as core (so our bucket aligns with
	 * whatever theme-side branching uses). Falls back to a tiny inline
	 * detector if wp_is_mobile() isn't loaded (e.g. the drop-in path).
	 */
	private static function is_mobile_request(): bool {
		if ( function_exists( 'wp_is_mobile' ) ) {
			return (bool) wp_is_mobile();
		}
		// Fallback for the rare context where wp_is_mobile() isn't loaded.
		// Mirrors core's wp_is_mobile() EXACTLY — including the
		// Sec-CH-UA-Mobile client hint it checks *before* UA tokens — so the
		// bucket this picks matches whatever the engine's primary path (and
		// the drop-in's own copy of this logic) would pick for the same
		// request. Drift here re-introduces the cross-path key mismatch.
		if ( isset( $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ) ) {
			return '?1' === $_SERVER['HTTP_SEC_CH_UA_MOBILE'];
		}
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( '' === $ua ) {
			return false;
		}
		return (bool) preg_match( '/(Mobile|Android|Silk\/|Kindle|BlackBerry|Opera Mini|Opera Mobi)/i', $ua );
	}

	public static function cache_file_for( $key ) {
		return XSPEED_CACHE_DIR . '/' . $key . '.html';
	}

	/**
	 * If a precompressed Brotli sibling (`<file>.br`) exists and the client
	 * advertises `Accept-Encoding: br`, emit the Brotli response headers and
	 * return the `.br` path to stream. Returns null to fall through to the
	 * plain file. Keeps the PHP serve path in parity with the web server's
	 * static .br serving (mod_brotli / ngx_brotli rewrite).
	 *
	 * Free has no Brotli logic of its own — this only fires when an add-on
	 * (the Pro Brotli module) actually wrote the .br, so it's a safe no-op
	 * on Free-only installs.
	 *
	 * @param string $file Absolute path to the cached .html file.
	 * @return string|null The .br path to stream, or null to serve $file.
	 */
	public static function maybe_serve_brotli( string $file ): ?string {
		if ( headers_sent() ) {
			return null;
		}
		$accept = isset( $_SERVER['HTTP_ACCEPT_ENCODING'] )
			? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) )
			: '';
		// Match `br` as a token (comma/space delimited), not a substring, so
		// a hypothetical "xbr" encoding can't false-positive.
		if ( ! preg_match( '/(^|[\s,])br([\s,;]|$)/', $accept ) ) {
			return null;
		}
		$br = $file . '.br';
		if ( ! is_string( $br ) || ! file_exists( $br ) || ! is_readable( $br ) ) {
			return null;
		}
		header( 'Content-Encoding: br' );
		header( 'Vary: Accept-Encoding', false );
		// The byte length changes for the compressed body — drop any
		// Content-Length the caller may have set so the stream isn't
		// truncated/padded. readfile() lets the SAPI set the right length.
		header_remove( 'Content-Length' );
		return $br;
	}

	/**
	 * Sidecar metadata file for a cache entry. Holds response bits the HIT
	 * path must replay — Content-Type (cached feeds → application/rss+xml,
	 * sitemaps → text/xml) and status (a cached 404 must serve 404, not
	 * 200). JSON, one tiny file per entry, written only when there's
	 * something non-default to replay.
	 */
	public static function cache_meta_for( $key ) {
		return XSPEED_CACHE_DIR . '/' . $key . '.meta';
	}

	/**
	 * Read the .meta sidecar for a cache entry as an array, or [] if none.
	 * Keys: 'content_type' (string), 'status' (int). Used on the HIT path
	 * to replay them before streaming the file.
	 */
	private static function read_meta( $key ): array {
		$meta_file = self::cache_meta_for( $key );
		if ( ! file_exists( $meta_file ) ) {
			return array();
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend HIT.
		$raw  = file_get_contents( $meta_file );
		$data = json_decode( (string) $raw, true );
		return is_array( $data ) ? $data : array();
	}

	/**
	 * Conditional-GET support for a cache HIT. Emits Last-Modified + ETag
	 * derived from the cache file's mtime, and — when the request's
	 * If-Modified-Since / If-None-Match still match — sends 304 Not Modified
	 * and returns true (caller should exit without a body). Returns false to
	 * proceed with a normal 200 body. Lets aggregators/browsers skip
	 * re-downloading an unchanged cached response. (FBS-82407 #5)
	 *
	 * @param string $file Absolute path to the cache .html file.
	 * @return bool True when a 304 was sent.
	 */
	public static function serve_not_modified( string $file ): bool {
		$mtime = (int) filemtime( $file );
		if ( $mtime <= 0 ) {
			return false;
		}
		$last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
		$etag          = '"' . md5( $file . '|' . $mtime ) . '"';
		header( 'Last-Modified: ' . $last_modified );
		header( 'ETag: ' . $etag );

		$ims = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
		$inm = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';

		$etag_match = '' !== $inm && false !== strpos( $inm, $etag );
		$time_match = '' !== $ims && ( strtotime( $ims ) >= $mtime );

		if ( $etag_match || $time_match ) {
			if ( function_exists( 'http_response_code' ) ) {
				http_response_code( 304 );
			}
			return true;
		}
		return false;
	}

	public static function is_expired( $file ) {
		// cache_expiry now owned by CacheModule; per-post override
		// (Phase 3.4) shrinks the TTL further when the editor set one.
		$opts            = Settings_Manager::get( 'cache' );
		$max_age         = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
		$post_override   = Cache_Rules::expiry_override_seconds_for_post( Cache_Rules::current_post_id() );
		if ( null !== $post_override ) {
			$max_age = $post_override;
		}

		/**
		 * Filter the max-age (seconds) for the current cache entry.
		 *
		 * Lets an add-on apply a request-type-specific TTL — e.g. the
		 * xspeed-pro feed cache gives feeds a longer expiry than pages,
		 * since aggregators tolerate more staleness. Return seconds.
		 *
		 * @param int $max_age Computed max-age in seconds.
		 */
		$max_age = (int) apply_filters( 'xspeed_cache_max_age', $max_age );

		return ( time() - filemtime( $file ) ) > $max_age;
	}

	/**
	 * Accumulator for the full response body across all output-handler phases.
	 *
	 * PHP invokes an ob_start() callback once per flush, and each invocation
	 * only receives the chunk produced *since the previous flush*. If anything
	 * during the render calls `ob_flush()` or `flush()` (some themes, lazy-
	 * load plugins, AMP, etc. do), the final-phase call would otherwise only
	 * see the tail of the page — and we'd cache a truncated response that
	 * gets served repeatedly until purge. We accumulate every chunk here so
	 * the cache file always reflects the complete page.
	 *
	 * @var string
	 */
	private static $accumulated = '';

	public static function finalize_buffer( $buffer, $phase = PHP_OUTPUT_HANDLER_FINAL ) {
		self::$accumulated .= $buffer;

		// On non-final phases (mid-request flushes), pass the current chunk
		// through to the client unmodified and keep collecting. The WP 6.9
		// filter path always passes the full body in one shot with the
		// default $phase, so it falls straight through to the final block.
		$is_final = ( $phase & ( PHP_OUTPUT_HANDLER_FINAL | PHP_OUTPUT_HANDLER_END ) ) !== 0;
		if ( ! $is_final ) {
			return $buffer;
		}

		$full              = self::$accumulated;
		self::$accumulated = '';

		if ( strlen( $full ) < 255 ) {
			return $buffer;
		}

		// Status gate. We cache 200 by default. A 404 may be cached too,
		// but only when an add-on (xspeed-pro 404 cache) opts in for a
		// genuine is_404() — never a transient 404 (maintenance screen,
		// DB error, or a 404 emitted outside the main query), which would
		// otherwise be frozen until purge. Any other status is skipped.
		$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
		if ( 200 !== $status ) {
			if ( 404 !== $status || ! self::should_cache_404() ) {
				return $buffer;
			}
		}

		// If no mid-request flush happened, $buffer === $full and we can
		// safely minify the on-wire bytes too. Otherwise earlier chunks have
		// already been sent unminified, so we minify only what goes to disk —
		// the first visitor sees unminified HTML, every cache hit after that
		// is minified.
		$single_chunk = ( $buffer === $full );

		/**
		 * Filter: xspeed_cache_final_html
		 *
		 * Last chance to transform the fully-rendered page HTML before it is
		 * minified and written to the cache file. Runs on cache MISS only, so
		 * whatever a listener injects here is baked into the cached HTML and
		 * replayed on every subsequent HIT (the drop-in short-circuits before
		 * PHP on a HIT — a wp_head hook would never fire there).
		 *
		 * The Preload module uses this to inject the LCP-image <link rel=preload>
		 * + preconnect hints and add fetchpriority="high" to the hero <img>.
		 * Keep listeners fast and idempotent; this is the on-wire body.
		 *
		 * @param string $full Complete page HTML.
		 */
		$full = (string) apply_filters( 'xspeed_cache_final_html', $full );
		if ( $single_chunk ) {
			$buffer = $full;
		}

		// minify_html now owned by the Minify module; read through the
		// module's storage so this stays consistent with the engine that
		// applies CSS/JS minification.
		$minify_opts = Settings_Manager::get( 'minify' );
		if ( ! empty( $minify_opts['minify_html'] ) ) {
			$full = Minifier::minify_html( $full );
			if ( $single_chunk ) {
				$buffer = $full;
			}
		}

		if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
			wp_mkdir_p( XSPEED_CACHE_DIR );
			self::write_silence( XSPEED_CACHE_DIR );
		}

		// Path safety: cache_file_for() builds `XSPEED_CACHE_DIR . '/' . $key . '.html'`
		// where $key comes from md5() — guaranteed to be exactly 32 lowercase
		// hex chars, so no traversal sequence ('..', '/', null byte, etc.)
		// can appear. The write is therefore always inside XSPEED_CACHE_DIR.
		$key  = self::cache_key();
		$file = self::cache_file_for( $key );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache writes happen on frontend requests where it's unavailable.
		file_put_contents( $file, $full, LOCK_EX );

		/**
		 * Fires after the flat hash cache file ({md5}.html) is written.
		 *
		 * Mirror of `xspeed_static_file_written` for the flat cache. The PHP
		 * serve path (Cache::maybe_serve_brotli / the drop-in) serves THIS
		 * file and looks for a `{md5}.html.br` sibling — which only the Pro
		 * Brotli listener on this hook writes. Without it the .br sibling was
		 * never created and the PHP path could never serve Brotli (FBS-83039,
		 * Blocker 2): the static-tree .br (written on xspeed_static_file_written)
		 * lives in a different cache layout the PHP path never reads.
		 *
		 * @param string $file Absolute path to the flat cache file just written.
		 * @param string $full The HTML written to it.
		 */
		do_action( 'xspeed_flat_file_written', $file, $full );

		// Persist a non-default Content-Type so the HIT path can replay it
		// (cached feeds must serve application/rss+xml, not text/html).
		// Only written when the response set a content-type other than
		// the HTML default — pages don't pay for an extra file.
		self::write_meta( $key );

		// Static-cache tree (xspeed-static/{host}{path}/index.html). The
		// .htaccess rewrite block serves this file directly via the web
		// server, bypassing PHP for ~3-5× lower TTFB vs the drop-in path.
		// store_static() returns silently on any path/permission issue —
		// the drop-in remains the safety net.
		//
		// Skip it entirely when mobile_separate is on: the rewrite is
		// disabled in that mode (static_rewrite_allowed()), so a static file
		// would only be dead weight — and a device-blind one at that.
		// Skip the static-tree write for responses the web server can't replay
		// correctly: a non-200 status (a cached 404 would be served as a soft
		// 200, FBS-82406) or a non-HTML content-type (a cached feed would go
		// out as text/html, FBS-82407). The web server serves these .html files
		// directly with no PHP, so there's no .meta replay — keep them on the
		// drop-in / PHP path instead, which DOES replay status + content-type.
		if ( self::static_rewrite_allowed() && self::response_is_plain_html() ) {
			self::store_static( $full );
		}

		return $buffer;
	}

	/**
	 * Write the current response to the static-cache tree at
	 * `xspeed-static/{host}{request_uri}/index.html`. The web-server
	 * rewrite block points at this path so cache hits skip PHP
	 * entirely. Caller already minified/finalized $html.
	 *
	 * Path safety: $host is restricted to a `[a-zA-Z0-9.\-]` allowlist;
	 * $uri has its query string stripped, null bytes removed, '..'
	 * sequences collapsed, and after concatenation we verify the
	 * resolved real path stays inside XSPEED_CACHE_STATIC_DIR before
	 * any write. Anything off the happy path returns silently.
	 */
	private static function store_static( string $html ): void {
		$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		$uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$host = preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host );
		$uri  = str_replace( "\0", '', $uri );
		$uri  = (string) strtok( $uri, '?' );
		if ( '' === $host || '' === $uri ) {
			return;
		}
		// Collapse any traversal sequences before path resolution.
		$uri = preg_replace( '#/+#', '/', $uri );
		if ( false !== strpos( $uri, '..' ) ) {
			return;
		}

		$base = rtrim( XSPEED_CACHE_STATIC_DIR, '/' );
		$dir  = $base . '/' . $host . rtrim( $uri, '/' );
		$file = $dir . '/index.html';

		// Resolve the parent against the cache root to be sure the
		// final path is inside our tree even if the OS does anything
		// funny with multi-byte sequences.
		$base_real = realpath( WP_CONTENT_DIR );
		if ( false === $base_real || 0 !== strpos( $base, $base_real ) ) {
			return;
		}

		if ( ! file_exists( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		if ( ! is_dir( $dir ) ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- Same rationale as the flat-hash cache write above: WP_Filesystem isn't available on frontend requests, and the cache write must happen during shutdown.
		$written = file_put_contents( $file, $html, LOCK_EX );

		if ( false !== $written ) {
			/**
			 * Fires after a static cache file (index.html) is written.
			 *
			 * The extension point for serving pre-compressed siblings:
			 * the xspeed-pro Brotli module writes `index.html.br` next to
			 * the file here so the web server's static rewrite can serve a
			 * Brotli copy to clients that advertise `Accept-Encoding: br`,
			 * falling back to GZIP / the plain file otherwise. No core
			 * behavior depends on a listener being present.
			 *
			 * @param string $file Absolute path to the static cache file just written.
			 * @param string $html The HTML written to it.
			 */
			do_action( 'xspeed_static_file_written', $file, $html );
		}
	}

	/**
	 * Write the .meta sidecar for a cache entry when the response carries
	 * anything the HIT path must replay beyond a plain 200 text/html:
	 *   - a non-HTML Content-Type (cached feeds → application/rss+xml,
	 *     sitemaps → text/xml, …), and/or
	 *   - a non-200 status (a cached 404 must serve 404, not 200).
	 *
	 * Ordinary 200 text/html pages get NO .meta file, so the common path
	 * stays a single write.
	 *
	 * @param string $key Cache key for the current request.
	 */
	/**
	 * True only for a plain 200 text/html response — the only kind the
	 * web-server static tree can serve correctly (it streams the .html with
	 * no PHP, so it can't replay a 404 status or a feed Content-Type). Used
	 * to gate store_static() so cached 404s / feeds stay on the replay-capable
	 * drop-in / PHP path. (FBS-82406, FBS-82407)
	 */
	private static function response_is_plain_html(): bool {
		$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;
		if ( 200 !== $status && $status > 0 ) {
			return false;
		}
		foreach ( headers_list() as $header ) {
			if ( 0 === stripos( $header, 'content-type:' ) ) {
				$ct = trim( substr( $header, strlen( 'content-type:' ) ) );
				if ( '' !== $ct && false === stripos( $ct, 'text/html' ) ) {
					return false;
				}
			}
		}
		return true;
	}

	private static function write_meta( string $key ): void {
		$content_type = '';
		foreach ( headers_list() as $header ) {
			if ( 0 === stripos( $header, 'content-type:' ) ) {
				$content_type = trim( substr( $header, strlen( 'content-type:' ) ) );
			}
		}
		$status = function_exists( 'http_response_code' ) ? (int) http_response_code() : 200;

		$meta            = array();
		$is_default_type = ( '' === $content_type || false !== stripos( $content_type, 'text/html' ) );
		if ( ! $is_default_type ) {
			$meta['content_type'] = $content_type;
		}
		if ( 200 !== $status && $status > 0 ) {
			$meta['status'] = $status;
		}

		// Per-content TTL (seconds). The drop-in and static fast paths can't
		// call is_expired() / the xspeed_cache_max_age filter (they run before
		// WP), so persist the resolved max-age here whenever it differs from
		// the plain page TTL — e.g. the Pro feed cache's 12h vs the 24h page
		// default. The fast paths read this to expire correctly. (FBS-82407)
		$opts        = Settings_Manager::get( 'cache' );
		$default_ttl = (int) $opts['cache_expiry'] * HOUR_IN_SECONDS;
		$ttl         = (int) apply_filters( 'xspeed_cache_max_age', $default_ttl );
		if ( $ttl > 0 && $ttl !== $default_ttl ) {
			$meta['ttl'] = $ttl;
		}

		// Nothing to replay → no sidecar.
		if ( empty( $meta ) ) {
			return;
		}

		$payload = wp_json_encode( $meta );
		if ( false === $payload ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend shutdown write.
		file_put_contents( self::cache_meta_for( $key ), $payload, LOCK_EX );
	}

	/**
	 * @param string $cause Free-form human reason. Recorded in the
	 *                      Activity log to give users context (e.g.
	 *                      'post saved', 'settings change', 'manual',
	 *                      'theme switch').
	 */
	public static function purge_all( string $cause = 'manual' ) {
		$count = 0;
		if ( is_dir( XSPEED_CACHE_DIR ) ) {
			$files = glob( XSPEED_CACHE_DIR . '/*.html' );
			if ( $files ) {
				$count = count( $files );
				foreach ( $files as $f ) {
					wp_delete_file( $f );
				}
			}
			// Remove the .meta sidecars (content-type for feeds/sitemaps)
			// alongside their .html entries. Not counted — they're not
			// cache "pages", just per-entry metadata.
			$meta = glob( XSPEED_CACHE_DIR . '/*.meta' );
			if ( $meta ) {
				foreach ( $meta as $m ) {
					wp_delete_file( $m );
				}
			}
			// Remove precompressed siblings (e.g. <key>.html.br from the Pro
			// Brotli module). Not counted — same as .meta. Without this a
			// purge leaves stale .br bodies behind: disk bloat, and a
			// staleness window if precompression is later disabled.
			$br = glob( XSPEED_CACHE_DIR . '/*.br' );
			if ( $br ) {
				foreach ( $br as $b ) {
					wp_delete_file( $b );
				}
			}
		}
		// Static-cache tree purge — recursive because the layout is
		// xspeed-static/{host}/{path}/index.html, so a flat glob can't
		// reach everything.
		if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
			$count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
		}
		// REST response cache (cache/xspeed/rest/*.json) — same purge
		// triggers (publish, settings change) invalidate it too.
		$count += Rest_Cache::purge();

		// Minified + combined CSS/JS (cache/xspeed/min/ and min/combined/).
		// purge_all is a full filesystem sweep and must clear these too, even
		// when the Minify module is currently disabled — orphaned min/ files
		// from a feature the user later turned off must still be removed, and
		// a stale combined-<hash>.css that the regenerated page no longer
		// references otherwise 404s and breaks the frontend. (FBS-83114/83116)
		if ( class_exists( '\\XSpeed\\Minifier' ) ) {
			Minifier::purge_minified();
		}

		// Persistent object cache (Redis / Memcached). Flush regardless of
		// whether the Object Cache module is currently enabled — a drop-in
		// installed earlier keeps serving until flushed.
		if ( function_exists( 'wp_cache_flush' ) ) {
			wp_cache_flush();
		}

		self::update_stats( array( 'last_purge' => time() ) );

		// Fire AFTER the local sweep so module listeners (Critical CSS,
		// Unused CSS, Cloudflare edge purge) run — this action had three
		// registered listeners but was never emitted. Treat it as additive
		// (CDN / edge invalidation), not the mechanism for clearing local
		// files. (FBS-83114)
		do_action( 'xspeed_after_purge_all', $cause );

		// Trigger of WP_CLI / hook / admin-bar purges all hit the same
		// path. Record once with the supplied cause so the dashboard
		// activity feed reads naturally.
		Activity_Log::record(
			'cache_purged',
			sprintf( 'Cache purged (%s) — %d file%s removed', $cause, $count, 1 === $count ? '' : 's' ),
			Activity_Log::INFO
		);

		return $count;
	}

	/**
	 * The per-type purge menu, LiteSpeed-style. Each entry is a cache type
	 * the user can purge individually from the admin-bar dropdown. `visible`
	 * controls whether the item shows (active + licensed module only) — it
	 * NEVER limits Purge All, which always sweeps everything on disk.
	 *
	 * Pro registers its own types (Critical CSS, Unused CSS, …) by filtering
	 * `xspeed_purge_types`, so Free degrades gracefully when Pro is absent.
	 *
	 * @return array<string,array{label:string,visible:bool}>
	 */
	public static function purge_types(): array {
		$minify_on = false;
		if ( class_exists( '\\XSpeed\\Settings_Manager' ) ) {
			$min       = Settings_Manager::get( 'minify' );
			$minify_on = ! empty( $min['minify_css'] ) || ! empty( $min['minify_js'] ) || ! empty( $min['combine_css'] ) || ! empty( $min['combine_js'] );
		}
		// Object cache is "active" when an external object-cache drop-in is in
		// use — the canonical WP signal, independent of our settings option.
		$oc_on = function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache();

		$types = array(
			'all'    => array(
				'label'   => __( 'Purge All', 'xspeed' ),
				'visible' => true,
			),
			'page'   => array(
				'label'   => __( 'Purge Page / Static Cache', 'xspeed' ),
				'visible' => true,
			),
			'assets' => array(
				'label'   => __( 'Purge CSS / JS Cache', 'xspeed' ),
				'visible' => $minify_on,
			),
			'object' => array(
				'label'   => __( 'Purge Object Cache', 'xspeed' ),
				'visible' => $oc_on,
			),
			'rest'   => array(
				'label'   => __( 'Purge REST Cache', 'xspeed' ),
				'visible' => true,
			),
		);

		/**
		 * Filter the admin-bar purge-type menu. Pro modules add their own
		 * (Critical CSS, Unused CSS, CDN). Adding a type here only adds a
		 * MENU item — purge_type() must know how to handle the same slug.
		 *
		 * @param array $types Map of slug => [label, visible].
		 */
		return (array) apply_filters( 'xspeed_purge_types', $types );
	}

	/**
	 * Purge a single cache type by slug. 'all' delegates to purge_all();
	 * every other slug clears just its own artifacts. Unknown slugs (e.g. a
	 * Pro type) fan out via the `xspeed_purge_type_{slug}` action so the
	 * owning module can handle it. Returns the number of items removed where
	 * countable.
	 */
	public static function purge_type( string $type ): int {
		switch ( $type ) {
			case 'all':
				return self::purge_all( 'manual' );

			case 'page':
				$count = 0;
				if ( is_dir( XSPEED_CACHE_DIR ) ) {
					foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.html' ) as $f ) {
						wp_delete_file( $f );
						++$count;
					}
					foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.meta' ) as $m ) {
						wp_delete_file( $m );
					}
					foreach ( (array) glob( XSPEED_CACHE_DIR . '/*.br' ) as $b ) {
						wp_delete_file( $b );
					}
				}
				if ( is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
					$count += self::rmtree_html( XSPEED_CACHE_STATIC_DIR );
				}
				self::update_stats( array( 'last_purge' => time() ) );
				return $count;

			case 'assets':
				if ( class_exists( '\\XSpeed\\Minifier' ) ) {
					Minifier::purge_minified();
				}
				return 0;

			case 'object':
				if ( function_exists( 'wp_cache_flush' ) ) {
					wp_cache_flush();
				}
				return 0;

			case 'rest':
				return Rest_Cache::purge();

			default:
				// Pro / third-party type — let the owning module handle it.
				do_action( 'xspeed_purge_type_' . $type );
				return 0;
		}
	}

	/**
	 * Recursively delete every `index.html` (and its precompressed
	 * `index.html.br` sibling, if the Pro Brotli module wrote one) plus
	 * empty directories inside the static-cache tree. Used by purge_all().
	 * Returns the number of .html files removed so purge stats stay accurate
	 * across the flat + static caches — .br siblings are not counted
	 * (they're encodings of a page, not pages).
	 */
	private static function rmtree_html( string $dir ): int {
		if ( ! is_dir( $dir ) ) {
			return 0;
		}
		$removed = 0;
		// SCANDIR_SORT_NONE skips alphabetic sort — we're going to walk
		// the whole tree regardless of order.
		$entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false === $entries ) {
			return 0;
		}
		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}
			$path = $dir . '/' . $entry;
			if ( is_dir( $path ) ) {
				$removed += self::rmtree_html( $path );
				// Best-effort empty-dir cleanup; ignore failures (a
				// foreign file inside would block rmdir, which is fine).
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort empty-dir cleanup; WP_Filesystem needs admin credentials we don't have during a normal purge.
				@rmdir( $path );
				continue;
			}
			if ( substr( $entry, -5 ) === '.html' ) {
				wp_delete_file( $path );
				++$removed;
			} elseif ( substr( $entry, -3 ) === '.br' ) {
				// Precompressed sibling (index.html.br). Remove it too so a
				// purge doesn't orphan stale Brotli bodies. Not counted.
				wp_delete_file( $path );
			}
		}
		return $removed;
	}

	/**
	 * Drop a "silence is golden" index.php into a directory so apaches/nginx
	 * with directory listing enabled don't expose cache contents.
	 */
	public static function write_silence( $dir ) {
		$file = trailingslashit( $dir ) . 'index.php';
		if ( ! file_exists( $file ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context for credentials; cache dir setup may run during a frontend page render.
			file_put_contents( $file, "<?php\n// Silence is golden.\n" );
		}
	}

	/**
	 * Persist stats with autoload disabled — stats are only read in admin
	 * contexts, so there is no reason to inflate every frontend request's
	 * `wp_load_alloptions()` payload.
	 */
	private static function update_stats( array $stats ) {
		if ( false === get_option( 'xspeed_stats' ) ) {
			add_option( 'xspeed_stats', $stats, '', 'no' );
			return;
		}
		update_option( 'xspeed_stats', $stats );
	}

	public static function get_stats() {
		$count = 0;
		$size  = 0;
		if ( is_dir( XSPEED_CACHE_DIR ) ) {
			$files = glob( XSPEED_CACHE_DIR . '/*.html' );
			if ( $files ) {
				$count = count( $files );
				foreach ( $files as $f ) {
					$size += filesize( $f );
				}
			}
		}
		// Drain the HIT-log file BEFORE reading totals. Two serve paths that
		// bypass the normal in-PHP record_hit() append one line per HIT here:
		// the nginx server-level rewrite (see nginx_snippet(), never reaches
		// PHP) and the advanced-cache.php drop-in (runs pre-WordPress, can't
		// reach Hit_Counter). Without this drain both look like a 0% hit-ratio
		// on a perfectly working cache.
		Hit_Counter::collect_nginx_log_hits();

		// Apache/LiteSpeed static-rewrite HITs are served straight from disk
		// by .htaccess and never reach PHP either — but there's no .htaccess
		// equivalent of nginx's access_log directive, so we count them by
		// scanning the web server's own access log incrementally. No-op when
		// the log isn't readable (managed hosts) — see the method docblock.
		Hit_Counter::collect_server_log_hits();

		$stats  = get_option( 'xspeed_stats', array() );
		$totals = Hit_Counter::totals_24h();
		return array(
			'cached_pages' => $count,
			'cache_size'   => $size,
			'last_purge'   => isset( $stats['last_purge'] ) ? (int) $stats['last_purge'] : 0,
			// Rolling 24h cache performance — sourced from Hit_Counter's
			// hourly buckets. The frontend uses hit_ratio to drive the
			// CacheHero stat grid + the Health module's panel.
			'hits_24h'     => $totals['hits'],
			'misses_24h'   => $totals['misses'],
			'hit_ratio'    => $totals['ratio'],
		);
	}

	/**
	 * Apply the user's enable/disable choice. Called only from the REST
	 * toggle endpoint, which is gated by current_user_can( 'manage_options' )
	 * and a verified REST nonce. This is the only place the drop-in and
	 * the WP_CACHE constant are written — they MUST NOT happen on
	 * register_activation_hook (WordPress.org review requirement).
	 *
	 * @param bool $enable User's choice.
	 * @return array{
	 *     enabled: bool,
	 *     dropin_installed: bool,
	 *     wp_cache_constant: bool,
	 *     wp_config_writable: bool,
	 *     manual_snippet: ?string
	 * }
	 */
	public static function toggle( $enable ) {
		$enable = (bool) $enable;

		if ( $enable ) {
			$dropin_ok    = self::install_dropin();
			$wp_config_ok = self::set_wp_cache_constant( true );
			$rewrite_ok   = self::install_rewrite();
			self::ensure_hits_log_file();
			self::sync_mobile_flag();
			$snippet      = $wp_config_ok ? null : "define( 'WP_CACHE', true );";

			Activity_Log::record(
				'cache_enabled_event',
				$wp_config_ok
					? 'Cache enabled. Drop-in installed, WP_CACHE constant set.'
					: 'Cache enabled. Drop-in installed; wp-config.php not writable — add the WP_CACHE snippet manually.',
				$wp_config_ok ? Activity_Log::SUCCESS : Activity_Log::WARN
			);

			return array(
				'enabled'            => true,
				'dropin_installed'   => (bool) $dropin_ok,
				'wp_cache_constant'  => (bool) $wp_config_ok,
				'rewrite_installed'  => (bool) $rewrite_ok,
				'wp_config_writable' => self::wp_config_writable(),
				'manual_snippet'     => $snippet,
				'nginx_snippet'      => self::nginx_snippet(),
				// Unified server-block snippet aggregating every enabled
				// module's directives — the same value the dashboard and
				// Health insight render. The wizard shows this so all three
				// surfaces stay in lockstep. Null on non-nginx hosts.
				'nginx_server_block' => self::full_nginx_server_block(),
			);
		}

		self::remove_dropin();
		self::set_wp_cache_constant( false );
		self::remove_rewrite();
		// Drop the device-bucket marker too — with the drop-in gone there's
		// nothing left to read it, and leaving it behind would dirty a fresh
		// re-enable (and leaks across test runs).
		self::sync_mobile_flag( false );

		Activity_Log::record(
			'cache_disabled_event',
			'Cache disabled. Drop-in removed.',
			Activity_Log::INFO
		);

		return array(
			'enabled'            => false,
			'dropin_installed'   => false,
			'wp_cache_constant'  => false,
			'rewrite_installed'  => false,
			'wp_config_writable' => self::wp_config_writable(),
			'manual_snippet'     => null,
			'nginx_snippet'      => self::nginx_snippet(),
			'nginx_server_block' => self::full_nginx_server_block(),
		);
	}

	/**
	 * Check wp-config.php writability via WP_Filesystem. Plugin Check flags
	 * direct is_writable() under WordPress.WP.AlternativeFunctions.
	 */
	private static function wp_config_writable() {
		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();

		return $wp_filesystem ? (bool) $wp_filesystem->is_writable( ABSPATH . 'wp-config.php' ) : false;
	}

	/**
	 * Nginx server-block snippet mirroring the Apache rewrite block.
	 * We never auto-write nginx config — it sits outside the WordPress
	 * root and is owned by the server admin — but the dashboard
	 * surfaces this snippet when nginx is detected so the admin can
	 * paste it once and unlock the same PHP-bypass speedup we get on
	 * Apache / LiteSpeed via .htaccess.
	 *
	 * Returns null when the server isn't nginx (no point showing it).
	 */
	/**
	 * Create wp-content/cache/xspeed/hits.log as an empty file so the
	 * server-level rewrite's `access_log` directive has somewhere to
	 * write on first request. Idempotent — touches an existing file
	 * without disturbing accumulated lines. Called from Cache::toggle()
	 * on enable and from auto_heal() when the file is missing.
	 *
	 * Permissions matter here. The file is created by PHP-FPM (often uid
	 * www-data), but the nginx process that appends HIT lines may run as a
	 * DIFFERENT uid — on multi-container hosts (e.g. xclude/Kinsta: nginx in
	 * its own container as uid `nginx`, PHP-FPM in another as `www-data`)
	 * they don't share a user at all. A default-umask 0644 file is then
	 * unwritable by nginx, the access_log write silently fails, and the
	 * dashboard shows a 0% hit ratio even though static HITs are serving.
	 * So we widen the dir to 0777 and the file to 0666 — group/other write —
	 * so whatever uid nginx runs as can append. (The file holds only HIT
	 * request lines, no secrets.)
	 */
	/**
	 * Directory holding the nginx hit log. Lives under uploads/, NOT the
	 * cache dir — uninstall.php and a cache purge both delete the cache
	 * dir, which would orphan the pasted nginx `access_log` directive's
	 * parent directory and make `nginx -t` fail [emerg], taking down every
	 * vhost on the host (FBS-82478). uploads/ always exists, isn't a
	 * plugin-managed cache dir, and is never deleted on uninstall — so the
	 * directive's target dir survives both, and nginx (which creates a
	 * missing log FILE but not a missing DIR) can always open it.
	 *
	 * Falls back to the cache dir only if uploads is somehow unavailable.
	 */
	public static function hits_log_dir(): string {
		if ( function_exists( 'wp_upload_dir' ) ) {
			$uploads = wp_upload_dir( null, false );
			if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
				return rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed';
			}
		}
		return XSPEED_CACHE_DIR;
	}

	/** Absolute path to the nginx hit log file. */
	public static function hits_log_path(): string {
		return self::hits_log_dir() . '/hits.log';
	}

	/**
	 * Sync the drop-in's mobile-bucket flag file with the `mobile_separate`
	 * setting. The drop-in (advanced-cache.php) runs before WordPress loads,
	 * so it can't read the option — instead it checks for a zero-byte
	 * `.mobile-separate` marker next to the cache files. When the setting is
	 * on we touch the marker; when off we remove it. The drop-in's cache_key
	 * computation keys off the marker's presence so its '|m'/'|d' device
	 * bucket stays in lockstep with Cache::cache_key().
	 *
	 * Without this, turning on mobile_separate made Cache::store() write keys
	 * with a '|d'/'|m' suffix the drop-in never reproduced — so the drop-in's
	 * file_exists() always missed, every HIT fell through to a full WP boot,
	 * and the fast pre-WP path was silently dead.
	 *
	 * @param bool|null $enabled Force a state; null reads the current setting.
	 */
	public static function sync_mobile_flag( $enabled = null ): void {
		if ( null === $enabled ) {
			$opts    = Settings_Manager::get( 'cache' );
			$enabled = ! empty( $opts['mobile_separate'] );
		}
		$dir  = XSPEED_CACHE_DIR;
		$flag = $dir . '/.mobile-separate';
		if ( $enabled ) {
			if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
				return;
			}
			if ( ! file_exists( $flag ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
				@touch( $flag );
			}
			return;
		}
		if ( file_exists( $flag ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
			@unlink( $flag );
		}
	}

	/**
	 * Write / remove the `.maintenance-active` sentinel next to the cache
	 * files. The pre-WP drop-in checks for this marker and bails when present,
	 * so a page cached while the site was live is NOT served during
	 * maintenance / coming-soon mode — WordPress loads and renders the
	 * maintenance screen instead. The Pro Maintenance-Cache module drives this
	 * on the maintenance on/off transition. (FBS-82409 B1)
	 *
	 * @param bool $active True to arm the sentinel (entering maintenance),
	 *                     false to clear it (site recovered).
	 */
	public static function sync_maintenance_flag( bool $active ): void {
		$dir  = XSPEED_CACHE_DIR;
		$flag = $dir . '/.maintenance-active';
		if ( $active ) {
			if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
				return;
			}
			if ( ! file_exists( $flag ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged -- read by the pre-WP drop-in via file_exists(); must be a plain marker, not WP_Filesystem.
				@touch( $flag );
			}
			return;
		}
		if ( file_exists( $flag ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- plain marker removal; non-fatal.
			@unlink( $flag );
		}
	}

	/**
	 * Reconcile every mobile_separate-dependent artifact to the current
	 * setting. Called on boot and whenever the cache settings are saved, so
	 * flipping mobile_separate at runtime can't leave the install in a
	 * half-converted state.
	 *
	 * Three things must agree with the setting:
	 *   1. the drop-in's `.mobile-separate` flag (sync_mobile_flag()),
	 *   2. the device-blind server rewrite — present only when OFF
	 *      (static_rewrite_allowed()),
	 *   3. the now-stale static-cache tree + page cache, which were keyed
	 *      under the old scheme and would serve wrong-device HTML.
	 *
	 * No-ops when the cache is disabled — there's nothing installed to
	 * reconcile, and toggle() handles install/teardown itself.
	 */
	public static function reconcile_mobile_separate(): void {
		self::sync_mobile_flag();

		// The rewrite/static reconciliation below needs the plugin's path
		// constants. They're absent in early-boot / unit-test contexts where
		// only the drop-in flag matters — bail to the flag-only behavior then.
		if ( ! defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
			return;
		}

		// Only touch the rewrite + caches when caching is actually on.
		$opts = get_option( 'xspeed_options', array() );
		if ( empty( $opts['cache_enabled'] ) ) {
			return;
		}

		$rewrite_present = self::rewrite_installed();
		$rewrite_wanted  = self::static_rewrite_allowed();

		if ( $rewrite_present === $rewrite_wanted ) {
			// Already consistent — nothing flipped, leave caches intact so a
			// plain settings save (e.g. expiry change) doesn't blow the cache.
			return;
		}

		// The setting flipped. Bring the rewrite into line and purge the
		// now-misbucketed cache so the next request re-primes under the new
		// device scheme.
		if ( $rewrite_wanted ) {
			self::install_rewrite();
		} else {
			self::remove_rewrite();
		}
		self::purge_all( 'mobile_separate changed' );
	}

	/**
	 * Whether the server-level static-rewrite fast path may be used.
	 *
	 * The rewrite serves `{host}{path}/index.html` straight from the web
	 * server, keyed only by host + path — it has no way to run our PHP
	 * device detection, so it can't tell mobile from desktop. When
	 * `mobile_separate` is on, a single static file would be shared across
	 * devices and whoever primed it wins (mobile visitors could get desktop
	 * HTML, or vice-versa). Rather than duplicate a wp_is_mobile()-equivalent
	 * UA matcher into .htaccess AND the nginx snippet (three copies that
	 * would inevitably drift), we simply DON'T engage the static rewrite when
	 * mobile_separate is on. Requests then fall through to the PHP drop-in,
	 * which buckets correctly — a small TTFB cost (~85ms vs ~30ms) paid only
	 * on mobile-separate sites, in exchange for guaranteed correctness.
	 *
	 * LiteSpeed exclusion (2026-06-16): on LiteSpeed — OpenLiteSpeed in
	 * particular — `.htaccess` CAN run our RewriteRule to serve the static
	 * file, but its `.htaccess` engine ignores `mod_headers`, so we cannot
	 * stamp the served response with `X-XSpeed-Cache: HIT`, AND there is no
	 * `.htaccess` equivalent of nginx's per-location `access_log` to record
	 * the hit. The result was a cache that worked but was invisible: no HIT
	 * header and a hit-ratio frozen near 0%. Every OTHER server gives the
	 * user a visible HIT header + a counted hit (nginx via add_header +
	 * access_log in its snippet; Apache via .htaccess mod_headers, which it
	 * honors). To keep LiteSpeed CONSISTENT with the rest, we route its hits
	 * through the PHP drop-in instead — the drop-in emits
	 * `X-XSpeed-Cache: HIT (php)` and calls Hit_Counter inline, exactly the
	 * observable behavior the other servers get. The cost is the drop-in's
	 * ~30ms TTFB vs the static path's ~10ms, paid only on LiteSpeed; in
	 * exchange the dashboard hit-ratio and the response header finally tell
	 * the truth there. (Apache keeps the static fast path — it honors the
	 * header.) See maybe_emit_lscache_headers() for the paired LSCache
	 * stand-down that stops LiteSpeed's own module from shadowing the
	 * drop-in.
	 */
	public static function static_rewrite_allowed(): bool {
		// LiteSpeed: drop-in serves hits (visible + counted) — see docblock.
		if ( Server::LITESPEED === Server::type() ) {
			return false;
		}
		$opts = Settings_Manager::get( 'cache' );
		return empty( $opts['mobile_separate'] );
	}

	/**
	 * Why the device-blind static rewrite is NOT installed, when it isn't.
	 * Returns 'mobile_separate' when Separate Mobile Cache is the blocker
	 * (the static file is one-per-URL, so it can't coexist with per-device
	 * buckets), '' otherwise. Lets the dashboard explain the slow path
	 * instead of silently falling back to PHP serving. (FBS-83145)
	 */
	public static function static_rewrite_block_reason(): string {
		if ( Server::LITESPEED === Server::type() ) {
			return ''; // Intended on LiteSpeed — not a "block".
		}
		$opts = Settings_Manager::get( 'cache' );
		return ! empty( $opts['mobile_separate'] ) ? 'mobile_separate' : '';
	}

	/**
	 * Whether migration flagged Separate Mobile Cache for user review. Set by
	 * Migration::map_mobile_separate() when a source plugin (WP Rocket / WP
	 * Super Cache / LiteSpeed) had its "separate mobile cache" option on: we
	 * import it as OFF (to keep the device-blind static fast path) but record
	 * this flag so the dashboard can invite the user to turn it back on only
	 * if their site genuinely serves different HTML per device. (FBS-83145)
	 */
	public static function mobile_separate_needs_review(): bool {
		$opts = Settings_Manager::get( 'cache' );
		return ! empty( $opts['mobile_separate_review'] );
	}

	/**
	 * Clear the review flag — called when the user has acted on the prompt
	 * (dismissed it, or turned Separate Mobile Cache on/off deliberately) so
	 * the dashboard callout doesn't nag forever. Writes the option directly
	 * (bypassing Settings_Manager) so it never touches schema fields.
	 */
	public static function clear_mobile_separate_review(): void {
		$stored = get_option( 'xspeed_module_cache', array() );
		if ( ! is_array( $stored ) || empty( $stored['mobile_separate_review'] ) ) {
			return;
		}
		unset( $stored['mobile_separate_review'] );
		update_option( 'xspeed_module_cache', $stored );
	}

	/**
	 * On-demand probe: does the homepage serve materially the same HTML to a
	 * desktop and a mobile browser? Fetches home_url() twice over loopback —
	 * once with a desktop User-Agent, once with a mobile one — strips
	 * per-request noise (nonces, CSRF tokens, session ids, inline timestamps),
	 * and compares. When identical, Separate Mobile Cache is almost certainly
	 * unnecessary and the user can turn it off to regain the static fast path.
	 *
	 * NEVER run automatically (no page-load cost) — only from the dashboard
	 * "Check now" button. Result is cached for 10 minutes so a double-click or
	 * a re-render doesn't fire two more self-requests. (FBS-83145)
	 *
	 * @return array{ identical:bool, checked:bool, reason?:string, desktop_bytes?:int, mobile_bytes?:int }
	 */
	public static function probe_mobile_equality(): array {
		$cached = get_transient( 'xspeed_mobile_equality_probe' );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$home = home_url( '/' );
		$host = (string) wp_parse_url( $home, PHP_URL_HOST );
		if ( '' === $host ) {
			$result = array( 'identical' => false, 'checked' => false, 'reason' => 'home_url has no host' );
			set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
			return $result;
		}

		// Match WP core's own mobile detection (wp_is_mobile) so the probe
		// reflects what the site would actually branch on. iPhone Safari for
		// mobile; a current desktop Chrome UA for desktop.
		$desktop_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
		$mobile_ua  = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';

		$is_local = function_exists( 'wp_get_environment_type' )
			&& in_array( wp_get_environment_type(), array( 'local', 'development' ), true );

		$fetch = static function ( string $ua ) use ( $home, $is_local ) {
			$resp = wp_remote_get(
				$home,
				array(
					'timeout'     => 5,
					'sslverify'   => ! $is_local,
					'redirection' => 2,
					// Bust any per-device cache so we compare freshly-rendered
					// HTML, and pass the device UA the site would branch on.
					'user-agent'  => $ua,
					'headers'     => array( 'Cache-Control' => 'no-cache' ),
				)
			);
			if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
				return null;
			}
			return (string) wp_remote_retrieve_body( $resp );
		};

		$desktop = $fetch( $desktop_ua );
		$mobile  = $fetch( $mobile_ua );

		if ( null === $desktop || null === $mobile ) {
			$result = array( 'identical' => false, 'checked' => false, 'reason' => 'could not fetch homepage twice' );
			set_transient( 'xspeed_mobile_equality_probe', $result, MINUTE_IN_SECONDS );
			return $result;
		}

		$identical = self::normalize_html_for_diff( $desktop ) === self::normalize_html_for_diff( $mobile );

		$result = array(
			'identical'     => $identical,
			'checked'       => true,
			'desktop_bytes' => strlen( $desktop ),
			'mobile_bytes'  => strlen( $mobile ),
		);
		set_transient( 'xspeed_mobile_equality_probe', $result, 10 * MINUTE_IN_SECONDS );
		return $result;
	}

	/**
	 * Strip per-request noise from HTML so a desktop-vs-mobile diff reflects
	 * real structural differences, not nonces / session ids / timestamps that
	 * change on every render. Deliberately conservative: it normalizes the
	 * handful of well-known noise sources and collapses whitespace, so a site
	 * that truly serves different markup per device still compares as different.
	 */
	private static function normalize_html_for_diff( string $html ): string {
		$patterns = array(
			// WP nonces (data-nonce="...", _wpnonce=..., "nonce":"...").
			'/(_wpnonce|nonce|_ajax_nonce)["\']?\s*[:=]\s*["\']?[a-f0-9]{10}/i',
			// Generic 10+ hex tokens (CSRF, cache-buster hashes, session ids).
			'/\b[a-f0-9]{16,}\b/i',
			// wp-generated unique ids (e.g. wp-block ids, aria ids).
			'/(id|for|aria-[a-z]+)="[^"]*-[0-9]{3,}"/i',
			// ISO-ish timestamps + epoch-looking numbers in query strings.
			'/\?ver=[0-9.]+/',
			'/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+Z-]+/',
		);
		$html = (string) preg_replace( $patterns, 'X', $html );
		// Collapse all whitespace so trivial formatting differences don't count.
		return trim( (string) preg_replace( '/\s+/', ' ', $html ) );
	}

	public static function ensure_hits_log_file(): bool {
		// The HITs log exists ONLY so a server-level nginx `access_log`
		// directive has a world-writable file to append to (see nginx_snippet()
		// + Hit_Counter::collect_nginx_log_hits()). On Apache/LiteSpeed/managed
		// hosts nothing writes it, so creating it — and, worse, chmod()-ing it
		// world-writable — is pointless AND fails with "Operation not permitted"
		// when PHP can't chmod files it doesn't own (a warning that surfaces in
		// logs that capture @-suppressed errors). Skip the whole thing off nginx.
		if ( Server::NGINX !== Server::type() ) {
			return false;
		}
		$dir = self::hits_log_dir();
		if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
			return false;
		}
		// Ensure the dir is traversable + writable by a different-uid nginx.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- nginx (a separate uid in multi-container setups) must be able to create/append the log; WP_Filesystem layers ownership overrides that defeat that intent.
		@chmod( $dir, 0777 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort; the access_log just stays empty if it fails.
		$path = self::hits_log_path();
		if ( ! file_exists( $path ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- See docblock: must be a plain touch, not WP_Filesystem.
			@touch( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-fatal helper; failures already covered by the dir check.
		}
		// World-writable so a different-uid nginx can append HIT lines.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- See docblock.
		@chmod( $path, 0666 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort.
		return file_exists( $path );
	}

	public static function nginx_snippet(): ?string {
		if ( Server::NGINX !== Server::type() ) {
			return null;
		}
		$rel = '/' . ltrim( str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR ), '/' );
		$rel = rtrim( $rel, '/' );

		// WP-Rocket-canonical pattern: every condition lives at
		// SERVER level (outside any location block). Each one appends
		// a tag to $xspeed_no_cache; the final check is a single
		// string-equality against the unmodified default "no-cache".
		// Only when ALL conditions pass does the rewrite fire,
		// jumping the request to the static file's URL. nginx then
		// restarts location matching against the new path, where
		// regular static-file serving takes over.
		//
		// Why server-level + a single rewrite (instead of try_files
		// inside `location /`): nginx's well-documented "if is evil"
		// quirk silently disables `try_files`'s last fallback when
		// any `if` in the same location is true. Moving the `if`s
		// outside any location dodges the trap completely, because
		// server-level rewrite is the documented stable path.
		//
		// `last` (not `break`) restarts location matching — required
		// so the rewritten static-file URI gets served via the normal
		// static-file location, not re-matched against `location /`
		// where our own rewrite would loop.
		//
		// The cache existence check is the LAST condition in the
		// chain so when the file isn't cached, $xspeed_no_cache
		// gets a "-nofile" tag and the rewrite is skipped — the
		// request falls through to whatever `location /` the user
		// already had (typically `try_files $uri $uri/ /index.php?$args;`).
		// Absolute path to the hit-log file from the nginx process's
		// filesystem view. Nginx's `access_log buffer=N flush=Ns` form
		// requires a literal path — `$document_root` variables are
		// rejected — so PHP computes it. Lives under uploads/ (NOT the
		// cache dir): a cache purge or uninstall deletes the cache dir,
		// which would orphan this directive's parent directory and make
		// `nginx -t` fail [emerg] for EVERY vhost on the host
		// (FBS-82478). uploads/ survives both, so the directive can
		// never take nginx down. Works on every topology where the nginx
		// process shares a filesystem with PHP (container or host).
		$hits_abs = self::hits_log_path();

		$lines   = array();
		$lines[] = '# xSpeed static cache — paste at server level, above location / { }.';
		// Cache host must match the on-disk dir PHP writes: store_static() /
		// static_host() take HTTP_HOST and strip every char outside
		// [a-zA-Z0-9.\-] — i.e. it removes the colon but KEEPS the port digits
		// (localhost:8192 → localhost8192). nginx's own $host can't reproduce
		// that: $host has the port already stripped ENTIRELY (→ localhost), so
		// the -f check looks for localhost/... while PHP wrote localhost8192/...
		// and the rewrite never fires on a non-standard port. Derive
		// $xspeed_host from $http_host (which keeps the port) and drop just the
		// colon, so it equals the PHP dir on every port. On standard ports
		// $http_host has no colon, so $xspeed_host == $host == the bare domain.
		$lines[] = 'set $xspeed_host $http_host;'; // default: no port → unchanged (e.g. example.com)
		$lines[] = 'if ($http_host ~ "^([^:]+):(\\d+)$") { set $xspeed_host $1$2; }'; // host:port → hostport (matches PHP static_host())
		$lines[] = 'set $xspeed_no_cache "no-cache";';
		$lines[] = 'if ($request_method != GET) { set $xspeed_no_cache "$xspeed_no_cache-method"; }';
		$lines[] = 'if ($args) { set $xspeed_no_cache "$xspeed_no_cache-args"; }';
		$lines[] = 'if ($http_cookie ~* "(wordpress_logged_in|comment_author|wp-postpass_)") { set $xspeed_no_cache "$xspeed_no_cache-cookie"; }';
		$lines[] = 'if (!-f "$document_root' . $rel . '/$xspeed_host$uri/index.html") { set $xspeed_no_cache "$xspeed_no_cache-nofile"; }';
		// Neither `add_header` nor `access_log` is allowed inside an `if{}`
		// at server level (nginx rejects with "directive is not allowed
		// here"). The logging therefore lives in a `location` block that
		// matches the rewritten URI after `rewrite … last;` restarts
		// location matching. Every HIT lands there exactly once, every
		// MISS / PHP-served request never matches it.
		$lines[] = 'if ($xspeed_no_cache = "no-cache") {';
		$lines[] = '    rewrite ^ ' . $rel . '/$xspeed_host$uri/index.html last;';
		$lines[] = '}';
		$lines[] = '';
		$lines[] = '# Serve + log the cached HIT — `^~` is required so this beats any regex location.';
		$lines[] = 'location ^~ ' . $rel . '/ {';
		$lines[] = '    internal;';
		// LITERAL log path (not `set $var; access_log $var`). The variable form
		// makes nginx open the log lazily per-request and SILENTLY drop the
		// line if the open fails — so on a working host hits were served
		// (X-XSpeed-Cache fires regardless) but nothing was ever written and
		// the hit ratio sat at 0%. A literal path makes nginx open the file at
		// config load and actually log every hit.
		//
		// Deleting the log FILE is still safe with a literal path: nginx
		// recreates it on the next write/reload and `nginx -t` stays green
		// (verified). The only thing that [emerg]s `nginx -t` is a missing
		// parent DIRECTORY — and the log lives under uploads/xspeed/, which
		// survives cache purge + uninstall, and which ensure_hits_log_file()
		// (run on every admin_init via auto_heal) recreates if it ever goes
		// missing. So: hits are logged, and a user deleting the log can't take
		// nginx down.
		$lines[] = '    access_log ' . $hits_abs . ' combined buffer=16k flush=5s;';
		$lines[] = '    add_header X-XSpeed-Cache "HIT (nginx)" always;';
		$lines[] = '}';
		return implode( "\n", $lines );
	}

	/**
	 * Aggregate every enabled module's nginx_directives() into one
	 * pasteable server-block snippet. Replaces the per-module "paste
	 * this snippet" notices with a single consolidated paste — every
	 * future feature toggle just regenerates this output.
	 *
	 * Returns null on non-nginx hosts (nothing to paste).
	 *
	 * Sections render in module-registration order so the layout stays
	 * predictable; each module gets a comment header `# <slug>`.
	 */
	public static function full_nginx_server_block(): ?string {
		if ( Server::NGINX !== Server::type() ) {
			return null;
		}

		$blocks = array();
		foreach ( Module_Registry::all() as $module ) {
			$directives = $module->nginx_directives();
			if ( ! is_string( $directives ) || '' === trim( $directives ) ) {
				continue;
			}
			$blocks[] = "# === " . $module->slug() . " ===\n" . rtrim( $directives );
		}

		if ( empty( $blocks ) ) {
			return null;
		}

		$header = "# xSpeed unified nginx config — paste into `server { }`, above `location / { }`; re-paste after toggling features.\n";

		return $header . "\n" . implode( "\n\n", $blocks ) . "\n";
	}

	/**
	 * Tell LiteSpeed's LSCache module to stand down on the cache-miss
	 * render path.
	 *
	 * History: this method used to emit X-LiteSpeed-Cache-Control:
	 * public,max-age=N + X-LiteSpeed-Tag, handing caching to the server's
	 * LSCache store. That delegation backfired — once LSCache cached a
	 * page it served every subsequent request from its OWN store and
	 * intercepted the request before our site-root .htaccess static
	 * rewrite could run. Net effect on LiteSpeed hosts: no X-XSpeed-Cache
	 * header, our static-cache tree never served, the HIT log never
	 * written (hit ratio frozen at 0%), and the Health probe reporting a
	 * false "cache running on PHP fallback" because it never saw an
	 * xSpeed-served response.
	 *
	 * xSpeed now owns the cache on LiteSpeed exactly as it does on Apache:
	 * our `.htaccess` mod_rewrite block serves hits straight from the
	 * static-cache tree (with the X-XSpeed-Cache header + access-log HIT
	 * accounting), and PHP/the drop-in is the fallback. To guarantee
	 * LSCache doesn't shadow that with its own copy — some LiteSpeed
	 * configs cache by default — we send an explicit `no-cache` control so
	 * the server defers to our rewrite. Skipped when the LiteSpeed Cache
	 * plugin is active (it owns its own header policy; our Conflict
	 * registry handles that coexistence separately).
	 */
	public static function maybe_emit_lscache_headers(): void {
		if ( headers_sent() ) {
			return;
		}
		if ( Server::LITESPEED !== Server::type() ) {
			return;
		}
		// is_plugin_active() lives in wp-admin/includes/plugin.php which
		// isn't auto-loaded on front-end requests. Use the option layer
		// directly to avoid pulling in admin code from a render path.
		$active = (array) get_option( 'active_plugins', array() );
		if ( in_array( 'litespeed-cache/litespeed-cache.php', $active, true ) ) {
			return;
		}

		// Explicitly opt this response OUT of LSCache so the server can't
		// shadow our static-rewrite cache with its own internal copy.
		header( 'X-LiteSpeed-Cache-Control: no-cache' );
	}

	/**
	 * Reconcile drop-in + WP_CACHE + rewrite block with the user's
	 * saved choice. Runs on admin_init. Cheap when nothing's wrong
	 * (one option read + a handful of file_exists / defined checks);
	 * writes only when state has drifted (typical cause: plugin
	 * upgrade wiped the drop-in, foreign plugin removed our WP_CACHE
	 * define, or someone hand-edited .htaccess).
	 *
	 * Skipped during the WP plugin updater run so we don't race
	 * the upgrader's own filesystem operations.
	 */
	public static function auto_heal(): void {
		if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
			return;
		}
		if ( wp_doing_ajax() || wp_doing_cron() ) {
			return;
		}

		$opts = get_option( 'xspeed_options', array() );
		if ( empty( $opts['cache_enabled'] ) ) {
			return;
		}

		$dropin_target = WP_CONTENT_DIR . '/advanced-cache.php';
		$dropin_ours   = false;
		$dropin_stale  = false;
		if ( file_exists( $dropin_target ) ) {
			$contents    = @file_get_contents( $dropin_target ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			$dropin_ours = is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' );
			// Reinstall when OUR drop-in is an older version than the source —
			// the marker alone can't distinguish an old copy from a new one, so
			// a serve-logic change (e.g. the .meta read for 404s/feeds) would
			// otherwise never reach existing cache-enabled sites until a manual
			// cache toggle. (FBS-82406/82407)
			if ( $dropin_ours ) {
				$dropin_stale = self::dropin_version( (string) $contents ) < self::dropin_version( @file_get_contents( XSPEED_DIR . 'includes/advanced-cache.php' ) ?: '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
		}

		if ( ! $dropin_ours || $dropin_stale ) {
			self::install_dropin();
		}

		if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) {
			self::set_wp_cache_constant( true );
		}

		// Rewrite block goes last. It's what turns the static-cache
		// tree into a PHP-bypass — every cache hit served by the web
		// server directly. Without it we still cache, just at drop-in
		// speed (~85ms TTFB) instead of static-file speed (~25-40ms).
		//
		// Reconcile against mobile_separate: the rewrite is device-blind, so
		// it must be ABSENT when mobile_separate is on and PRESENT otherwise.
		// auto_heal() runs periodically, so it also repairs a rewrite that
		// was left installed before mobile_separate was switched on.
		if ( self::static_rewrite_allowed() ) {
			if ( ! self::rewrite_installed() ) {
				self::install_rewrite();
			}
		} elseif ( self::rewrite_installed() ) {
			self::remove_rewrite();
		}

		// HITs log file — nginx writes one line per HIT served directly
		// (see nginx_snippet()), Cache::get_stats() drains the file via
		// Hit_Counter::collect_nginx_log_hits(). If the file vanishes
		// (plugin upgrade wiped wp-content/cache/), nginx errors silently
		// on the access_log directive and the counter stays at 0.
		self::ensure_hits_log_file();
	}

	/**
	 * Build the .htaccess rules that map cacheable requests to the
	 * static-cache tree. Conditions are deliberately strict: GET only,
	 * empty query string, no session/comment-author/post-password
	 * cookie, and the static file must exist on disk. Anything that
	 * fails one of these falls through to PHP and the drop-in / full
	 * WordPress path.
	 *
	 * @return string[] Lines for insert_with_markers().
	 */
	public static function rewrite_block_lines(): array {
		// Path relative to ABSPATH so the rule lives in the site-root
		// .htaccess regardless of where wp-content sits. WP_CONTENT_DIR
		// can be moved, so we compute the document-root-relative form
		// at install time and bake it into the rule.
		$rel = str_replace( ABSPATH, '/', XSPEED_CACHE_STATIC_DIR );
		$rel = '/' . ltrim( $rel, '/' );
		$rel = rtrim( $rel, '/' );

		return array(
			'<IfModule mod_rewrite.c>',
			'  RewriteEngine On',
			'  RewriteCond %{REQUEST_METHOD} ^GET$',
			'  RewriteCond %{QUERY_STRING} ^$',
			'  RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|wp-postpass_) [NC]',
			// Capture REQUEST_URI without its trailing slash into %1.
			// store_static() writes `{host}{uri-without-trailing-slash}/index.html`,
			// so this normalization lets `/blog/` and `/blog` both hit
			// the same cache file without producing the double-slash
			// path that would skip the -f check below.
			'  RewriteCond %{REQUEST_URI} ^(.*?)/?$',
			'  RewriteCond %{DOCUMENT_ROOT}' . $rel . '/%{HTTP_HOST}%1/index.html -f',
			// Pattern is `^`, NOT `.`. The per-directory rewrite engine
			// strips the leading slash before matching, so the HOMEPAGE
			// request `/` arrives here as an EMPTY path. `.` requires at
			// least one character and therefore never matches the homepage
			// — on LiteSpeed (which honors this strictly) the front page
			// fell through to PHP while every inner page rewrote fine.
			// `^` matches the empty string AND any non-empty path, so it
			// covers `/` and `/blog` alike. (Confirmed on OpenLiteSpeed
			// 1.8: `.` → homepage served by PHP drop-in; `^` → served
			// directly from the static file.)
			'  RewriteRule ^ ' . $rel . '/%{HTTP_HOST}%1/index.html [L]',
			'</IfModule>',
		);
	}

	/**
	 * Active probe that confirms the web-server static-rewrite path is
	 * actually serving cached files. Writes a probe file with a random
	 * nonce, fetches it over HTTP at its public URL, and checks whether
	 * the response was served directly by the web server (Last-Modified
	 * + ETag headers + no X-Powered-By: PHP).
	 *
	 * Server-agnostic: same probe works for nginx (snippet pasted) and
	 * Apache / LiteSpeed (.htaccess block installed). If the rewrite
	 * isn't engaged, the request falls through to WordPress and PHP
	 * adds its own headers, which the probe detects and reports.
	 *
	 * Throttled via a 5-minute transient — we never want this running
	 * on every Health card paint.
	 *
	 * @return array{active:bool, reason:string, code?:int, php?:bool, expires?:int}
	 */
	/**
	 * @param bool $allow_probe When false (the default), return ONLY a cached
	 *   result and never make an HTTP request — so admin page loads are never
	 *   blocked by the loopback probe. The actual HTTP probe only runs when a
	 *   caller explicitly opts in (the Health tab / cron). Previously this ran
	 *   synchronously on every dashboard bootstrap, so a slow/timing-out
	 *   loopback request added up to `timeout` seconds to admin page loads on
	 *   hosts that block self-requests. (FBS-82142)
	 */
	public static function probe_static_rewrite( bool $allow_probe = false ): array {
		$cached = get_transient( 'xspeed_rewrite_probe' );
		if ( is_array( $cached ) ) {
			return $cached;
		}
		// No cached result yet and the caller doesn't want to pay for a live
		// HTTP probe (e.g. the admin bootstrap): report "pending" without
		// blocking. The Health tab will run the real probe on demand.
		if ( ! $allow_probe ) {
			return array( 'active' => false, 'reason' => 'probe pending', 'pending' => true );
		}

		$home = home_url( '/' );
		$host = (string) wp_parse_url( $home, PHP_URL_HOST );
		if ( '' === $host ) {
			$result = array( 'active' => false, 'reason' => 'home_url has no host' );
			set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
			return $result;
		}

		// Use a randomised path AND nonce so a stale CDN cache entry
		// from a prior probe can never make a broken install look
		// healthy. Path is namespaced under __xspeed_probe__ so the
		// directory listing stays obvious if cleanup misfires.
		$slug       = wp_generate_password( 12, false, false );
		$nonce      = wp_generate_password( 24, false, false );
		$probe_dir  = XSPEED_CACHE_STATIC_DIR . '/' . $host . '/__xspeed_probe__/' . $slug;
		$probe_file = $probe_dir . '/index.html';
		$probe_url  = trailingslashit( $home ) . '__xspeed_probe__/' . $slug . '/';

		if ( ! file_exists( $probe_dir ) ) {
			wp_mkdir_p( $probe_dir );
		}
		if ( ! is_dir( $probe_dir ) ) {
			$result = array( 'active' => false, 'reason' => 'cannot create probe dir' );
			set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS );
			return $result;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir.
		file_put_contents( $probe_file, $nonce, LOCK_EX );

		// Verify TLS by default — disabling it site-wide is a needless MITM
		// exposure (FBS-82142). Only relax verification in local/dev
		// environments, where self-signed certs are common and there's no
		// real attacker in the loop.
		$is_local  = function_exists( 'wp_get_environment_type' )
			&& in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
		$resp = wp_remote_get(
			$probe_url,
			array(
				// 3s cap so a host that hangs on loopback self-requests can't
				// stall the caller for long; the result/error is cached so we
				// don't repeat the wait every minute.
				'timeout'     => 3,
				'sslverify'   => ! $is_local,
				'redirection' => 0,
				'headers'     => array( 'Cache-Control' => 'no-cache' ),
			)
		);

		// Best-effort cleanup so we don't accumulate probe dirs even
		// if subsequent calls all hit the transient.
		if ( file_exists( $probe_file ) ) {
			wp_delete_file( $probe_file );
		}
		if ( is_dir( $probe_dir ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Best-effort probe-dir cleanup; WP_Filesystem needs admin credentials we don't have here.
			@rmdir( $probe_dir );
		}

		if ( is_wp_error( $resp ) ) {
			$result = array(
				'active' => false,
				'reason' => 'http error: ' . $resp->get_error_message(),
			);
			// Cache the failure for the full 5 minutes (not 1) so a host that
			// times out on the loopback probe isn't re-probed — and re-stalled
			// — on every page load within the window. (FBS-82142)
			set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
			return $result;
		}

		$code     = (int) wp_remote_retrieve_response_code( $resp );
		$body     = (string) wp_remote_retrieve_body( $resp );
		$ua_php   = '' !== (string) wp_remote_retrieve_header( $resp, 'x-powered-by' );
		$has_etag = '' !== (string) wp_remote_retrieve_header( $resp, 'etag' )
				 || '' !== (string) wp_remote_retrieve_header( $resp, 'last-modified' );
		$match    = trim( $body ) === $nonce;

		// "Active" = the web server served our raw nonce bytes back
		// AND emitted the static-serve markers (ETag / Last-Modified)
		// AND didn't add an X-Powered-By: PHP header. All three are
		// individually noisy; together they're conclusive.
		$active = $match && $has_etag && ! $ua_php && 200 === $code;

		if ( $active ) {
			$reason = 'static-served';
		} elseif ( 200 === $code && $match && $ua_php ) {
			$reason = 'php served the file instead of nginx/Apache (rewrite block missing)';
		} elseif ( 200 === $code && ! $match ) {
			$reason = 'unexpected body (CDN cached an older response?)';
		} elseif ( 404 === $code ) {
			$reason = 'probe URL returned 404 (rewrite block missing or wrong path)';
		} else {
			$reason = sprintf( 'unexpected response (HTTP %d, body %d B, php=%s)', $code, strlen( $body ), $ua_php ? 'yes' : 'no' );
		}

		$result = array(
			'active' => $active,
			'reason' => $reason,
			'code'   => $code,
			'php'    => $ua_php,
		);
		set_transient( 'xspeed_rewrite_probe', $result, 5 * MINUTE_IN_SECONDS );
		return $result;
	}

	public static function rewrite_installed(): bool {
		$htaccess = ABSPATH . '.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			return false;
		}
		$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! is_string( $existing ) ) {
			return false;
		}
		return false !== strpos( $existing, '# BEGIN xSpeed Static Cache' );
	}

	/**
	 * Install the static-cache rewrite block at the TOP of .htaccess.
	 *
	 * Position matters: WordPress's own block ends with
	 * `RewriteRule . /index.php [L]` which routes every non-file
	 * request to PHP. The [L] flag stops the current rewrite pass,
	 * but Apache restarts the cycle; on the second pass REQUEST_URI
	 * is /index.php and no static-file check can match. The only
	 * reliable position for a "serve static if it exists" rule is
	 * before WordPress's block.
	 *
	 * WP's insert_with_markers() always appends, so we manage the
	 * block manually: strip any prior xSpeed Static Cache markers,
	 * then write our block followed by the rest of the file.
	 */
	public static function install_rewrite(): bool {
		// The static rewrite is device-blind; never install it when
		// mobile_separate is on (see static_rewrite_allowed()).
		if ( ! self::static_rewrite_allowed() ) {
			return false;
		}
		$htaccess = ABSPATH . '.htaccess';
		$existing = file_exists( $htaccess ) ? @file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false === $existing ) {
			$existing = '';
		}
		// Apache/LiteSpeed only. nginx hosts: rule won't fire, drop-in
		// covers; we skip the write so we don't litter their root.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Pre-flight check before file_put_contents; WP_Filesystem requires admin credentials we don't have inside a manage_options REST request.
		if ( file_exists( $htaccess ) && ! is_writable( $htaccess ) ) {
			return false;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See above.
		if ( ! file_exists( $htaccess ) && ! is_writable( ABSPATH ) ) {
			return false;
		}

		$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
		$block   = self::marker_block( 'xSpeed Static Cache', self::rewrite_block_lines() );
		$next    = $block . ( '' === $cleaned ? '' : "\n" . $cleaned );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- WP_Filesystem requires admin credentials we don't have here; toggle() runs in a REST request authorized by manage_options nonce. The target is the site's .htaccess (configuration file managed by WP core itself), not user data — wp_upload_dir() doesn't apply.
		return false !== file_put_contents( $htaccess, $next, LOCK_EX );
	}

	public static function remove_rewrite(): bool {
		$htaccess = ABSPATH . '.htaccess';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- See install_rewrite() rationale.
		if ( ! file_exists( $htaccess ) || ! is_writable( $htaccess ) ) {
			return false;
		}
		$existing = @file_get_contents( $htaccess ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( false === $existing ) {
			return false;
		}
		$cleaned = self::strip_marker_block( $existing, 'xSpeed Static Cache' );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected -- See install_rewrite() rationale.
		return false !== file_put_contents( $htaccess, $cleaned, LOCK_EX );
	}

	/**
	 * Strip a `# BEGIN <marker>` ... `# END <marker>` block from a
	 * .htaccess-style file, including any blank line that immediately
	 * follows it. Idempotent — returns the input unchanged if the
	 * marker isn't present.
	 */
	private static function strip_marker_block( string $contents, string $marker ): string {
		$pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '\b.*?# END ' . preg_quote( $marker, '/' ) . "\b[^\n]*\n?\n?/s";
		$out     = preg_replace( $pattern, '', $contents );
		return is_string( $out ) ? $out : $contents;
	}

	private static function marker_block( string $marker, array $lines ): string {
		$header = "# BEGIN $marker\n";
		$header .= "# The directives (lines) between \"BEGIN $marker\" and \"END $marker\" are\n";
		$header .= "# dynamically generated, and should only be modified via WordPress filters.\n";
		$header .= "# Any changes to the directives between these markers will be overwritten.\n";
		$footer  = "# END $marker\n";
		return $header . implode( "\n", $lines ) . "\n" . $footer;
	}

	/**
	 * Parse the `XSPEED_DROPIN_VERSION: N` stamp out of a drop-in's source.
	 * Returns 0 when absent (an un-stamped older copy reinstalls). Used to
	 * detect a stale installed drop-in vs the bundled source.
	 */
	private static function dropin_version( string $contents ): int {
		if ( preg_match( '/XSPEED_DROPIN_VERSION:\s*(\d+)/', $contents, $m ) ) {
			return (int) $m[1];
		}
		return 0;
	}

	public static function install_dropin() {
		$source = XSPEED_DIR . 'includes/advanced-cache.php';
		$target = WP_CONTENT_DIR . '/advanced-cache.php';
		if ( ! file_exists( $source ) ) {
			return false;
		}

		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		if ( ! $wp_filesystem ) {
			return false;
		}

		$source_contents = $wp_filesystem->get_contents( $source );
		if ( ! is_string( $source_contents ) ) {
			return false;
		}

		// Bake the absolute hit-log path into the drop-in. It runs before
		// WordPress loads, so it can't resolve wp_upload_dir() itself — we
		// substitute the @@XSPEED_HITS_LOG@@ token with the real uploads path
		// (never the cache dir; see hits_log_dir() / FBS-82478). Use a single
		// quoted PHP string literal so the installed file stays valid PHP.
		$source_contents = str_replace(
			'@@XSPEED_HITS_LOG@@',
			str_replace( "'", "\\'", self::hits_log_path() ),
			$source_contents
		);

		if ( file_exists( $target ) ) {
			$existing = $wp_filesystem->get_contents( $target );
			$is_xspeed = is_string( $existing ) && false !== strpos( $existing, 'XSPEED_DROPIN' );

			if ( $is_xspeed ) {
				if ( $existing === $source_contents ) {
					return true;
				}
				return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
			}

			// Foreign drop-in (e.g. left over from another cache plugin) — back it up
			// before overwriting so the user can recover if needed. Uploads dir
			// (not wp-content root) keeps the backup out of WordPress's reserved
			// drop-in location.
			$upload  = wp_upload_dir( null, false );
			$basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
			if ( $basedir ) {
				if ( ! file_exists( $basedir ) ) {
					wp_mkdir_p( $basedir );
					self::write_silence( $basedir );
				}
				$backup = $basedir . '/advanced-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
				$wp_filesystem->move( $target, $backup, true );
			} else {
				$wp_filesystem->delete( $target );
			}
		}

		return (bool) $wp_filesystem->put_contents( $target, $source_contents, FS_CHMOD_FILE );
	}

	public static function remove_dropin() {
		$target = WP_CONTENT_DIR . '/advanced-cache.php';
		if ( ! file_exists( $target ) ) {
			return;
		}

		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		if ( ! $wp_filesystem ) {
			return;
		}

		$contents = $wp_filesystem->get_contents( $target );
		if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) {
			wp_delete_file( $target );
		}
	}

	public static function set_wp_cache_constant( $enable ) {
		$wp_config = ABSPATH . 'wp-config.php';
		if ( ! file_exists( $wp_config ) ) {
			return false;
		}

		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		if ( ! $wp_filesystem || ! $wp_filesystem->is_writable( $wp_config ) ) {
			return false;
		}

		$config = $wp_filesystem->get_contents( $wp_config );

		if ( $enable ) {
			// Own the constant. A previous caching plugin (e.g. WP Rocket sets
			// it false on deactivate) can leave `define( 'WP_CACHE', false );`
			// behind — presence alone is not enough, the VALUE must be true or
			// WordPress never loads advanced-cache.php and our drop-in is dead.
			if ( preg_match( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,/", $config ) ) {
				$rewritten = preg_replace(
					"/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*[^)]*\\)\\s*;/",
					"define( 'WP_CACHE', true );",
					$config,
					1
				);
				// If an existing define was already `true`, the rewrite is a
				// no-op string-wise; either way we end on WP_CACHE === true.
				if ( null !== $rewritten ) {
					$config = $rewritten;
				}
			} else {
				$config = preg_replace( '/(<\?php)/', "$1\ndefine( 'WP_CACHE', true );", $config, 1 );
			}
		} else {
			$config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config );
		}

		return (bool) $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
	}

	/**
	 * Admin-bar purge menu — a parent node plus one child per visible cache
	 * type (LiteSpeed-style), instead of a single "Purge All" link. Each
	 * child posts to the same admin-post handler with its type slug. The
	 * per-type items only appear for active/licensed modules; "Purge All"
	 * always shows and always sweeps everything. (FBS-83114)
	 */
	public function admin_bar_purge( $wp_admin_bar ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$wp_admin_bar->add_node(
			array(
				'id'    => 'xspeed-purge',
				'title' => __( 'xSpeed Cache', 'xspeed' ),
				'href'  => self::purge_type_url( 'all' ),
			)
		);

		foreach ( self::purge_types() as $slug => $type ) {
			if ( empty( $type['visible'] ) ) {
				continue;
			}
			$wp_admin_bar->add_node(
				array(
					'id'     => 'xspeed-purge-' . $slug,
					'parent' => 'xspeed-purge',
					'title'  => esc_html( $type['label'] ),
					'href'   => self::purge_type_url( $slug ),
				)
			);
		}
	}

	/**
	 * Nonce-protected admin-post URL for purging a single type. The nonce
	 * action is per-type so a leaked URL can't be replayed for a different
	 * scope.
	 */
	private static function purge_type_url( string $type ): string {
		return wp_nonce_url(
			admin_url( 'admin-post.php?action=xspeed_purge&type=' . rawurlencode( $type ) ),
			'xspeed_purge_' . $type
		);
	}

	public function handle_admin_bar_purge() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
		}
		$type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all';
		check_admin_referer( 'xspeed_purge_' . $type );

		// Only honour known types; anything else falls back to a full purge.
		if ( ! array_key_exists( $type, self::purge_types() ) ) {
			$type = 'all';
		}
		self::purge_type( $type );

		wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) );
		exit;
	}

	/**
	 * Resolve a safe redirect target for an admin-bar purge.
	 *
	 * The purge sends the admin back where they came from — but the referer
	 * can be a ONE-SHOT action URL (e.g. update.php?action=upload-plugin from
	 * installing a plugin zip, or any *.php?action=… that consumed a POST /
	 * temp upload). Redirecting there re-runs the action with nothing to act
	 * on, so WordPress dies — the classic "Please select a file" from
	 * File_Upload_Upgrader. Strip the transient action args so we return to a
	 * safe, re-GET-able view of the same page; fall back to the dashboard when
	 * there is no usable referer.
	 *
	 * @param string|false $referer Raw wp_get_referer() value.
	 * @return string Safe URL to redirect to.
	 */
	public static function safe_purge_redirect( $referer ): string {
		$referer = is_string( $referer ) ? $referer : '';
		if ( '' === $referer ) {
			return admin_url();
		}

		// A referer that lands on an action-processing endpoint (update.php,
		// update-core.php, plugin/theme install/upload flows) can't be safely
		// re-requested — send them to the dashboard instead of replaying it.
		$path = (string) wp_parse_url( $referer, PHP_URL_PATH );
		if ( preg_match( '#/wp-admin/(update|update-core)\.php$#', $path ) ) {
			return admin_url();
		}

		// Otherwise keep them on the same page but drop the query args that
		// would re-trigger a form action or upload on load.
		return remove_query_arg(
			array( 'action', 'action2', 'package', 'overwrite', 'plugin', 'theme', 'file', '_wpnonce', '_ajax_nonce' ),
			$referer
		);
	}
}

```
