<?php
/**
 * CacheLoop – Static Page Cache (Free)
 *
 * Generates static HTML files for pages and posts, serves them directly,
 * and purges them automatically when content changes.
 *
 * @package CacheLoop
 */

// Security: Prevent direct file access.
defined( 'ABSPATH' ) || exit;

/**
 * Class CACHELOOP_Page_Cache
 *
 * Handles static HTML page caching.
 */
class CACHELOOP_Page_Cache {

	/**
	 * @var array Settings array.
	 * @psalm-var array<string, mixed>
	 */
	private $settings;

	/**
	 * Constructor: hooks into WordPress lifecycle.
	 *
	 * @param array $settings Plugin settings.
	 */
	public function __construct( $settings = array() ) {
		$this->settings = $settings;

		// Start output buffering early to capture the full HTML response.
		add_action( 'template_redirect', array( $this, 'maybe_serve_or_start_cache' ), 1 );

		// CDN URL rewriting on cached HTML.
		add_filter( 'cacheloop_process_html', array( $this, 'rewrite_cdn_urls' ) );

		// HTTP/2 Server Push for critical assets.
		add_action( 'wp_head', array( $this, 'inject_server_push_headers' ), 0 );

		// Purge cache when content is saved or updated.
		add_action( 'save_post', array( $this, 'purge_post_cache' ), 10, 1 );
		add_action( 'comment_post', array( $this, 'purge_post_cache_by_comment' ), 10, 2 );
		add_action( 'switch_theme', array( $this, 'flush_all' ) );
		add_action( 'upgrader_process_complete', array( $this, 'flush_all' ) );
		add_action( 'wp_update_nav_menu', array( $this, 'flush_all' ) );
		add_action( 'customize_save_after', array( $this, 'flush_all' ) );

		// Load logger for cache hit/miss logging.
		if ( class_exists( 'CACHELOOP_Logger' ) === false ) {
			$logger_path = CACHELOOP_PLUGIN_DIR . 'includes/class-cl-logger.php';
			if ( file_exists( $logger_path ) ) {
				require_once $logger_path;
			}
		}
	}

	// ---------------------------------------------------------------------------
	// Serve / Generate Cache
	// ---------------------------------------------------------------------------

	/**
	 * If a valid cache file exists, serve it and exit.
	 * Otherwise start output buffering so we can write a new cache file.
	 *
	 * @return void
	 */
	public function maybe_serve_or_start_cache() {
		// Prevent double-serving: if the drop-in already handled this request.
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- internal marker the drop-in sets itself, not user input.
		if ( isset( $_SERVER['CACHELOOP_DROPIN_SERVED'] ) && $_SERVER['CACHELOOP_DROPIN_SERVED'] ) {
			return;
		}

		// Never cache for these situations.
		if ( $this->should_bypass_cache() ) {
			return;
		}

		$cache_file  = $this->get_cache_file_path();
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';

		// Serve existing cache file (Fallback if advanced-cache.php didn't catch it).
		$ttl = defined( 'CACHELOOP_TTL' ) ? (int) CACHELOOP_TTL : DAY_IN_SECONDS;
		if ( file_exists( $cache_file ) && ( time() - filemtime( $cache_file ) ) < $ttl ) {
			header( 'X-CacheLoop-Cache: HIT-FALLBACK' );

			// Log cache hit.
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_hit( $request_uri );
			}
			if ( class_exists( 'CACHELOOP_Stats' ) ) {
				CACHELOOP_Stats::record_hit();
			}

            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Pre-built HTML cache file.
			echo file_get_contents( $cache_file );
			exit;
		}

		// Log cache miss (fallback path). Only this specific reason —
		// "no cache file exists, page must be generated" — counts as a
		// miss for reporting; the other bypass reasons in
		// should_bypass_cache() below (logged-in user, query string, etc.)
		// are pages that were never eligible for caching, not a cache
		// underperforming, so they're deliberately not recorded here.
		if ( class_exists( 'CACHELOOP_Logger' ) ) {
			CACHELOOP_Logger::cache_miss( $request_uri, 'fallback-no-cache-file' );
		}
		if ( class_exists( 'CACHELOOP_Stats' ) ) {
			CACHELOOP_Stats::record_miss();
		}

		// Start output buffer to capture response for caching.
		ob_start( array( $this, 'write_cache_file' ) );
	}

	/**
	 * Callback for ob_start: writes the captured HTML to a cache file.
	 *
	 * @param  string $buffer The full page HTML.
	 * @return string         The original buffer (passed through unchanged).
	 */
	public function write_cache_file( $buffer ) {
		// Only cache non-empty, successful HTML responses.
		if ( empty( $buffer ) || strlen( $buffer ) < 255 ) {
			return $buffer;
		}

		$cache_file = $this->get_cache_file_path();
		$cache_dir  = dirname( $cache_file );

		// Create the directory if it does not exist.
		if ( ! is_dir( $cache_dir ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
			wp_mkdir_p( $cache_dir );
		}

		// Concurrent write protection: acquire a non-blocking lock.
		// If another process is already writing this page, skip caching to avoid corruption.
		$fp = $this->acquire_lock( $cache_file );
		if ( ! $fp ) {
			return $buffer;
		}

		try {
			// Allow other modules (like Delay JS) to process the HTML before caching.
			$buffer = apply_filters( 'cacheloop_process_html', $buffer );

			// Append a generation timestamp comment for debugging.
			$content = $buffer . "\n<!-- CacheLoop cached on " . gmdate( 'Y-m-d H:i:s' ) . ' UTC -->';

			// Re-check right before writing: a concurrent flush_all() from
			// another request (settings save, Clear Cache, cron) can remove
			// this exact subdirectory in the gap between the check above and
			// now — confirmed in a production error log as
			// "file_put_contents(): Failed to open stream: No such file or directory".
			if ( ! is_dir( $cache_dir ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
				wp_mkdir_p( $cache_dir );
			}

            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $cache_file, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents
		} finally {
			$this->release_lock( $fp, $cache_file );
		}

		return $buffer;
	}

	// ---------------------------------------------------------------------------
	// CDN Rewriting
	// ---------------------------------------------------------------------------

	/**
	 * Rewrites local URLs to CDN URLs in cached HTML.
	 *
	 * @param  string $html The HTML content.
	 * @return string       The HTML with CDN URLs.
	 */
	public function rewrite_cdn_urls( $html ) {
		if ( empty( $this->settings['cdn_enabled'] ) || empty( $this->settings['cdn_domain'] ) ) {
			return $html;
		}

		$cdn_url  = 'https://' . $this->settings['cdn_domain'];
		$home_url = home_url();

		// Replace https:// and http:// home_url occurrences in attribute values.
		$html = str_replace(
			array( '="' . $home_url . '/', '="' . $home_url . '"', "='" . $home_url . '/', "='" . $home_url . "'" ),
			array( '="' . $cdn_url . '/', '="' . $cdn_url . '"', "='" . $cdn_url . '/', "='" . $cdn_url . "'" ),
			$html
		);

		// Replace srcset with home_url.
		$html = str_replace(
			$home_url . '/',
			$cdn_url . '/',
			$html
		);

		// --- CDN asset rewriting for inline CSS/url() declarations ---

		// Rewrite url() inside <style> blocks and inline style="..." attributes.
		// Matches: url(http://example.com/path/to/file.ext)
		$host = wp_parse_url( $home_url, PHP_URL_HOST ) ?: '.*';
		$html = preg_replace_callback(
			'#url\(\s*[\'"]?https?://' . preg_quote( $host, '#' ) . '/([^\'"\)]+)\s*[\'"]?\)#',
			function ( $matches ) use ( $cdn_url ) {
				return 'url(\'' . $cdn_url . '/' . $matches[1] . '\')';
			},
			$html
		);

		// Rewrite protocol-relative url(): url(//example.com/path)
		$html = preg_replace_callback(
			'#url\(\s*[\'"]?//' . preg_quote( $host, '#' ) . '/([^\'"\)]+)\s*[\'"]?\)#',
			function ( $matches ) use ( $cdn_url ) {
				return 'url(\'' . $cdn_url . '/' . $matches[1] . '\')';
			},
			$html
		);

		return $html;
	}

	// ---------------------------------------------------------------------------
	// HTTP/2 Server Push
	// ---------------------------------------------------------------------------

	/**
	 * Injects Link headers for HTTP/2 Server Push of critical assets.
	 *
	 * @return void
	 */
	public function inject_server_push_headers() {
		// Only push on front-page, non-cached requests.
		if ( ! is_front_page() || is_admin() ) {
			return;
		}

		// Collect critical CSS/JS from the page.
		$critical_assets = $this->get_critical_assets();

		foreach ( $critical_assets as $asset ) {
			header( 'Link: <' . esc_url( $asset ) . '>; rel=preload; as=style; nopush', false );
		}
	}

	/**
	 * Extracts critical asset URLs for HTTP/2 Server Push.
	 *
	 * @return array Array of critical asset URLs.
	 */
	private function get_critical_assets() {
		$assets = array();

		// Common critical stylesheet paths.
		$assets[] = get_stylesheet_directory_uri() . '/style.css';
		$assets[] = get_template_directory_uri() . '/style.css';

		// Add theme CSS from wp_head if available.
		$critical_css = apply_filters( 'cacheloop_critical_css', array() );
		$assets       = array_merge( $assets, $critical_css );

		return array_unique( $assets );
	}

	// ---------------------------------------------------------------------------
	// Cache Bypass Logic
	// ---------------------------------------------------------------------------

	/**
	 * Determines whether caching should be bypassed for the current request.
	 *
	 * @return bool True if caching should be skipped.
	 */
	private function should_bypass_cache() {
		// Respect DONOTCACHEPAGE constant (used by WooCommerce and others).
		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'DONOTCACHEPAGE' );
			}
			return true;
		}

		// Bypass for authenticated users.
		if ( is_user_logged_in() ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'logged-in-user' );
			}
			return true;
		}

		// Bypass for non-GET requests (POST, PUT, etc.).
		$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
		if ( 'GET' !== $request_method ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'non-get-method-' . $request_method );
			}
			return true;
		}

		// Bypass when query strings are present (dynamic pages, search, etc.).
		$query_string = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
		if ( ! empty( $query_string ) ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'query-string-present' );
			}
			return true;
		}

		// Check whitelist/blacklist.
		if ( $this->should_bypass_for_rules() ) {
			return true;
		}

		// Bypass for WooCommerce dynamic pages (cart, checkout, my-account).
		if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'woocommerce-dynamic-page' );
			}
			return true;
		}

		// Bypass for feeds and search results.
		if ( is_feed() || is_search() ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'feed-or-search' );
			}
			return true;
		}

		// Bypass for 404 pages (no value in caching errors).
		if ( is_404() ) {
			if ( class_exists( 'CACHELOOP_Logger' ) ) {
				CACHELOOP_Logger::cache_miss( $this->get_current_url(), '404-page' );
			}
			return true;
		}

		return false;
	}

	/**
	 * Returns the current request URL for logging.
	 *
	 * @return string Current URL path.
	 */
	private function get_current_url() {
		$scheme = isset( $_SERVER['HTTPS'] ) && 'on' === $_SERVER['HTTPS'] ? 'https' : 'http';
		$host   = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'localhost';
		$uri    = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
		return $scheme . '://' . $host . $uri;
	}

	/**
	 * Checks whitelist/blacklist rules to determine if caching should be bypassed.
	 *
	 * @return bool True if a rule matches and caching should be skipped.
	 */
	private function should_bypass_for_rules() {
		if ( empty( $this->settings['cache_whitelist'] ) && empty( $this->settings['cache_blacklist'] ) ) {
			return false;
		}

		$current_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';

		// Blacklist: if any pattern matches, skip cache.
		if ( ! empty( $this->settings['cache_blacklist'] ) ) {
			$patterns = array_map( 'trim', explode( "\n", $this->settings['cache_blacklist'] ) );
			foreach ( $patterns as $pattern ) {
				if ( empty( $pattern ) ) {
					continue;
				}
				// Support wildcard matching (* = any characters).
				$regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#';
				if ( preg_match( $regex, $current_uri ) ) {
					if ( class_exists( 'CACHELOOP_Logger' ) ) {
						CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'blacklist-match:' . $pattern );
					}
					return true;
				}
			}
		}

		// Whitelist: if enabled, only cached URIs that match are allowed.
		if ( ! empty( $this->settings['cache_whitelist'] ) ) {
			$enabled = isset( $this->settings['cache_whitelist_enabled'] ) && $this->settings['cache_whitelist_enabled'];
			if ( $enabled ) {
				$patterns = array_map( 'trim', explode( "\n", $this->settings['cache_whitelist'] ) );
				$matched  = false;
				foreach ( $patterns as $pattern ) {
					if ( empty( $pattern ) ) {
						continue;
					}
					$regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#';
					if ( preg_match( $regex, $current_uri ) ) {
						$matched = true;
						break;
					}
				}
				if ( ! $matched ) {
					if ( class_exists( 'CACHELOOP_Logger' ) ) {
						CACHELOOP_Logger::cache_miss( $this->get_current_url(), 'whitelist-not-matched' );
					}
					return true;
				}
			}
		}

		return false;
	}

	// ---------------------------------------------------------------------------
	// Cache File Path Helper
	// ---------------------------------------------------------------------------

	/**
	 * Detects the device type from the User-Agent.
	 *
	 * @return string 'desktop' or 'mobile'.
	 */
	private function get_device_type() {
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : '';

		// Mobile detection: common mobile keywords.
		if ( preg_match( '/Mobile|Android|iPod|iPhone|iPad|Opera Mini|IEMobile|WPDesktop/i', $ua ) ) {
			return 'mobile';
		}

		return 'desktop';
	}

	/**
	 * Acquires a file lock for cache writing to prevent concurrent write corruption.
	 *
	 * @param string $cache_file Full path to the cache file.
	 * @return resource|null File handle locked, or null on failure.
	 */
	private function acquire_lock( $cache_file ) {
		$lock_file = $cache_file . '.lock';
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- flock() locking has no WP_Filesystem equivalent.
		$fp = fopen( $lock_file, 'c' );
		if ( $fp && ! flock( $fp, LOCK_EX | LOCK_NB ) ) {
			// Another process is writing — skip caching this request.
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $fp );
			return null;
		}
		return $fp;
	}

	/**
	 * Releases a file lock.
	 *
	 * @param resource|null $fp File handle.
	 * @param string        $cache_file Full path to the cache file.
	 * @return void
	 */
	private function release_lock( $fp, $cache_file ) {
		if ( $fp ) {
			flock( $fp, LOCK_UN );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $fp );
			$lock_file = $cache_file . '.lock';
			if ( file_exists( $lock_file ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				unlink( $lock_file );
			}
		}
	}

	/**
	 * Returns the absolute path to the cache file for the current request URI.
	 *
	 * @return string Absolute path to the .html cache file.
	 */
	private function get_cache_file_path() {
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';

		// Strip query strings to ensure exact match with advanced-cache.php
		$parsed_url = wp_parse_url( $request_uri );
		$path       = isset( $parsed_url['path'] ) ? $parsed_url['path'] : '/';

		// Include plugin version in hash so cache is invalidated on plugin updates.
		$version = defined( 'CACHELOOP_VERSION' ) ? CACHELOOP_VERSION : '1.0.3';

		// Optionally include device type in hash for mobile/desktop differentiation.
		$device_suffix = '';
		if ( ! empty( $this->settings['cache_device_split'] ) ) {
			$device_suffix = $this->get_device_type();
		}

		$uri_hash = md5( $path . $version . $device_suffix );
		$sub_dir  = substr( $uri_hash, 0, 2 );

		return CACHELOOP_CACHE_DIR . $sub_dir . '/' . $uri_hash . '.html';
	}

	// ---------------------------------------------------------------------------
	// Cache Purging
	// ---------------------------------------------------------------------------

	/**
	 * Purges the cache file for a specific post.
	 *
	 * @param  int $post_id The ID of the post to purge.
	 * @return void
	 */
	public function purge_post_cache( $post_id ) {
		// Ignore auto-saves and revisions.
		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}

		$permalink = get_permalink( $post_id );
		if ( empty( $permalink ) ) {
			return;
		}

		$uri     = wp_parse_url( $permalink, PHP_URL_PATH );
		$version = defined( 'CACHELOOP_VERSION' ) ? CACHELOOP_VERSION : '1.0.3';

		// Purge all device variants (desktop + mobile).
		$devices = array( '', 'desktop', 'mobile' );
		foreach ( $devices as $device ) {
			$uri_hash = md5( $uri . $version . $device );
			$sub_dir  = substr( $uri_hash, 0, 2 );
			$file     = CACHELOOP_CACHE_DIR . $sub_dir . '/' . $uri_hash . '.html';

			if ( file_exists( $file ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				unlink( $file );
			}
		}

		// Also purge the homepage, as its "recent posts" may have changed.
		$this->purge_url( home_url( '/' ) );

		// Hook for anything that needs to know a URL was purged from our own
		// cache — e.g. the Varnish integration mirrors the same purge to an
		// upstream Varnish server, which CacheLoop has no visibility into
		// otherwise (it's a separate cache layer, not ours to manage).
		do_action( 'cacheloop_url_purged', $permalink );
		do_action( 'cacheloop_url_purged', home_url( '/' ) );
	}

	/**
	 * Purges the cache file for the post associated with a new comment.
	 *
	 * @param  int    $comment_id     The new comment ID.
	 * @param  string $comment_status The comment status ('approved', '0', 'spam').
	 * @return void
	 */
	public function purge_post_cache_by_comment( $comment_id, $comment_status ) {
		if ( 1 !== (int) $comment_status ) {
			return;
		}

		$comment = get_comment( $comment_id );
		if ( $comment ) {
			$this->purge_post_cache( (int) $comment->comment_post_ID );
		}
	}

	/**
	 * Purges the cache file for a specific URL.
	 *
	 * @param  string $url The URL to purge.
	 * @return void
	 */
	private function purge_url( $url ) {
		$uri     = wp_parse_url( $url, PHP_URL_PATH );
		$version = defined( 'CACHELOOP_VERSION' ) ? CACHELOOP_VERSION : '1.0.3';

		// Purge all device variants (desktop + mobile).
		$devices = array( '', 'desktop', 'mobile' );
		foreach ( $devices as $device ) {
			$uri_hash = md5( $uri . $version . $device );
			$sub_dir  = substr( $uri_hash, 0, 2 );
			$file     = CACHELOOP_CACHE_DIR . $sub_dir . '/' . $uri_hash . '.html';

			if ( file_exists( $file ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				unlink( $file );
			}
		}
	}

	/**
	 * Deletes all cached HTML files (full cache flush).
	 *
	 * Called on theme switch, plugin updates, and by the admin UI "Clear Cache" button.
	 *
	 * @return void
	 */
	public static function flush_all() {
		if ( ! is_dir( CACHELOOP_CACHE_DIR ) ) {
			return;
		}

		self::delete_dir_recursive( CACHELOOP_CACHE_DIR );

		// Also flush REST cache.
		if ( class_exists( 'CACHELOOP_REST_Cache' ) ) {
			CACHELOOP_REST_Cache::flush_rest_cache();
		}

		// Same reasoning as the per-URL hook in purge_post_cache() — lets an
		// upstream cache layer (Varnish, etc.) mirror a full flush.
		do_action( 'cacheloop_cache_flushed' );
	}

	/**
	 * Recursively deletes all files inside a directory (but keeps the directory itself).
	 *
	 * flush_all() is called from several places (settings save, Clear Cache
	 * button, version-bump self-heal, WP-CLI) and nothing serializes them —
	 * on a busy site two of those can legitimately overlap. RecursiveDirectoryIterator
	 * throws UnexpectedValueException the moment a directory it's about to
	 * descend into has already been removed by the other concurrent call,
	 * which without this guard was an uncaught fatal (confirmed in a
	 * production error log). Both calls are trying to reach the same empty
	 * end state, so losing the race here just means there's nothing left
	 * for this call to do.
	 *
	 * @param  string $dir Absolute path to the directory.
	 * @return void
	 */
	private static function delete_dir_recursive( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return; // Already removed by a concurrent flush_all() call.
		}

		try {
			$files = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
				RecursiveIteratorIterator::CHILD_FIRST
			);

			foreach ( $files as $file_info ) {
				$path = $file_info->getRealPath();
				if ( false === $path || ! file_exists( $path ) ) {
					continue; // Removed by a concurrent flush_all() between listing and now.
				}
				// Config data, not a cache entry — the advanced-cache.php and
				// object-cache.php drop-ins read it pre-bootstrap on every
				// request. Deleting it here left the drop-ins running on
				// stale/default config from the moment of the first cache
				// flush after each settings save until the next save.
				if ( in_array( $file_info->getFilename(), array( 'runtime-config.json', '.htaccess' ), true ) ) {
					continue;
				}

				// The stats/ subtree (hit/miss/purge counters feeding the
				// HealthLoop bridge, see class-cl-stats.php) is reporting
				// history, not cache output. flush_all() runs far more often
				// than a user would expect to lose that history — theme
				// switches, plugin updates, the admin "Clear Cache" button,
				// `wp cacheloop clear` — so it must survive all of those.
				// Only an explicit plugin uninstall (the Freemius
				// after_uninstall hook in cacheloop.php, which deletes the
				// whole cache directory) wipes it.
				if ( 0 === strpos( $path, CACHELOOP_CACHE_DIR . 'stats/' ) ) {
					continue;
				}
				if ( $file_info->isDir() ) {
	                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
					@rmdir( $path );
				} else {
	                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
					@unlink( $path );
				}
			}
		} catch ( \UnexpectedValueException $e ) {
			// A directory vanished mid-traversal (another flush_all() got
			// there first). Whatever this call already deleted stays
			// deleted; the rest is already gone too. Nothing to recover.
			return;
		}
	}
}
