<?php
/**
 * BaseCloud Boost — Cache Preloader
 *
 * Warms the page cache by crawling the site over HTTP. Two execution modes:
 *
 * 1. CHAINED (preferred — NitroPack-style responsiveness): each worker
 *    processes a small batch of URLs, then fires a non-blocking loopback
 *    request to a secret-protected admin-ajax endpoint that processes the
 *    next batch and chains again, until the queue is empty. The whole site
 *    re-warms in minutes with ZERO dependence on visitor traffic.
 *
 * 2. CRON (fallback): the legacy WP-Cron path (one batch per BCBOOST_1min
 *    tick). Used automatically when loopback requests are blocked by the
 *    host, and as a watchdog that resumes a chain the host killed mid-run.
 *
 * Safety model (shared-hosting friendly, never hammer, never fatal):
 * - Small batches (default 5 URLs) with a pause between fetches.
 * - Hard cap on queue size (default 500 URLs, filterable).
 * - A stop-flag option checked before every single fetch.
 * - A lock so only ONE chain runs at a time; every run has a run_id and a
 *   step counter, so a second purge mid-warm RESETS the chain (old links
 *   die at validation) instead of double-chaining.
 * - Skips entirely (with a WP_DEBUG_LOG line) on hosts where Boost's page
 *   cache is inactive (WP Engine, Kinsta… — preloading would warm nothing).
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Background cache warmer that crawls the site via HTTP to pre-populate the cache.
 */
class Cache_Preloader {

	private const BATCH_SIZE    = 5;
	private const MAX_URLS      = 500;
	private const CRON_HOOK     = 'BCBOOST_preload_batch';
	private const QUEUE_OPTION  = 'BCBOOST_preload_queue';
	private const PROGRESS_OPT  = 'BCBOOST_preload_progress';
	private const STOP_FLAG_OPT = 'BCBOOST_preload_stop';
	private const LOCK_OPTION   = 'BCBOOST_preload_lock';
	private const SECRET_OPTION = 'BCBOOST_preload_secret';
	private const LOOPBACK_TRANSIENT = 'BCBOOST_loopback_ok';
	private const CHAIN_ACTION  = 'BCBOOST_preload_chain';

	/** Seconds after which a chain lock is considered stale (worker died). */
	private const LOCK_TTL = 120;

	/** Watchdog resumes before the chain gives up and falls back to cron. */
	private const MAX_CHAIN_RESUMES = 3;

	/**
	 * Register cron + loopback-endpoint handlers.
	 *
	 * @return void
	 */
	public function boot(): void {
		add_action( self::CRON_HOOK, [ $this, 'process_batch' ] );

		// Loopback chain endpoint. Loopback requests carry no auth cookies, so
		// this is nopriv + protected by a per-site random secret (hash_equals).
		add_action( 'wp_ajax_nopriv_' . self::CHAIN_ACTION, [ $this, 'handle_chain_request' ] );
		add_action( 'wp_ajax_' . self::CHAIN_ACTION,        [ $this, 'handle_chain_request' ] );

		add_filter( 'cron_schedules', static function ( array $schedules ): array {
			$schedules['BCBOOST_1min'] = [
				'interval' => 60,
				'display'  => __( 'Every Minute (BaseCloud Boost)', 'basecloud-boost' ),
			];
			return $schedules;
		} );
	}

	/**
	 * Whether preloading makes sense at all on this install: Boost's page cache
	 * must be enabled AND active (Host_Detector disables it on WP Engine,
	 * Kinsta, etc. — warming a cache that never writes is pure server load).
	 *
	 * @return bool
	 */
	public function should_preload(): bool {
		if ( ! BCBOOST_get_option( 'cache_enabled', true ) ) {
			return false;
		}
		return (bool) apply_filters( 'BCBOOST_page_cache_enabled', true );
	}

	/**
	 * Start (or RESTART) preloading. A fresh run_id is generated, so any chain
	 * link from a previous run dies at validation instead of double-chaining.
	 *
	 * Queue building (sitemap fetch / WP queries) is deferred to the first
	 * background worker so the caller — e.g. the purge AJAX request — returns
	 * immediately.
	 *
	 * @return void
	 */
	public function start_preload(): void {
		if ( ! $this->should_preload() ) {
			$this->log( 'Preload skipped — Boost page cache is inactive on this host (managed host cache or cache_enabled off).' );
			$this->write_progress( [
				'status' => 'skipped',
				'mode'   => '',
				'fallback_reason' => 'page_cache_inactive',
			] );
			return;
		}

		$this->reset_state();

		$run_id   = function_exists( 'wp_generate_password' ) ? wp_generate_password( 12, false ) : substr( md5( uniqid( '', true ) ), 0, 12 );
		$progress = [
			'total'           => 0,
			'completed'       => 0,
			'percentage'      => 0,
			'current_url'     => '',
			'status'          => 'starting',
			'started_at'      => time(),
			'finished_at'     => 0,
			'run_id'          => $run_id,
			'next_step'       => 1,
			'resumes'         => 0,
			'mode'            => 'chained',
			'fallback_reason' => '',
		];

		if ( $this->loopback_supported() ) {
			$this->write_progress( $progress );
			$this->spawn_chain_link();
			$this->schedule_watchdog();
			return;
		}

		// Host blocks loopback HTTP (some WP Engine/Kinsta/firewalled setups):
		// fall back to the legacy WP-Cron path and say so in the status.
		$progress['mode']            = 'cron';
		$progress['fallback_reason'] = 'loopback_blocked';
		$this->write_progress( $progress );
		$this->log( 'Loopback requests blocked on this host — preloader falling back to WP-Cron batches.' );

		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time(), 'BCBOOST_1min', self::CRON_HOOK );
		}
	}

	/**
	 * Stop the preloader: raise the stop flag (in-flight workers check it
	 * before every fetch), clear cron, drop the queue.
	 *
	 * @return void
	 */
	public function stop_preload(): void {
		update_option( self::STOP_FLAG_OPT, time(), false );
		wp_clear_scheduled_hook( self::CRON_HOOK );
		delete_option( self::QUEUE_OPTION );
		delete_option( self::LOCK_OPTION );

		$progress = (array) get_option( self::PROGRESS_OPT, [] );
		if ( ! empty( $progress ) ) {
			$progress['status']      = 'stopped';
			$progress['current_url'] = '';
			$progress['finished_at'] = time();
			update_option( self::PROGRESS_OPT, $progress, false );
		}
	}

	/**
	 * Fire-and-forget warm of specific URLs (e.g. the edited post + homepage
	 * right after a targeted purge). Deduplicates within the request.
	 *
	 * @param string[] $urls Absolute URLs to warm.
	 * @return void
	 */
	public function warm_urls_async( array $urls ): void {
		static $warmed = [];

		if ( ! $this->should_preload() ) {
			return;
		}

		$warm_mobile = $this->should_warm_mobile();

		foreach ( array_unique( array_filter( $urls ) ) as $url ) {
			if ( isset( $warmed[ $url ] ) ) {
				continue;
			}
			$warmed[ $url ] = true;

			wp_remote_get( $url, [
				'timeout'    => 0.01,
				'blocking'   => false,
				'sslverify'  => apply_filters( 'https_local_ssl_verify', false ),
				'user-agent' => 'BaseCloudBoost-Warmer/' . BCBOOST_VERSION . ' (WordPress)',
				'headers'    => [ 'X-BaseCloud-Warmer' => '1' ],
			] );

			// Device-split cache: also warm the MOBILE variant, or phone visitors
			// (and the PSI mobile crawler) hit a cold cache no matter how fresh
			// the desktop copy is.
			if ( $warm_mobile ) {
				wp_remote_get( $url, [
					'timeout'    => 0.01,
					'blocking'   => false,
					'sslverify'  => apply_filters( 'https_local_ssl_verify', false ),
					'user-agent' => self::mobile_warmer_ua(),
					'headers'    => [ 'X-BaseCloud-Warmer' => '1' ],
				] );
			}
		}
	}

	/**
	 * Whether the page cache stores a separate mobile variant — then warming
	 * must fetch each URL twice (desktop UA + mobile UA).
	 *
	 * @return bool
	 */
	private function should_warm_mobile(): bool {
		return (bool) BCBOOST_get_option( 'cache_mobile_separate', false );
	}

	/**
	 * Mobile preloader user-agent. MUST keep matching Cache_Context's mobile
	 * detection regex ("Android" + "Mobile" tokens) — if that regex changes,
	 * this UA warms the wrong bucket.
	 *
	 * @return string
	 */
	private static function mobile_warmer_ua(): string {
		return 'Mozilla/5.0 (Linux; Android 12; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) '
			. 'Chrome/120.0 Mobile Safari/537.36 BaseCloudBoost-Preloader/' . BCBOOST_VERSION;
	}

	// ─── Chained (loopback) mode ──────────────────────────────────────────────

	/**
	 * admin-ajax handler for chain links. Never fatals — a cache warmer must
	 * never take a site down, so everything is wrapped and fails open.
	 *
	 * @return void
	 */
	public function handle_chain_request(): void {
		try {
			$this->process_chain_request();
		} catch ( \Throwable $e ) {
			$this->log( 'Preload chain link failed: ' . $e->getMessage() );
			delete_option( self::LOCK_OPTION );
		}
		wp_die( '', '', [ 'response' => 200 ] );
	}

	/**
	 * Validate + execute one chain link: auth (secret), run identity (run_id),
	 * ordering (step), stop flag, lock — then process a batch and chain on.
	 *
	 * @return void
	 */
	private function process_chain_request(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- loopback
		// requests have no user session; auth is the per-site random secret below.
		$secret = isset( $_POST['secret'] ) ? (string) wp_unslash( $_POST['secret'] ) : '';
		$stored = (string) get_option( self::SECRET_OPTION, '' );
		if ( $stored === '' || $secret === '' || ! hash_equals( $stored, $secret ) ) {
			return;
		}

		// Connectivity self-test (used by loopback_supported()).
		if ( isset( $_POST['ping'] ) ) {
			echo 'bcboost-pong';
			return;
		}

		$run  = isset( $_POST['run'] ) ? (string) wp_unslash( $_POST['run'] ) : '';
		$step = isset( $_POST['step'] ) ? (int) $_POST['step'] : 0;
		// phpcs:enable

		$progress = (array) get_option( self::PROGRESS_OPT, [] );

		// A purge mid-warm resets run_id — links from the old chain die here.
		if ( $run === '' || $run !== (string) ( $progress['run_id'] ?? '' ) ) {
			return;
		}
		// Ordering guard: only the expected next link runs (kills duplicates).
		if ( $step !== (int) ( $progress['next_step'] ?? -1 ) ) {
			return;
		}
		if ( get_option( self::STOP_FLAG_OPT ) ) {
			return;
		}
		if ( ! in_array( (string) ( $progress['status'] ?? '' ), [ 'starting', 'running' ], true ) ) {
			return;
		}

		// Hard safety cap on chain length — a runaway chain kills itself.
		$max_steps = (int) ceil( $this->max_urls() / $this->batch_size() ) + 10;
		if ( $step > $max_steps ) {
			$this->log( 'Preload chain exceeded its safety cap (' . $max_steps . ' links) — stopping.' );
			$this->mark_complete();
			return;
		}

		// Consume this step BEFORE working so a duplicate delivery no-ops.
		$progress['next_step'] = $step + 1;
		update_option( self::PROGRESS_OPT, $progress, false );

		if ( ! $this->acquire_lock( (string) $progress['run_id'] ) ) {
			return; // Another worker of this (or a fresher) run is active.
		}

		// The spawning request already returned; keep this worker alive even
		// though the "client" disconnected immediately (FastCGI aborts otherwise).
		ignore_user_abort( true );
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( self::LOCK_TTL );
		}

		$remaining = $this->run_batch( $progress );

		delete_option( self::LOCK_OPTION );

		if ( $remaining ) {
			$this->spawn_chain_link();
			$this->schedule_watchdog();
		}
	}

	/**
	 * Fire the next chain link: a non-blocking loopback POST that returns in
	 * ~10ms while the next worker crawls in the background.
	 *
	 * @return void
	 */
	private function spawn_chain_link(): void {
		$progress = (array) get_option( self::PROGRESS_OPT, [] );
		if ( empty( $progress['run_id'] ) ) {
			return;
		}

		wp_remote_post( admin_url( 'admin-ajax.php' ), [
			'timeout'   => 0.01,
			'blocking'  => false,
			'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
			'body'      => [
				'action' => self::CHAIN_ACTION,
				'secret' => $this->get_secret(),
				'run'    => (string) $progress['run_id'],
				'step'   => (int) ( $progress['next_step'] ?? 1 ),
			],
		] );
	}

	/**
	 * Schedule the single-event watchdog that resumes a chain the host killed.
	 * (WP-Cron dedupes identical events within 10 minutes, so this is cheap.)
	 *
	 * @return void
	 */
	private function schedule_watchdog(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_single_event( time() + self::LOCK_TTL, self::CRON_HOOK );
		}
	}

	/**
	 * Detect whether this host allows loopback HTTP requests to itself.
	 * Result is cached for 12 hours; a purge storm never re-tests repeatedly.
	 *
	 * @return bool
	 */
	private function loopback_supported(): bool {
		$cached = get_transient( self::LOOPBACK_TRANSIENT );
		if ( 'yes' === $cached ) {
			return true;
		}
		if ( 'no' === $cached ) {
			return false;
		}

		$response = wp_remote_post( admin_url( 'admin-ajax.php' ), [
			'timeout'   => (float) apply_filters( 'BCBOOST_preload_loopback_test_timeout', 5 ),
			'blocking'  => true,
			'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
			'body'      => [
				'action' => self::CHAIN_ACTION,
				'secret' => $this->get_secret(),
				'ping'   => '1',
			],
		] );

		$ok = ! is_wp_error( $response )
			&& 200 === wp_remote_retrieve_response_code( $response )
			&& false !== strpos( (string) wp_remote_retrieve_body( $response ), 'bcboost-pong' );

		set_transient( self::LOOPBACK_TRANSIENT, $ok ? 'yes' : 'no', 12 * HOUR_IN_SECONDS );

		return $ok;
	}

	// ─── Cron mode + watchdog ─────────────────────────────────────────────────

	/**
	 * Cron handler. In cron-fallback mode this processes one batch per tick
	 * (legacy behaviour). In chained mode it acts as the watchdog: if the
	 * chain is alive it re-arms and exits; if the chain died it processes a
	 * batch itself and tries to re-spawn the chain. After repeated stalls it
	 * downgrades the run to cron mode permanently.
	 *
	 * @return void
	 */
	public function process_batch(): void {
		try {
			$progress = (array) get_option( self::PROGRESS_OPT, [] );
			$status   = (string) ( $progress['status'] ?? '' );

			if ( ! in_array( $status, [ 'starting', 'running' ], true ) ) {
				wp_clear_scheduled_hook( self::CRON_HOOK );
				return;
			}
			if ( get_option( self::STOP_FLAG_OPT ) ) {
				wp_clear_scheduled_hook( self::CRON_HOOK );
				return;
			}

			$mode = (string) ( $progress['mode'] ?? 'cron' );

			if ( 'chained' === $mode ) {
				// Watchdog: fresh lock = a chain worker ran within LOCK_TTL — healthy.
				$lock = (array) get_option( self::LOCK_OPTION, [] );
				if ( ! empty( $lock['time'] ) && ( time() - (int) $lock['time'] ) < self::LOCK_TTL ) {
					$this->schedule_watchdog();
					return;
				}

				// Chain stalled. Resume it; after too many stalls, downgrade to cron.
				$progress['resumes'] = (int) ( $progress['resumes'] ?? 0 ) + 1;
				if ( $progress['resumes'] > self::MAX_CHAIN_RESUMES ) {
					$progress['mode']            = 'cron';
					$progress['fallback_reason'] = 'chain_stalled';
					delete_transient( self::LOOPBACK_TRANSIENT );
					$this->log( 'Preload chain stalled repeatedly — downgrading to WP-Cron batches.' );
					if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
						wp_schedule_event( time(), 'BCBOOST_1min', self::CRON_HOOK );
					}
				}
				update_option( self::PROGRESS_OPT, $progress, false );
			}

			if ( ! $this->acquire_lock( (string) ( $progress['run_id'] ?? 'cron' ) ) ) {
				return;
			}

			$remaining = $this->run_batch( $progress );

			delete_option( self::LOCK_OPTION );

			if ( $remaining && 'chained' === (string) ( $progress['mode'] ?? '' ) ) {
				// Re-spawn the chain: the new link consumes progress.next_step
				// on arrival exactly like a normally-chained link.
				$this->spawn_chain_link();
				$this->schedule_watchdog();
			}
		} catch ( \Throwable $e ) {
			// Fail open — a warmer must never break cron or the site.
			$this->log( 'Preload cron batch failed: ' . $e->getMessage() );
			delete_option( self::LOCK_OPTION );
		}
	}

	// ─── Shared batch core ────────────────────────────────────────────────────

	/**
	 * Process one batch: build the queue on the first run, fetch up to
	 * batch_size() URLs (stop flag honoured between every fetch, small pause
	 * between requests), persist progress.
	 *
	 * @param array<string,mixed> $progress Progress array (by value; persisted here).
	 * @return bool True when URLs remain in the queue.
	 */
	private function run_batch( array $progress ): bool {
		// First worker builds the queue (deferred so purge/admin returns fast).
		if ( 'starting' === (string) ( $progress['status'] ?? '' ) ) {
			$urls = $this->build_url_queue();

			delete_option( self::QUEUE_OPTION );
			add_option( self::QUEUE_OPTION, $urls, '', 'no' ); // never autoload ~40KB of URLs

			$progress['total']  = count( $urls );
			$progress['status'] = 'running';
			update_option( self::PROGRESS_OPT, $progress, false );
		}

		$queue = (array) get_option( self::QUEUE_OPTION, [] );

		if ( empty( $queue ) ) {
			$this->mark_complete();
			return false;
		}

		$batch = array_splice( $queue, 0, $this->batch_size() );
		update_option( self::QUEUE_OPTION, $queue, false );

		$pause = (int) apply_filters( 'BCBOOST_preload_pause_us', 250000 ); // 250ms between fetches

		foreach ( $batch as $url ) {
			// Kill switch + supersession check, read FRESH from the DB — this
			// loop runs for seconds, and another process may have stopped the
			// run or started a NEW one meanwhile (a second purge mid-warm).
			// The in-process option cache would lie about both.
			if ( $this->fresh_option( self::STOP_FLAG_OPT ) ) {
				$this->stop_preload();
				return false;
			}
			$db_progress = (array) $this->fresh_option( self::PROGRESS_OPT );
			if ( (string) ( $db_progress['run_id'] ?? '' ) !== (string) ( $progress['run_id'] ?? '' ) ) {
				return false; // Superseded by a newer run — abandon without touching its state.
			}

			$progress['current_url'] = (string) $url;
			update_option( self::PROGRESS_OPT, $progress, false );

			wp_remote_get( (string) $url, [
				'timeout'    => 10,
				'blocking'   => true,
				'sslverify'  => apply_filters( 'https_local_ssl_verify', false ),
				'user-agent' => 'BaseCloudBoost-Preloader/' . BCBOOST_VERSION . ' (WordPress)',
				'headers'    => [ 'X-BaseCloud-Preload' => '1' ],
			] );

			// Device-split cache: warm the mobile variant too — phone visitors
			// (and PSI's mobile crawler) otherwise hit a cold cache on every
			// warmed URL. Doubles per-URL work, so only when the split is on.
			if ( $this->should_warm_mobile() ) {
				wp_remote_get( (string) $url, [
					'timeout'    => 10,
					'blocking'   => true,
					'sslverify'  => apply_filters( 'https_local_ssl_verify', false ),
					'user-agent' => self::mobile_warmer_ua(),
					'headers'    => [ 'X-BaseCloud-Preload' => '1' ],
				] );
			}

			$progress['completed'] = (int) ( $progress['completed'] ?? 0 ) + 1;

			if ( $pause > 0 && $pause <= 2000000 ) {
				usleep( $pause );
			}
		}

		// Final supersession check before persisting batch results.
		$db_progress = (array) $this->fresh_option( self::PROGRESS_OPT );
		if ( (string) ( $db_progress['run_id'] ?? '' ) !== (string) ( $progress['run_id'] ?? '' ) ) {
			return false;
		}

		$total                  = max( 1, (int) ( $progress['total'] ?? count( $batch ) ) );
		$progress['percentage'] = (int) round( ( (int) $progress['completed'] / $total ) * 100 );
		$progress['status']     = empty( $queue ) ? 'completed' : 'running';
		update_option( self::PROGRESS_OPT, $progress, false );

		if ( empty( $queue ) ) {
			$this->mark_complete();
			return false;
		}

		return true;
	}

	// ─── Status ───────────────────────────────────────────────────────────────

	/**
	 * Get current preloader progress (drives the admin status UI).
	 *
	 * @return array{total:int, completed:int, remaining:int, percentage:int, current_url:string, status:string, mode:string, fallback_reason:string, started_at:int, finished_at:int}
	 */
	public function get_preload_progress(): array {
		$defaults = [
			'total'           => 0,
			'completed'       => 0,
			'percentage'      => 0,
			'current_url'     => '',
			'status'          => 'idle',
			'mode'            => '',
			'fallback_reason' => '',
			'started_at'      => 0,
			'finished_at'     => 0,
		];
		$progress = array_merge( $defaults, (array) get_option( self::PROGRESS_OPT, [] ) );

		$progress['remaining'] = max( 0, (int) $progress['total'] - (int) $progress['completed'] );

		// Internal chain fields stay internal.
		unset( $progress['run_id'], $progress['next_step'], $progress['resumes'] );

		return $progress;
	}

	/**
	 * Check whether the preloader is currently active.
	 *
	 * @return bool
	 */
	public function is_running(): bool {
		return in_array( $this->get_preload_progress()['status'], [ 'starting', 'running' ], true );
	}

	// ─── Private helpers ──────────────────────────────────────────────────────

	/**
	 * Effective batch size (URLs per worker).
	 *
	 * @return int
	 */
	private function batch_size(): int {
		return max( 1, (int) apply_filters( 'BCBOOST_preload_batch_size', self::BATCH_SIZE ) );
	}

	/**
	 * Hard cap on URLs per preload run.
	 *
	 * @return int
	 */
	private function max_urls(): int {
		return max( 1, (int) apply_filters( 'BCBOOST_preload_max_urls', self::MAX_URLS ) );
	}

	/**
	 * Per-site random secret authenticating loopback chain links.
	 *
	 * @return string
	 */
	private function get_secret(): string {
		$secret = (string) get_option( self::SECRET_OPTION, '' );
		if ( '' === $secret ) {
			$secret = wp_generate_password( 32, false );
			update_option( self::SECRET_OPTION, $secret, false );
		}
		return $secret;
	}

	/**
	 * Clear all runtime state before a fresh run (also invalidates any chain
	 * links still in flight from a previous run — their run_id won't match).
	 *
	 * @return void
	 */
	private function reset_state(): void {
		wp_clear_scheduled_hook( self::CRON_HOOK );
		delete_option( self::QUEUE_OPTION );
		delete_option( self::LOCK_OPTION );
		delete_option( self::STOP_FLAG_OPT );
	}

	/**
	 * Persist progress (never autoloaded — it changes constantly).
	 *
	 * @param array<string,mixed> $progress Progress payload.
	 * @return void
	 */
	private function write_progress( array $progress ): void {
		delete_option( self::PROGRESS_OPT );
		add_option( self::PROGRESS_OPT, $progress, '', 'no' );
	}

	/**
	 * Take the single-runner lock. Locks are released after every batch, so a
	 * FRESH lock always means a live concurrent worker — reject it regardless
	 * of run identity. Only stale locks (dead workers) are stolen.
	 *
	 * @param string $run_id Current run identity (stored for the watchdog's
	 *                       freshness check and diagnostics).
	 * @return bool Whether the lock was acquired.
	 */
	private function acquire_lock( string $run_id ): bool {
		$lock = (array) get_option( self::LOCK_OPTION, [] );

		if ( ! empty( $lock['time'] ) && ( time() - (int) $lock['time'] ) < self::LOCK_TTL ) {
			return false;
		}

		update_option( self::LOCK_OPTION, [ 'run_id' => $run_id, 'time' => time() ], false );
		return true;
	}

	/**
	 * Read an option straight from the database, bypassing this process's
	 * option cache. Needed inside long-running batch loops where another PHP
	 * process may have changed (or created) the option after we started.
	 *
	 * @param string $name Option name.
	 * @return mixed Option value or false.
	 */
	private function fresh_option( string $name ) {
		wp_cache_delete( $name, 'options' );
		wp_cache_delete( 'notoptions', 'options' );
		return get_option( $name );
	}

	/**
	 * Debug logging under WP_DEBUG_LOG only (no silent failures, no log spam).
	 *
	 * @param string $message Log line.
	 * @return void
	 */
	private function log( string $message ): void {
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			error_log( '[BaseCloud Boost] ' . $message );
		}
	}

	/**
	 * Build the full list of URLs to preload (sitemap first, WP queries as
	 * fallback), capped at max_urls().
	 *
	 * @return string[]
	 */
	private function build_url_queue(): array {
		$urls = [];

		// Try sitemap first.
		$sitemap_urls = $this->get_urls_from_sitemap();
		if ( ! empty( $sitemap_urls ) ) {
			return array_slice( array_values( array_unique( $sitemap_urls ) ), 0, $this->max_urls() );
		}

		// Fallback: build from WP queries.
		$urls[] = home_url( '/' );

		$post_types = get_post_types( [ 'public' => true ], 'names' );
		foreach ( $post_types as $post_type ) {
			$posts = get_posts( [
				'post_type'      => $post_type,
				'post_status'    => 'publish',
				'posts_per_page' => 500,
				'fields'         => 'ids',
			] );
			foreach ( $posts as $id ) {
				$link = get_permalink( $id );
				if ( $link ) {
					$urls[] = $link;
				}
			}

			$archive = get_post_type_archive_link( $post_type );
			if ( $archive ) {
				$urls[] = $archive;
			}
		}

		$taxonomies = get_taxonomies( [ 'public' => true ], 'names' );
		foreach ( $taxonomies as $taxonomy ) {
			$terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => true ] );
			if ( is_array( $terms ) ) {
				foreach ( $terms as $term ) {
					$link = get_term_link( $term );
					if ( ! is_wp_error( $link ) ) {
						$urls[] = $link;
					}
				}
			}
		}

		return array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, $this->max_urls() );
	}

	/**
	 * Attempt to parse the site's XML sitemap for URLs.
	 *
	 * @return string[]
	 */
	private function get_urls_from_sitemap(): array {
		$candidates = [
			home_url( '/sitemap.xml' ),
			home_url( '/sitemap_index.xml' ),
			home_url( '/wp-sitemap.xml' ),
		];

		foreach ( $candidates as $sitemap_url ) {
			$response = wp_remote_get( $sitemap_url, [ 'timeout' => 10 ] );
			if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
				continue;
			}
			return $this->extract_urls_from_sitemap_xml( wp_remote_retrieve_body( $response ) );
		}

		return [];
	}

	/**
	 * Extract <loc> URLs from a sitemap XML string.
	 *
	 * @param string $xml Sitemap XML content.
	 * @return string[]
	 */
	private function extract_urls_from_sitemap_xml( string $xml ): array {
		$urls = [];

		libxml_use_internal_errors( true );

		try {
			$doc = new \SimpleXMLElement( $xml );
		} catch ( \Exception $e ) {
			return [];
		}

		libxml_clear_errors();

		if ( isset( $doc->sitemap ) ) {
			foreach ( $doc->sitemap as $child ) {
				// Cap already reached? Stop fetching child sitemaps.
				if ( count( $urls ) >= $this->max_urls() ) {
					break;
				}
				$child_url = (string) $child->loc;
				$response  = wp_remote_get( $child_url, [ 'timeout' => 10 ] );
				if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
					$urls = array_merge( $urls, $this->extract_urls_from_sitemap_xml( wp_remote_retrieve_body( $response ) ) );
				}
			}
		} elseif ( isset( $doc->url ) ) {
			foreach ( $doc->url as $url_entry ) {
				$loc = (string) $url_entry->loc;
				if ( $loc ) {
					$urls[] = $loc;
				}
			}
		}

		return $urls;
	}

	/**
	 * Mark the preloader as complete and clean up all runtime state.
	 *
	 * @return void
	 */
	private function mark_complete(): void {
		$progress                = (array) get_option( self::PROGRESS_OPT, [] );
		$progress['status']      = 'completed';
		$progress['percentage']  = 100;
		$progress['current_url'] = '';
		$progress['finished_at'] = time();
		update_option( self::PROGRESS_OPT, $progress, false );

		delete_option( self::QUEUE_OPTION );
		delete_option( self::LOCK_OPTION );
		wp_clear_scheduled_hook( self::CRON_HOOK );
	}
}
