<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\ImmediateKick;
use Ekesto\CacheWarmup\Integrations\InvalidatorAdapterGate;
use Ekesto\CacheWarmup\Settings\Repository;
use Ekesto\CacheWarmup\Settings\SitemapSnapshotService;
use Ekesto\CacheWarmup\Warmup\TargetedBrotliTransport;

/**
 * Warmup runner.
 * Processes the queued URLs in small batches via WP-Cron.
 */
final class Runner
{
    /** Cron hook name for batch processing. */
    public const CRON_HOOK = 'ekesto_cache_warmup_run';

    /** Daily cleanup cron hook. */
    private const CRON_CLEANUP = 'ekesto_cache_warmup_cleanup';

    /** Per-request warmup timeout for production/staging hosts (seconds). */
    private const WARMUP_TIMEOUT = 10;

    /** Per-request warmup timeout on local development environments (seconds). */
    private const WARMUP_TIMEOUT_LOCAL = 3;

    /** Whether this cron request already deferred one full-run kick to shutdown. */
    private static bool $deferredFullKickRegistered = false;

    public static function register(): void
    {
        add_action(self::CRON_HOOK, [__CLASS__, 'run']);
        add_action(self::CRON_CLEANUP, [__CLASS__, 'cleanupOldLogs']);
        add_action(Sitemap::MEMBERSHIP_REBUILD_HOOK, [Sitemap::class, 'executeMembershipRebuild']);

        add_action('init', static function (): void {
            if (!wp_next_scheduled(self::CRON_CLEANUP)) {
                wp_schedule_event(time() + 600, 'daily', self::CRON_CLEANUP);
            }
        }, 20);
    }

    /**
     * Execute one warmup batch.
     *
     * How execution timing really works:
     * - This method may run through WP-Cron, the internal immediate kick, or a manual action.
     * - On hosts where wp-cron.php is called by a real server cron (e.g. every 5 minutes),
     *   this plugin will process exactly ONE batch per wp-cron.php call (per due cron event).
     * - Therefore, "pages_per_batch" is effectively "URLs per cron execution" in that setup.
     *
     * Important:
     * - We MUST schedule the next cron event while the queue is still running.
     *   Otherwise wp-cron.php can be called all day long and ECW will never continue.
     * - The scheduled "due time" is not a guarantee of execution time; it only makes the event eligible.
     *
     * @param string $expectedRunId Optional run ownership guard for an internal immediate kick.
     * @return void
     */
    public static function run(string $expectedRunId = ''): void
    {
        $expectedRunId = trim($expectedRunId);

        Debug::log('Runner::run() ENTER', [
            'wp_time' => self::wpLocalNow(),
            'expected_run_id' => $expectedRunId,
        ]);

        // Bail during maintenance mode — plugin files may be mid-swap (race condition guard).
        if (file_exists(ABSPATH . '.maintenance') || wp_is_maintenance_mode()) {
            return;
        }

        if (!Repository::isEnabled('general.enabled')) {
            return;
        }

        $stateBeforeGate = Queue::get();
        $hasActiveQueue = Queue::hasActiveRun($stateBeforeGate);

        if ($expectedRunId !== '' && (string) ($stateBeforeGate['run_id'] ?? '') !== $expectedRunId) {
            Debug::log('Runner skipped superseded immediate kick', [
                'expected_run_id' => $expectedRunId,
                'current_run_id' => (string) ($stateBeforeGate['run_id'] ?? ''),
            ]);
            return;
        }

        if (!InvalidatorAdapterGate::hasOperationalAdapter([
            'caller' => 'ecw_runner',
            'reason' => 'warmup',
        ])) {
            if (!$hasActiveQueue) {
                Debug::info('Warmup skipped: no operational adapter available (ECI authority).');
            }

            self::stopActiveRunForUnavailableAdapter();
            return;
        }

        // Prevent overlapping executions across concurrent PHP requests (server cron overlap, manual kick overlap).
        // Read settings once before any decisions.
        $throttle = Repository::getInt('advanced.throttle_seconds', Settings::DEFAULT_THROTTLE_SECONDS);
        if ($throttle < 0) {
            $throttle = 0;
        }

        $batchSizing = self::computeBatchSizing();
        $perBatch = (int) ($batchSizing['pages_per_batch'] ?? 1);
        $maxBatch = (int) ($batchSizing['max_batch_seconds'] ?? 1);
        $basis = (string) ($batchSizing['basis'] ?? 'unknown');
        $isAutoMode = ($batchSizing['pacing_mode'] ?? '') === 'auto';

        $timeout = wp_get_environment_type() === 'local'
            ? self::WARMUP_TIMEOUT_LOCAL
            : self::WARMUP_TIMEOUT;

        if ($isAutoMode) {
            TuningStats::saveLastComputed($perBatch, $maxBatch, $basis);
            Debug::log('Runner auto-pacing', [
                'mode' => $basis,
                'pages' => $perBatch,
                'max_batch' => $maxBatch,
            ]);
        }

        // Lease calculation uses final $maxBatch value (which may have been auto-computed).
        $leaseSeconds = (int) $maxBatch + (int) $timeout + 15;

        $lock = Queue::acquireRunLock($leaseSeconds);
        if (!$lock['acquired']) {
            self::scheduleNextRun(5);
            return;
        }

        $lockToken = $lock['token'];

        try {
            $state = Queue::get();
            if ($expectedRunId !== '' && (string) ($state['run_id'] ?? '') !== $expectedRunId) {
                Debug::log('Runner skipped superseded immediate kick', [
                    'expected_run_id' => $expectedRunId,
                    'current_run_id' => (string) ($state['run_id'] ?? ''),
                ]);
                return;
            }

            if (Queue::isPreparingFullState($state)) {
                $state = self::prepareFullRun($state);
            }

            if (Queue::hasActiveRun($state, 'single')) {
                $mutationLock = Queue::acquireTargetedMutationLock();
                if (!$mutationLock['acquired']) {
                    self::scheduleSoon(false);
                    return;
                }

                try {
                    $state = Queue::drainTargetedBuffer((string) ($state['run_id'] ?? ''));
                } finally {
                    Queue::releaseTargetedMutationLock($mutationLock['token']);
                }
            }

            if (($state['status'] ?? '') !== 'running') {
                return;
            }

            $runId = trim((string) ($state['run_id'] ?? ''));
            if ($runId === '') {
                return;
            }

            if ((string) ($state['mode'] ?? '') === 'single') {
                Logger::syncTargetedUpdateMeta(
                    $runId,
                    (int) ($state['targeted_update_count'] ?? 0),
                    !empty($state['correlation_history_saturated'])
                );
            }

            Logger::transitionPendingToRunning($runId);
            self::processRunningQueue($state, $perBatch, $throttle, $maxBatch, $timeout);

        } finally {
            Queue::releaseRunLock($lockToken);
        }

    }

    /**
     * Resolve a normalized runtime system cap for pages per batch.
     *
     * The UI max and runtime cap are intentionally separate:
     * - UI persistence cap defaults to 1000
     * - Runtime cap may be filtered, then softly clamped to 5000
     *
     * @return int Runtime pages-per-batch ceiling.
     */
    private static function getDynamicBatchSystemCap(): int
    {
        $cap = apply_filters('ekesto_cw_batch_max_urls', Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS);
        $cap = max(1, (int) $cap);
        return min($cap, Settings::MAX_DYNAMIC_BATCH_MAX_URLS);
    }

    /**
     * Compute effective batch sizing for both runtime execution and admin preview.
     *
     * Runtime behavior:
     * - Auto mode: telemetry/bootstrap pages constrained by dynamic runtime cap
     * - Manual mode: saved pages_per_batch treated as an upper bound and constrained
     *   by telemetry (when sufficient) and dynamic runtime cap
     *
     * @return array{
     *     pacing_mode:string,
     *     basis:string,
     *     configured_pages_per_batch:int,
     *     pages_per_batch:int,
     *     max_batch_seconds:int,
     *     dynamic_system_cap:int
     * }
     */
    private static function computeBatchSizing(): array
    {
        $configuredPagesPerBatch = Repository::getInt('advanced.pages_per_batch', Settings::DEFAULT_PAGES_PER_BATCH);
        $configuredPagesPerBatch = max(1, $configuredPagesPerBatch);

        $configuredMaxBatchSeconds = Repository::getInt('advanced.max_batch_seconds', Settings::DEFAULT_MAX_BATCH_SECONDS);
        $configuredMaxBatchSeconds = max(1, $configuredMaxBatchSeconds);

        $pacingMode = Repository::getString('advanced.pacing_mode');
        if ($pacingMode === '') {
            $pacingMode = Settings::DEFAULT_PACING_MODE;
        }

        $dynamicSystemCap = self::getDynamicBatchSystemCap();

        $maxExecTime = (int) ini_get('max_execution_time');
        if ($maxExecTime <= 0) {
            $maxExecTime = 120;
        }

        $safeBatchSeconds = (int) floor($maxExecTime * 0.8);
        $safeBatchSeconds = max(10, $safeBatchSeconds);

        $telemetryIsSufficient = TuningStats::isSufficient();

        $effectivePagesPerBatch = $configuredPagesPerBatch;
        $effectiveMaxBatchSeconds = $configuredMaxBatchSeconds;
        $basis = 'manual_no_telemetry';

        if ($pacingMode === 'auto') {
            $effectiveMaxBatchSeconds = $safeBatchSeconds;

            if ($telemetryIsSufficient) {
                $p90 = TuningStats::getP90Ms() ?? 500;
                $telemetryPages = self::computeTelemetryPageCeiling($safeBatchSeconds, $p90);
                $effectivePagesPerBatch = min($telemetryPages, $dynamicSystemCap);
                $basis = 'telemetry';
            } else {
                $effectiveMaxBatchSeconds = min($safeBatchSeconds, 30);
                $effectivePagesPerBatch = min(10, $dynamicSystemCap);
                $basis = 'bootstrap';
            }
        } elseif ($telemetryIsSufficient) {
            $p90 = TuningStats::getP90Ms() ?? 500;
            $telemetryPages = self::computeTelemetryPageCeiling($safeBatchSeconds, $p90);
            $effectivePagesPerBatch = min($configuredPagesPerBatch, $telemetryPages, $dynamicSystemCap);
            $basis = 'telemetry';
        } else {
            $effectivePagesPerBatch = min($configuredPagesPerBatch, $dynamicSystemCap);
            $basis = 'manual_no_telemetry';
        }

        return [
            'pacing_mode' => $pacingMode,
            'basis' => $basis,
            'configured_pages_per_batch' => $configuredPagesPerBatch,
            'pages_per_batch' => max(1, (int) $effectivePagesPerBatch),
            'max_batch_seconds' => max(1, (int) $effectiveMaxBatchSeconds),
            'dynamic_system_cap' => $dynamicSystemCap,
        ];
    }

    /**
     * Calculate pages-per-batch ceiling from p90 telemetry and safe batch seconds.
     *
     * @param int $safeBatchSeconds Safe runtime batch duration budget.
     * @param int $p90Ms P90 latency in milliseconds.
     * @return int Telemetry-derived pages ceiling before system/user caps.
     */
    private static function computeTelemetryPageCeiling(int $safeBatchSeconds, int $p90Ms): int
    {
        $usableSeconds = max(1.0, ($safeBatchSeconds * 0.85) - 1.0);
        $p90Seconds = max(0.05, ((float) $p90Ms) / 1000.0);
        $pages = (int) floor($usableSeconds / $p90Seconds);
        return max(1, $pages);
    }

    /**
     * Build a server-side preview of configured vs effective batch sizing.
     *
     * @return array{
     *     configured_pages_per_batch:int,
     *     effective_pages_per_batch:int,
     *     dynamic_system_cap:int,
     *     basis:string
     * }
     */
    public static function computeEffectiveBatchPreview(): array
    {
        $batchSizing = self::computeBatchSizing();

        return [
            'configured_pages_per_batch' => (int) ($batchSizing['configured_pages_per_batch'] ?? Settings::DEFAULT_PAGES_PER_BATCH),
            'effective_pages_per_batch' => (int) ($batchSizing['pages_per_batch'] ?? Settings::DEFAULT_PAGES_PER_BATCH),
            'dynamic_system_cap' => (int) ($batchSizing['dynamic_system_cap'] ?? Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS),
            'basis' => (string) ($batchSizing['basis'] ?? 'manual_no_telemetry'),
        ];
    }

    /**
     * Prepare a full run from its captured intent snapshot.
     *
     * Execution order is strict:
     * validate -> load snapshot -> build plan -> merge pending -> apply cap ->
     * revalidate -> atomic commit -> logger transition.
     *
     * @param array<string,mixed> $state Current queue state.
     * @return array<string,mixed> Latest queue state after preparation attempt.
     */
    private static function prepareFullRun(array $state): array
    {
        if (!Queue::isPreparingFullState($state)) {
            return $state;
        }

        $runId = trim((string) ($state['run_id'] ?? ''));
        $intentSignature = trim((string) ($state['intent_signature'] ?? ''));
        $intentInputs = isset($state['intent_inputs']) && is_array($state['intent_inputs'])
            ? $state['intent_inputs']
            : [];

        if ($runId === '' || $intentSignature === '' || $intentInputs === []) {
            Debug::warn('PREPARE_ABORT', [
                'run_id' => $runId,
                'reason' => 'invalid_intent_snapshot',
            ]);

            self::failPreparingRun($state, __('Warmup preparation failed: missing intent snapshot.', 'cache-autopilot'));
            return [];
        }

        $clearedStaleBuffer = Queue::clearPreparingBufferIfRunMismatch($runId);

        Debug::log('PREPARE_START', [
            'run_id' => $runId,
            'pending_priority_urls' => count(Queue::getPreparingPendingUrls($state)),
            'cleared_stale_buffer' => $clearedStaleBuffer,
        ]);

        Queue::touchPreparingActivity($runId, $intentSignature);

        $plan = self::buildPreparationPlan($intentInputs, $runId, $intentSignature);
        if ($plan['urls'] === []) {
            Debug::warn('PREPARE_ABORT', [
                'run_id' => $runId,
                'reason' => 'empty_plan',
            ]);

            self::failPreparingRun($state, __('Warmup preparation failed: no URLs available for this full run.', 'cache-autopilot'));
            return [];
        }

        Debug::log('PREPARE_PLAN_BUILT', [
            'run_id' => $runId,
            'url_count' => count($plan['urls']),
            'source' => $plan['prepare_source'],
        ]);

        $freshState = Queue::get();
        if (
            !Queue::isPreparingFullState($freshState)
            || (string) ($freshState['run_id'] ?? '') !== $runId
            || (string) ($freshState['intent_signature'] ?? '') !== $intentSignature
        ) {
            Debug::warn('PREPARE_ABORT', [
                'run_id' => $runId,
                'reason' => 'queue_changed_before_commit',
            ]);

            return $freshState;
        }

        $pendingPriorityUrls = Queue::getPreparingPendingUrls($freshState);
        $mergedPlan = self::mergePendingPriorityUrls($pendingPriorityUrls, $plan['urls']);

        Debug::log('PREPARE_BUFFER_MERGED', [
            'run_id' => $runId,
            'buffer_count' => $mergedPlan['buffer_count'],
            'planned_url_count' => count($mergedPlan['planned_urls']),
        ]);

        $maxUrls = max(
            1,
            isset($intentInputs['max_urls_per_run']) ? (int) $intentInputs['max_urls_per_run'] : Settings::DEFAULT_MAX_URLS_PER_RUN
        );
        $cappedPlan = self::applyPreparedCap(
            $mergedPlan['pending_urls'],
            $mergedPlan['planned_urls'],
            $maxUrls
        );

        $freshState['urls'] = $cappedPlan['urls'];
        $freshState['total_urls'] = count($cappedPlan['urls']);
        $freshState['cursor'] = 0;
        $freshState['status'] = 'running';
        $freshState['pending_priority_urls'] = [];
        $freshState['prepare_source'] = $plan['prepare_source'];
        $freshState['last_activity'] = time();
        if (!Queue::setIfRunOwned($runId, $freshState)) {
            Debug::warn('PREPARE_ABORT', [
                'run_id' => $runId,
                'reason' => 'queue_changed_during_commit',
            ]);

            return Queue::get();
        }

        Debug::log('PREPARE_COMMIT', [
            'run_id' => $runId,
            'url_count' => count($cappedPlan['urls']),
            'source' => $plan['prepare_source'],
        ]);
        Debug::log('prepare_phase_completed', [
            'run_id' => $runId,
            'url_count' => count($cappedPlan['urls']),
        ]);

        Queue::drainPreparingBufferIntoRunningQueue($runId, $maxUrls, $mergedPlan['buffer_count']);
        if (!Queue::isCurrentRun($runId)) {
            return Queue::get();
        }

        $preparedState = Queue::get();
        $materializedTotal = Queue::getTotalUrls($preparedState);

        if ($plan['resolved_sitemap_urls'] !== []) {
            Sitemap::primeSitemapUrlSetCache($plan['resolved_sitemap_urls']);
        }

        if ($plan['prepare_source'] === 'sitemaps' && $plan['resolved_sitemap_urls'] !== []) {
            SitemapSnapshotService::recordRuntimeMetadata(
                Repository::normalizeSitemapUrls($intentInputs['sitemaps'] ?? []),
                $plan['resolved_sitemap_count']
            );
        }

        Logger::transitionPreparingToRunning($runId, $materializedTotal, [
            'source' => $plan['prepare_source'],
            'prepare_source' => $plan['prepare_source'],
            'capped' => $cappedPlan['capped'],
            'max_urls_per_run' => $maxUrls,
            'prepare' => [
                'plan_version' => sanitize_text_field((string) ($intentInputs['plan_version'] ?? '')),
                'buffer_count' => $mergedPlan['buffer_count'],
                'dropped_sitemap_urls' => $cappedPlan['dropped_count'],
                'resolved_sitemap_count' => $plan['resolved_sitemap_count'],
                'prepared_at' => gmdate('c'),
            ],
        ]);

        return $preparedState;
    }

    /**
     * Execute one running warmup batch against a materialized queue.
     *
     * @param array<string,mixed> $state Active running queue state.
     * @param int $perBatch Maximum URLs to process in this batch.
     * @param int $throttle Optional delay between URLs.
     * @param int $maxBatch Maximum batch duration in seconds.
     * @param int $timeout Per-request HTTP timeout.
     * @return void
     */
    private static function processRunningQueue(array $state, int $perBatch, int $throttle, int $maxBatch, int $timeout): void
    {
        $urls = isset($state['urls']) && is_array($state['urls']) ? array_values($state['urls']) : [];
        $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
        $runId = isset($state['run_id']) ? (string) $state['run_id'] : '';
        $runMode = isset($state['mode']) ? (string) $state['mode'] : '';
        $trustedSet = isset($state['trusted_set']) && is_array($state['trusted_set']) ? $state['trusted_set'] : [];

        if ($runId === '' || empty($urls)) {
            if ($runId === '') {
                Queue::clear();
            } else {
                Logger::finishRun(
                    $runId,
                    'failed',
                    0,
                    (int) ($state['warmed'] ?? 0),
                    (int) ($state['failed'] ?? 0),
                    __('Warmup queue contained no processable URLs.', 'cache-autopilot')
                );
                Queue::clearIfRunOwned($runId);
            }
            return;
        }

        $userAgent = Repository::getString('advanced.user_agent');
        if ($userAgent === '') {
            $userAgent = Settings::DEFAULT_USER_AGENT;
        }

        $initialWarmed = (int) ($state['warmed'] ?? 0);
        $initialFailed = (int) ($state['failed'] ?? 0);

        $start = microtime(true);
        $warmed = 0;
        $failed = 0;
        $skipped = 0;
        $processed = 0;
        $newCursor = $cursor;
        $details = [];
        $targetedMutationToken = '';
        $targetedCheckpointUnavailable = false;
        $brotliSupport = [
            'supported' => false,
            'reason' => 'feature_disabled',
        ];

        if (Settings::DEFAULT_TARGETED_BROTLI_WARMUP_ENABLED === true) {
            $brotliSupport = TargetedBrotliTransport::supportStatus();
        }

        Debug::log('Runner transport support', [
            'mode' => $runMode,
            'feature_enabled' => Settings::DEFAULT_TARGETED_BROTLI_WARMUP_ENABLED === true,
            'brotli_profile_available' => !empty($brotliSupport['supported']),
            'brotli_profile_reason' => (string) ($brotliSupport['reason'] ?? ''),
        ]);

        while ($processed < $perBatch) {
            $idx = max(0, (int) ($state['cursor'] ?? $newCursor));
            if (!isset($urls[$idx])) {
                if ($runMode !== 'single') {
                    break;
                }

                $mutationLock = Queue::acquireTargetedMutationLock();
                if (!$mutationLock['acquired']) {
                    $targetedCheckpointUnavailable = true;
                    break;
                }

                $targetedMutationToken = $mutationLock['token'];
                $state = Queue::drainTargetedBuffer($runId);
                if (!Queue::isCurrentRun($runId)) {
                    Queue::releaseTargetedMutationLock($targetedMutationToken);
                    return;
                }

                $urls = isset($state['urls']) && is_array($state['urls']) ? array_values($state['urls']) : [];
                $trustedSet = isset($state['trusted_set']) && is_array($state['trusted_set'])
                    ? $state['trusted_set']
                    : [];
                $newCursor = max(0, (int) ($state['cursor'] ?? $newCursor));
                Logger::updateTotalUrls($runId, count($urls));
                Logger::syncTargetedUpdateMeta(
                    $runId,
                    (int) ($state['targeted_update_count'] ?? 0),
                    !empty($state['correlation_history_saturated'])
                );

                if (!isset($urls[$newCursor])) {
                    break;
                }

                Queue::releaseTargetedMutationLock($targetedMutationToken);
                $targetedMutationToken = '';
                $idx = $newCursor;
            }

            if ($processed > 0 && $throttle > 0) {
                sleep($throttle);
            }

            $elapsed = (float) (microtime(true) - $start);
            $remaining = (float) $maxBatch - $elapsed;
            $worstCaseNext = (float) $timeout;
            if ($processed > 0 && $remaining < $worstCaseNext) {
                Debug::log('Runner batch guard stop before next URL', [
                    'remaining_seconds' => round($remaining, 3),
                    'worst_case_next_seconds' => round($worstCaseNext, 3),
                    'mode' => $runMode,
                ]);
                break;
            }

            $url = (string) $urls[$idx];
            $warmedAt = self::wpLocalNow();

            $isTrusted = isset($trustedSet[$url]);
            $membershipResult = Sitemap::inspectCachedMembership($url);
            $membership = $membershipResult['membership'] ?? null;
            $membershipLabel = is_bool($membership) ? ($membership ? 'true' : 'false') : 'null';
            if (!$isTrusted && ($membershipResult['membership'] ?? null) === false) {
                Debug::verbose('runner_prune_decision', [
                    'run_id' => $runId,
                    'mode' => $runMode,
                    'url' => $url,
                    'membership' => $membershipLabel,
                    'decision' => 'pruned_membership_false',
                    'match_type' => (string) ($membershipResult['match_type'] ?? 'none'),
                    'set_size' => (int) ($membershipResult['set_size'] ?? 0),
                ]);
                $result = [
                    'url' => $url,
                    'ok' => true,
                    'code' => 0,
                    'status_code' => 0,
                    'ms' => 0,
                    'error' => 'stale_sitemap_membership',
                    'result' => 'skipped_membership_miss',
                    'transport_used' => 'membership_check',
                    'membership_match_type' => (string) ($membershipResult['match_type'] ?? 'none'),
                    'membership_set_size' => (int) ($membershipResult['set_size'] ?? 0),
                ];
                $skipped++;
            } else {
                if ($isTrusted && ($membershipResult['membership'] ?? null) === false) {
                    Debug::verbose('runner_trust_bypass_membership', [
                        'run_id' => $runId,
                        'mode' => $runMode,
                        'url' => $url,
                        'membership' => $membershipLabel,
                        'match_type' => (string) ($membershipResult['match_type'] ?? 'none'),
                        'set_size' => (int) ($membershipResult['set_size'] ?? 0),
                    ]);
                }
                $result = self::warmUrl($url, $timeout, $userAgent, $runMode, $brotliSupport);
            }

            if (!empty($state['context']['language'])) {
                $result['lang'] = (string) $state['context']['language'];
            }

            $result['warmed_at'] = $warmedAt;
            $result['idx'] = $idx;

            $details[] = $result;

            if (($result['result'] ?? '') === 'skipped_membership_miss') {
                // Skipped before HTTP, so this advances the queue without counting as warm or failed.
            } elseif (!empty($result['ok'])) {
                $warmed++;
            } else {
                $failed++;
            }

            $processed++;
            $newCursor = $idx + 1;
            $state['cursor'] = $newCursor;
            $state['warmed'] = $initialWarmed + $warmed;
            $state['failed'] = $initialFailed + $failed;
            $state['last_activity'] = time();
            if (!Queue::setIfRunOwned($runId, $state)) {
                return;
            }

            if ((microtime(true) - $start) > (float) $maxBatch) {
                break;
            }
        }

        if (!empty($details)) {
            $telemetryRows = [];
            foreach ($details as $detail) {
                if (!is_array($detail) || !is_numeric($detail['ms'] ?? null)) {
                    continue;
                }

                $row = [
                    'effective_ms' => (int) $detail['ms'],
                ];

                if (!empty($detail['brotli_attempted']) && is_numeric($detail['brotli_ms'] ?? null)) {
                    $row['brotli_ms'] = (int) $detail['brotli_ms'];
                }

                if (!empty($detail['wp_attempted']) && is_numeric($detail['wp_ms'] ?? null)) {
                    $row['wp_ms'] = (int) $detail['wp_ms'];
                }

                if (!empty($detail['fallback_reason']) && is_string($detail['fallback_reason'])) {
                    $row['fallback_reason'] = (string) $detail['fallback_reason'];
                }

                $telemetryRows[] = $row;
            }

            if (!empty($telemetryRows)) {
                TuningStats::pushDetailed($telemetryRows);
            }
        }

        if (!Queue::isCurrentRun($runId)) {
            if ($targetedMutationToken !== '') {
                Queue::releaseTargetedMutationLock($targetedMutationToken);
            }
            return;
        }

        if ($processed === 0) {
            Logger::finishRun(
                $runId,
                'failed',
                count($urls),
                (int) ($state['warmed'] ?? 0),
                (int) ($state['failed'] ?? 0),
                __('Warmup stalled: no URLs processed in batch.', 'cache-autopilot')
            );
            Queue::clearIfRunOwned($runId);
            if ($targetedMutationToken !== '') {
                Queue::releaseTargetedMutationLock($targetedMutationToken);
            }
            return;
        }

        Logger::appendDetails($runId, $details);
        Logger::updateRunProgress(
            $runId,
            (int) ($state['warmed'] ?? 0),
            (int) ($state['failed'] ?? 0)
        );

        if ($runMode === 'single') {
            if ($targetedCheckpointUnavailable) {
                Logger::transitionRunningToPending($runId);
                self::scheduleSoon(false);
                return;
            }

            if ($targetedMutationToken === '') {
                $mutationLock = Queue::acquireTargetedMutationLock();
                if (!$mutationLock['acquired']) {
                    Logger::transitionRunningToPending($runId);
                    self::scheduleSoon(false);
                    return;
                }
                $targetedMutationToken = $mutationLock['token'];
            }
        }

        try {
            if ($runMode === 'single') {
                $state = Queue::drainTargetedBuffer($runId);
                if (!Queue::isCurrentRun($runId)) {
                    return;
                }

                $urls = isset($state['urls']) && is_array($state['urls']) ? array_values($state['urls']) : [];
                $trustedSet = isset($state['trusted_set']) && is_array($state['trusted_set'])
                    ? $state['trusted_set']
                    : [];
                $newCursor = max(0, (int) ($state['cursor'] ?? $newCursor));
                Logger::updateTotalUrls($runId, count($urls));
                Logger::syncTargetedUpdateMeta(
                    $runId,
                    (int) ($state['targeted_update_count'] ?? 0),
                    !empty($state['correlation_history_saturated'])
                );
            }

            if ($newCursor >= count($urls)) {
                $coalesced = Queue::consumeCoalescedFullIntentIfRunOwned($runId);

                $state['status'] = 'finished';
                $state['finished_at'] = gmdate('Y-m-d H:i:s');

                if (!Queue::setIfRunOwned($runId, $state)) {
                    return;
                }

                Debug::log('Runner completed', [
                    'run_id' => $runId,
                    'processed' => $processed,
                    'warmed' => $warmed,
                    'failed' => $failed,
                    'skipped' => $skipped,
                    'total' => count($urls),
                ]);

                Logger::finishRun(
                    $runId,
                    'finished',
                    count($urls),
                    (int) $state['warmed'],
                    (int) $state['failed'],
                    __('Warmup finished.', 'cache-autopilot')
                );

                Queue::clearIfRunOwned($runId);

                if (!empty($coalesced['has_intent'])) {
                    $trigger = trim((string) ($coalesced['latest_trigger'] ?? ''));
                    if ($trigger === '') {
                        $trigger = 'cache_invalidation';
                    }

                    $meta = is_array($coalesced['latest_meta'] ?? null) ? $coalesced['latest_meta'] : [];
                    if (!empty($meta['coalesced_replay'])) {
                        unset($meta['coalesced_replay']);
                    }

                    $reason = 'after_run_finished';
                    Debug::log('coalesced_run_replayed', [
                        'trigger' => $trigger,
                        'reason' => $reason,
                        'previous_run_id' => $runId,
                        'meta_keys' => array_keys($meta),
                    ]);

                    Queue::enqueueFullIntent($trigger, $meta + [
                        'coalesced_replay' => true,
                        'coalesced_from_run' => $runId,
                    ]);
                }

                return;
            }

            Logger::transitionRunningToPending($runId);
            self::scheduleNextRun(60);

            $batchSummary = self::summarizeBatchWarmDetails($details);

            Debug::log('Runner batch_done', [
                'processed' => $processed,
                'warmed' => $warmed,
                'failed' => $failed,
                'skipped' => $skipped,
                'cursor' => $newCursor,
                'total' => count($urls),
                'next' => wp_next_scheduled(self::CRON_HOOK),
            ] + $batchSummary);
        } finally {
            if ($targetedMutationToken !== '') {
                Queue::releaseTargetedMutationLock($targetedMutationToken);
            }
        }

    }

    /**
     * Build the full-run plan in memory using only the captured intent snapshot.
     *
     * @param array<string,mixed> $intentInputs Planning snapshot from queue state.
     * @param string $runId Active run identifier.
     * @param string $intentSignature Intent signature for guarded last_activity touches.
     * @return array{urls:array<int,string>,prepare_source:string,resolved_sitemap_urls:array<int,string>,resolved_sitemap_count:int}
     */
    private static function buildPreparationPlan(array $intentInputs, string $runId, string $intentSignature): array
    {
        $sitemaps = Repository::normalizeSitemapUrls($intentInputs['sitemaps'] ?? []);
        $resolvedSitemapUrls = [];
        $preparedUrls = [];
        $prepareSource = 'sitemaps';

        if ($sitemaps !== []) {
            $resolvedSitemapUrls = self::finalizePreparedUrls(Sitemap::resolveUrls($sitemaps), $intentInputs);
        }

        Queue::touchPreparingActivity($runId, $intentSignature);

        if ($resolvedSitemapUrls !== []) {
            $preparedUrls = $resolvedSitemapUrls;
        } else {
            $cachedUrls = self::finalizePreparedUrls(
                Sitemap::getMembershipUrls(),
                $intentInputs
            );

            if ($cachedUrls !== []) {
                Debug::log('runner_membership_urls_used', [
                    'count' => count($cachedUrls),
                ]);
                $preparedUrls = $cachedUrls;
                $prepareSource = 'cached_urls';
            } else {
                $preparedUrls = self::finalizePreparedUrls(self::getFallbackUrls(), $intentInputs);
                $prepareSource = 'pages_fallback';
            }
        }

        if ($preparedUrls === []) {
            $homeUrl = Sitemap::canonicalizeUrl(home_url('/'));
            if ($homeUrl !== '') {
                $preparedUrls = [$homeUrl];
                $prepareSource = 'home_fallback';
            }
        }

        Queue::touchPreparingActivity($runId, $intentSignature);

        return [
            'urls' => $preparedUrls,
            'prepare_source' => $prepareSource,
            'resolved_sitemap_urls' => $resolvedSitemapUrls,
            'resolved_sitemap_count' => count($resolvedSitemapUrls),
        ];
    }

    /**
     * Normalize, reprioritize, and exclude URLs using the captured full-run snapshot.
     *
     * @param array<int,string> $urls Raw URLs from sitemap or fallback.
     * @param array<string,mixed> $intentInputs Captured intent snapshot.
     * @return array<int,string> Prepared URLs ready for pending merge.
     */
    private static function finalizePreparedUrls(array $urls, array $intentInputs): array
    {
        $normalized = [];
        $seen = [];

        foreach ($urls as $url) {
            $canon = Sitemap::canonicalizeUrl((string) $url);
            if ($canon === '' || isset($seen[$canon])) {
                continue;
            }

            $seen[$canon] = true;
            $normalized[] = $canon;
        }

        if ($normalized === []) {
            return [];
        }

        $normalized = PriorityFilter::applyPrioritiesFromSnapshot($normalized, $intentInputs);

        $manualExclusions = Repository::normalizeManualExclusions($intentInputs['manual_exclusions'] ?? []);
        if ($manualExclusions !== []) {
            $normalized = array_values(array_filter($normalized, static function (string $url) use ($manualExclusions): bool {
                return !in_array($url, $manualExclusions, true);
            }));
        }

        return self::promotePlannedHomeUrls($normalized, $intentInputs, $manualExclusions);
    }

    /**
     * Inject and promote captured Home URLs inside the planned full-run segment.
     *
     * Pending invalidation URLs are merged later and retain precedence. Captured
     * Home URLs remain subject to explicit manual exclusions.
     *
     * @param array<int,string> $urls Prepared sitemap or fallback URLs.
     * @param array<string,mixed> $intentInputs Captured full-run snapshot.
     * @param array<int,string> $manualExclusions Canonical manual exclusions.
     * @return array<int,string>
     */
    private static function promotePlannedHomeUrls(
        array $urls,
        array $intentInputs,
        array $manualExclusions
    ): array {
        $homeUrls = [];
        $homeSet = [];

        foreach ((array) ($intentInputs['priority_home_urls'] ?? []) as $homeUrl) {
            $canonical = Sitemap::canonicalizeUrl((string) $homeUrl);
            if (
                $canonical === ''
                || isset($homeSet[$canonical])
                || in_array($canonical, $manualExclusions, true)
            ) {
                continue;
            }

            $homeSet[$canonical] = true;
            $homeUrls[] = $canonical;
        }

        if ($homeUrls === []) {
            return $urls;
        }

        $tail = array_values(array_filter(
            $urls,
            static fn(string $url): bool => !isset($homeSet[$url])
        ));

        return array_values(array_merge($homeUrls, $tail));
    }

    /**
     * Merge buffered pending invalidation URLs ahead of the prepared sitemap plan.
     *
     * @param array<int,string> $pendingPriorityUrls Pending buffer captured from queue state.
     * @param array<int,string> $plannedUrls Prepared sitemap/fallback plan.
     * @return array{pending_urls:array<int,string>,planned_urls:array<int,string>,buffer_count:int}
     */
    private static function mergePendingPriorityUrls(array $pendingPriorityUrls, array $plannedUrls): array
    {
        $pendingUrls = [];
        $seen = [];

        foreach ($pendingPriorityUrls as $url) {
            $canon = Sitemap::canonicalizeUrl((string) $url);
            if ($canon === '' || isset($seen[$canon])) {
                continue;
            }

            $seen[$canon] = true;
            $pendingUrls[] = $canon;
        }

        $dedupedPlannedUrls = [];
        foreach ($plannedUrls as $url) {
            $url = trim((string) $url);
            if ($url === '' || isset($seen[$url])) {
                continue;
            }

            $seen[$url] = true;
            $dedupedPlannedUrls[] = $url;
        }

        return [
            'pending_urls' => $pendingUrls,
            'planned_urls' => $dedupedPlannedUrls,
            'buffer_count' => count($pendingUrls),
        ];
    }

    /**
     * Apply the max_urls_per_run cap to the sitemap-derived tail only.
     *
     * Pending invalidation URLs are always preserved, even when they alone exceed the
     * nominal run cap.
     *
     * @param array<int,string> $pendingUrls Pending invalidation URLs (already deduped).
     * @param array<int,string> $plannedUrls Prepared sitemap/fallback tail (already deduped against pending).
     * @param int $maxUrls Configured max URLs per run from the intent snapshot.
     * @return array{urls:array<int,string>,capped:bool,dropped_count:int}
     */
    private static function applyPreparedCap(array $pendingUrls, array $plannedUrls, int $maxUrls): array
    {
        $maxUrls = max(1, $maxUrls);
        $remainingCap = max(0, $maxUrls - count($pendingUrls));
        $keptPlannedUrls = $remainingCap > 0 ? array_slice($plannedUrls, 0, $remainingCap) : [];

        return [
            'urls' => array_values(array_merge($pendingUrls, $keptPlannedUrls)),
            'capped' => count($plannedUrls) > count($keptPlannedUrls),
            'dropped_count' => max(0, count($plannedUrls) - count($keptPlannedUrls)),
        ];
    }

    /**
     * Build the fallback URL list used when sitemap resolution fails.
     *
     * @return array<int,string> Canonicalized fallback URLs.
     */
    private static function getFallbackUrls(): array
    {
        $query = new \WP_Query([
            'post_type' => 'page',
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'orderby' => 'date',
            'order' => 'DESC',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        $urls = [];
        foreach ($query->posts as $post) {
            $permalink = get_permalink($post->ID);
            if (!$permalink) {
                continue;
            }

            $canon = Sitemap::canonicalizeUrl((string) $permalink);
            if ($canon !== '') {
                $urls[] = $canon;
            }
        }

        wp_reset_postdata();

        if ($urls === []) {
            $homeUrl = Sitemap::canonicalizeUrl(home_url('/'));
            if ($homeUrl !== '') {
                $urls[] = $homeUrl;
            }
        }

        return $urls;
    }

    /**
     * Finalize a broken preparing run as failed and clear the queue.
     *
     * @param array<string,mixed> $state Active preparing queue state.
     * @param string $message User-facing failure message.
     * @return void
     */
    private static function failPreparingRun(array $state, string $message): void
    {
        $runId = isset($state['run_id']) ? (string) $state['run_id'] : '';
        if ($runId !== '' && !Queue::isCurrentRun($runId)) {
            return;
        }

        $coalesced = $runId !== ''
            ? Queue::consumeCoalescedFullIntentIfRunOwned($runId)
            : [];

        if ($runId !== '') {
            Logger::finishRun(
                $runId,
                'failed',
                Queue::getTotalUrls($state),
                (int) ($state['warmed'] ?? 0),
                (int) ($state['failed'] ?? 0),
                $message
            );
        }

        if ($runId === '') {
            Queue::clear();
            return;
        }

        Queue::clearIfRunOwned($runId);

        if (!empty($coalesced['has_intent'])) {
            $trigger = trim((string) ($coalesced['latest_trigger'] ?? ''));
            if ($trigger === '') {
                $trigger = 'cache_invalidation';
            }

            $meta = is_array($coalesced['latest_meta'] ?? null) ? $coalesced['latest_meta'] : [];
            if (!empty($meta['coalesced_replay'])) {
                unset($meta['coalesced_replay']);
            }

            $reason = 'prepare_failed';
            Debug::log('coalesced_run_replayed', [
                'trigger' => $trigger,
                'reason' => $reason,
                'previous_run_id' => $runId,
                'meta_keys' => array_keys($meta),
            ]);

            Queue::enqueueFullIntent($trigger, $meta + [
                'coalesced_replay' => true,
                'coalesced_from_run' => $runId,
                'coalesced_after_prepare_failed' => true,
            ]);
        }
    }

    /**
     * Schedule an imminent warmup run and proactively kick WP-Cron.
     *
     * Use this after enqueueing URLs to reduce the delay introduced by
     * external cron runners that may only ping wp-cron.php periodically.
     *
     * @param bool $kickCron Whether to start a non-blocking WP-Cron request.
     * @return void
     */
    public static function scheduleSoon(bool $kickCron = true): void
    {
        if (!$kickCron && wp_next_scheduled(self::CRON_HOOK)) {
            return;
        }

        self::scheduleNextRun(0);
        if ($kickCron) {
            self::kickCron();
        }
    }

    /**
     * Stop an active queue when no selected adapter can currently warm pages.
     *
     * @return void
     */
    private static function stopActiveRunForUnavailableAdapter(): void
    {
        if (Queue::isRunLocked()) {
            return;
        }

        $state = Queue::get();
        if (!Queue::hasActiveRun($state)) {
            return;
        }

        $runId = isset($state['run_id']) ? (string) $state['run_id'] : '';
        if ($runId === '') {
            Queue::clear();
            return;
        }

        if (!Queue::isCurrentRun($runId)) {
            return;
        }

        $eciAdapterManager = '\\Ekesto\\CacheInvalidator\\Integrations\\Caching\\AdapterManager';
        $hasAvailableAdapter = false;
        $adapterLabel = __('None detected', 'cache-autopilot');

        if (class_exists($eciAdapterManager)) {
            if (method_exists($eciAdapterManager, 'hasAvailableAdapters')) {
                $hasAvailableAdapter = (bool) $eciAdapterManager::hasAvailableAdapters();
            }

            if (method_exists($eciAdapterManager, 'getActiveAdapterLabel')) {
                $activeLabel = (string) $eciAdapterManager::getActiveAdapterLabel();
                if ($activeLabel !== '') {
                    $adapterLabel = $activeLabel;
                }
            }
        }

        $message = !$hasAvailableAdapter
            ? __('Warmup stopped: no supported caching plugin detected.', 'cache-autopilot')
            : sprintf(
                /* translators: %s: cache plugin label */
                __('Warmup stopped: %s is active, but page caching is currently disabled.', 'cache-autopilot'),
                $adapterLabel
            );

        Debug::warn(sprintf('Warmup stopped: adapter became unavailable (adapter: %s)', $adapterLabel));

        Logger::finishRun(
            $runId,
            'stopped',
            Queue::getTotalUrls($state),
            (int) ($state['warmed'] ?? 0),
            (int) ($state['failed'] ?? 0),
            $message
        );

        Queue::clearIfRunOwned($runId);
        Debug::info(sprintf('Warmup queue cleared: adapter unavailable (adapter: %s)', $adapterLabel));
    }

    /**
     * Warm a single URL by issuing an anonymous GET request.
     *
     * Captures LiteSpeed-related headers and cache metadata
     * to determine whether the request generated or hit cache.
     *
     * @param string $url
     * @param int    $timeoutSeconds
     * @param string $userAgent
     * @param string $runMode
     * @param array<string,mixed> $brotliSupport
     * @return array<string,mixed>
     */
    private static function warmUrl(string $url, int $timeoutSeconds, string $userAgent, string $runMode, array $brotliSupport): array
    {
        $requestStart = microtime(true);
        $brotliDefaults = self::brotliDefaults();
        $fallbackReason = '';
        $brotliResponse = [
            'attempted' => false,
            'ok' => false,
            'code' => 0,
            'ms' => 0,
            'error' => '',
            'final_url' => '',
            'redirects' => 0,
            'content_encoding' => '',
            'vary' => '',
            'etag' => '',
            'cache_ctrl' => '',
            'age' => '',
            'lscache' => '',
            'lscache_ctrl' => '',
            'lscache_vary' => '',
        ];

        $skipReason = self::targetedBrotliSkipReason($url, $brotliSupport);

        if ($skipReason === '') {
            $brotliResponse = TargetedBrotliTransport::warm($url, $timeoutSeconds, $userAgent);
            if (empty($brotliResponse['ok'])) {
                $fallbackReason = 'brotli_profile_failed:'
                    . (string) ($brotliResponse['error'] ?? 'wordpress_http_request_unsuccessful');
            }
        } else {
            $fallbackReason = $skipReason;
        }

        if (!empty($brotliResponse['ok'])) {
            $effectiveMs = (int) round((microtime(true) - $requestStart) * 1000);
            $result = [
                'url' => $url,
                'ok' => true,
                'code' => (int) ($brotliResponse['code'] ?? 0),
                'ms' => $effectiveMs,
                'error' => '',
                'lscache' => (string) ($brotliResponse['lscache'] ?? ''),
                'lscache_ctrl' => (string) ($brotliResponse['lscache_ctrl'] ?? ''),
                'lscache_tag' => '',
                'lscache_vary' => (string) ($brotliResponse['lscache_vary'] ?? ''),
                'etag' => (string) ($brotliResponse['etag'] ?? ''),
                'cache_ctrl' => (string) ($brotliResponse['cache_ctrl'] ?? ''),
                'vary' => (string) ($brotliResponse['vary'] ?? ''),
                'age' => (string) ($brotliResponse['age'] ?? ''),
                'ls_cached_at' => '',
                'transport_used' => 'brotli_profile',
                'fallback_used' => false,
                'fallback_reason' => '',
                'wp_attempted' => false,
                'wp_ms' => 0,
            ] + $brotliDefaults;

            $result['brotli_attempted'] = !empty($brotliResponse['attempted']);
            $result['brotli_ok'] = true;
            $result['brotli_code'] = (int) ($brotliResponse['code'] ?? 0);
            $result['brotli_ms'] = (int) ($brotliResponse['ms'] ?? 0);
            $result['brotli_error'] = '';
            $result['brotli_redirects'] = (int) ($brotliResponse['redirects'] ?? 0);
            $result['brotli_final_url'] = (string) ($brotliResponse['final_url'] ?? '');
            $result['brotli_content_encoding'] = (string) ($brotliResponse['content_encoding'] ?? '');
            $result['brotli_vary'] = (string) ($brotliResponse['vary'] ?? '');
            $result['brotli_etag'] = (string) ($brotliResponse['etag'] ?? '');
            $result['brotli_cache_ctrl'] = (string) ($brotliResponse['cache_ctrl'] ?? '');
            $result['brotli_age'] = (string) ($brotliResponse['age'] ?? '');
            $result['brotli_lscache'] = (string) ($brotliResponse['lscache'] ?? '');
            $result['brotli_lscache_ctrl'] = (string) ($brotliResponse['lscache_ctrl'] ?? '');
            $result['brotli_lscache_vary'] = (string) ($brotliResponse['lscache_vary'] ?? '');
            $result['brotli_skip_reason'] = '';

            Debug::verbose('Runner warm', [
                'url' => $url,
                'mode' => $runMode,
                'transport_used' => $result['transport_used'],
                'fallback_used' => $result['fallback_used'],
                'fallback_reason' => $result['fallback_reason'],
                'code' => $result['code'],
                'ms' => $result['ms'],
                'wp_ms' => $result['wp_ms'],
                'brotli_attempted' => $result['brotli_attempted'],
                'brotli_ok' => $result['brotli_ok'],
                'brotli_code' => $result['brotli_code'],
                'brotli_ms' => $result['brotli_ms'],
                'brotli_error' => $result['brotli_error'],
                'brotli_content_encoding' => $result['brotli_content_encoding'],
                'brotli_vary' => $result['brotli_vary'],
                'brotli_etag' => $result['brotli_etag'],
                'brotli_lscache' => $result['brotli_lscache'],
                'lscache' => $result['lscache'],
                'etag' => $result['etag'],
                'vary' => $result['vary'],
            ]);

            return $result;
        }

        $wpStart = microtime(true);
        $args = [
            'timeout' => max(1, $timeoutSeconds),
            'redirection' => 3,
            'headers' => [
                'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                'Accept-Encoding' => 'gzip, deflate',
                'Cache-Control' => 'no-cache',
                'Pragma' => 'no-cache',
            ],
            'user-agent' => $userAgent,
            'sslverify' => true,
        ];

        $res = wp_remote_get($url, $args);
        $wpMs = (int) round((microtime(true) - $wpStart) * 1000);

        if (is_wp_error($res)) {
            $error = $res->get_error_message();
            $result = [
                'url' => $url,
                'ok' => false,
                'code' => 0,
                'ms' => (int) round((microtime(true) - $requestStart) * 1000),
                'error' => $error,
                'lscache' => '',
                'lscache_ctrl' => '',
                'lscache_tag' => '',
                'lscache_vary' => '',
                'etag' => '',
                'cache_ctrl' => '',
                'vary' => '',
                'age' => '',
                'ls_cached_at' => '',
                'transport_used' => 'wp_remote_get',
                'fallback_used' => true,
                'fallback_reason' => $fallbackReason,
                'wp_attempted' => true,
                'wp_ms' => $wpMs,
            ] + $brotliDefaults;

            $result['brotli_attempted'] = !empty($brotliResponse['attempted']);
            $result['brotli_ok'] = false;
            $result['brotli_code'] = (int) ($brotliResponse['code'] ?? 0);
            $result['brotli_ms'] = (int) ($brotliResponse['ms'] ?? 0);
            $result['brotli_error'] = (string) ($brotliResponse['error'] ?? '');
            $result['brotli_redirects'] = (int) ($brotliResponse['redirects'] ?? 0);
            $result['brotli_final_url'] = (string) ($brotliResponse['final_url'] ?? '');
            $result['brotli_content_encoding'] = (string) ($brotliResponse['content_encoding'] ?? '');
            $result['brotli_vary'] = (string) ($brotliResponse['vary'] ?? '');
            $result['brotli_etag'] = (string) ($brotliResponse['etag'] ?? '');
            $result['brotli_cache_ctrl'] = (string) ($brotliResponse['cache_ctrl'] ?? '');
            $result['brotli_age'] = (string) ($brotliResponse['age'] ?? '');
            $result['brotli_lscache'] = (string) ($brotliResponse['lscache'] ?? '');
            $result['brotli_lscache_ctrl'] = (string) ($brotliResponse['lscache_ctrl'] ?? '');
            $result['brotli_lscache_vary'] = (string) ($brotliResponse['lscache_vary'] ?? '');
            $result['brotli_skip_reason'] = $skipReason;

            Debug::warn('Runner warm failed', [
                'url' => $url,
                'mode' => $runMode,
                'transport_used' => $result['transport_used'],
                'fallback_used' => $result['fallback_used'],
                'fallback_reason' => $result['fallback_reason'],
                'ms' => $result['ms'],
                'wp_ms' => $result['wp_ms'],
                'error' => $result['error'],
                'brotli_attempted' => $result['brotli_attempted'],
                'brotli_error' => $result['brotli_error'],
            ]);

            return $result;
        }

        $code = (int) wp_remote_retrieve_response_code($res);
        $headers = self::normalizeHeaders(wp_remote_retrieve_headers($res));
        $body = (string) wp_remote_retrieve_body($res);

        $ok = ($code >= 200 && $code < 400);
        $result = [
            'url' => $url,
            'ok' => $ok,
            'code' => $code,
            'ms' => (int) round((microtime(true) - $requestStart) * 1000),
            'error' => $ok ? '' : 'http_' . $code,
            'lscache' => (string) ($headers['x-litespeed-cache'] ?? ''),
            'lscache_ctrl' => (string) ($headers['x-litespeed-cache-control'] ?? ''),
            'lscache_tag' => (string) ($headers['x-litespeed-tag'] ?? ''),
            'lscache_vary' => (string) ($headers['x-litespeed-vary'] ?? ''),
            'etag' => (string) ($headers['etag'] ?? ''),
            'cache_ctrl' => (string) ($headers['cache-control'] ?? ''),
            'vary' => (string) ($headers['vary'] ?? ''),
            'age' => (string) ($headers['age'] ?? ''),
            'ls_cached_at' => self::extractLiteSpeedCachedAt($body),
            'transport_used' => 'wp_remote_get',
            'fallback_used' => true,
            'fallback_reason' => $fallbackReason,
            'wp_attempted' => true,
            'wp_ms' => $wpMs,
        ] + $brotliDefaults;

        $result['brotli_attempted'] = !empty($brotliResponse['attempted']);
        $result['brotli_ok'] = !empty($brotliResponse['ok']);
        $result['brotli_code'] = (int) ($brotliResponse['code'] ?? 0);
        $result['brotli_ms'] = (int) ($brotliResponse['ms'] ?? 0);
        $result['brotli_error'] = (string) ($brotliResponse['error'] ?? '');
        $result['brotli_redirects'] = (int) ($brotliResponse['redirects'] ?? 0);
        $result['brotli_final_url'] = (string) ($brotliResponse['final_url'] ?? '');
        $result['brotli_content_encoding'] = (string) ($brotliResponse['content_encoding'] ?? '');
        $result['brotli_vary'] = (string) ($brotliResponse['vary'] ?? '');
        $result['brotli_etag'] = (string) ($brotliResponse['etag'] ?? '');
        $result['brotli_cache_ctrl'] = (string) ($brotliResponse['cache_ctrl'] ?? '');
        $result['brotli_age'] = (string) ($brotliResponse['age'] ?? '');
        $result['brotli_lscache'] = (string) ($brotliResponse['lscache'] ?? '');
        $result['brotli_lscache_ctrl'] = (string) ($brotliResponse['lscache_ctrl'] ?? '');
        $result['brotli_lscache_vary'] = (string) ($brotliResponse['lscache_vary'] ?? '');
        $result['brotli_skip_reason'] = $skipReason;

        Debug::verbose('Runner warm', [
            'url' => $url,
            'mode' => $runMode,
            'transport_used' => $result['transport_used'],
            'fallback_used' => $result['fallback_used'],
            'fallback_reason' => $result['fallback_reason'],
            'code' => $result['code'],
            'ms' => $result['ms'],
            'wp_ms' => $result['wp_ms'],
            'lscache' => $result['lscache'],
            'etag' => $result['etag'],
            'vary' => $result['vary'],
            'brotli_attempted' => $result['brotli_attempted'],
            'brotli_ok' => $result['brotli_ok'],
            'brotli_code' => $result['brotli_code'],
            'brotli_ms' => $result['brotli_ms'],
            'brotli_error' => $result['brotli_error'],
            'brotli_content_encoding' => $result['brotli_content_encoding'],
            'brotli_vary' => $result['brotli_vary'],
            'brotli_etag' => $result['brotli_etag'],
            'brotli_lscache' => $result['brotli_lscache'],
        ]);

        return $result;
    }

    /**
     * Schedule a single warmup run (force exactly one upcoming tick).
     *
     * @param int $delaySeconds Delay before the event becomes due.
     * @return void
     */
    private static function scheduleNextRun(int $delaySeconds = 5): void
    {
        $delaySeconds = max(0, $delaySeconds);

        $existing = wp_next_scheduled(self::CRON_HOOK);
        $wantTs = time() + $delaySeconds;

        if (is_int($existing) && $existing > 0) {
            if ($delaySeconds === 0 && $existing <= $wantTs) {
                return;
            }

            // If there is already a future tick scheduled soon enough, keep it.
            if ($delaySeconds > 0 && $existing <= ($wantTs + 5)) {
                return;
            }
        }

        wp_clear_scheduled_hook(self::CRON_HOOK);
        wp_schedule_single_event($wantTs, self::CRON_HOOK);

        Debug::log('Runner scheduled tick', ['delay_seconds' => $delaySeconds]);
    }

    /**
     * Trigger one warmup batch as soon as possible (non-blocking).
     *
     * @return void
     */
    public static function kickCron(): void
    {
        if (defined('DOING_CRON') && DOING_CRON) {
            self::deferFullKickUntilShutdown();
            return;
        }

        if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
            if (class_exists(ImmediateKick::class, true)) {
                ImmediateKick::dispatch();
            } else {
                Debug::warn('Immediate kick unavailable; scheduled cron fallback retained.');
            }
            return;
        }

        if (function_exists('spawn_cron')) {
            $result = spawn_cron();

            Debug::verbose('Runner kickCron spawn_cron', ['result' => $result]);

            return;
        }

        // Fallback: non-blocking loopback request.
        // NOTE: 0.01s is too aggressive for TLS handshakes on shared hosting; keep non-blocking but allow realistic timeout.
        $url = site_url('wp-cron.php?doing_wp_cron=' . rawurlencode((string) microtime(true)));

        $resp = wp_remote_post(
            $url,
            [
                'timeout' => 0.75,
                'blocking' => false,
                'sslverify' => true,
                'headers' => [
                    'Cache-Control' => 'no-cache',
                ],
                'user-agent' => 'Ekesto-Cache-Warmup/cron-kick',
            ]
        );

        Debug::log('Runner kickCron fallback', [
            'result' => is_wp_error($resp) ? $resp->get_error_message() : 'ok',
        ]);
    }

    /**
     * Dispatch a pending full-run kick after the current cron request releases its runner lock.
     *
     * @return void
     */
    public static function dispatchDeferredFullKick(): void
    {
        self::$deferredFullKickRegistered = false;

        $state = Queue::get();
        if (!Queue::hasActiveRun($state, 'full')) {
            return;
        }

        if (class_exists(ImmediateKick::class, true)) {
            ImmediateKick::dispatch();
        }
    }

    /**
     * Register one shutdown kick only when a full run is waiting in this cron request.
     *
     * @return void
     */
    private static function deferFullKickUntilShutdown(): void
    {
        if (self::$deferredFullKickRegistered) {
            return;
        }

        $state = Queue::get();
        if (!Queue::hasActiveRun($state, 'full')) {
            return;
        }

        self::$deferredFullKickRegistered = true;
        add_action('shutdown', [self::class, 'dispatchDeferredFullKick'], 0);
    }

    /**
     * Delete old log rows based on retention.
     */
    public static function cleanupOldLogs(): void
    {
        $days = Repository::getInt('logging.retention_days');
        if ($days < 1) {
            $days = 30;
        }

        Logger::deleteOlderThanDays($days);

        // Safety net: mark long-abandoned active log rows as stale.
        // This is intentionally not done in the hot path (Runner::run) to avoid false stale on delayed cron hosts.
        if (method_exists(Logger::class, 'markStaleRuns')) {
            Logger::markStaleRuns(6 * HOUR_IN_SECONDS);
        }
    }

    private static function wpLocalNow(): string
    {
        return (string) wp_date('Y-m-d H:i:s');
    }

    /**
     * Build a compact batch-level transport summary for baseline debug logs.
     *
     * @param array<int,array<string,mixed>> $details Warm detail rows from this batch.
     * @return array<string,int>
     */
    private static function summarizeBatchWarmDetails(array $details): array
    {
        $summary = [
            'transport_brotli_profile' => 0,
            'transport_wp_remote_get' => 0,
            'fallback_used' => 0,
            'brotli_attempted' => 0,
            'brotli_ok' => 0,
        ];

        foreach ($details as $detail) {
            if (!is_array($detail)) {
                continue;
            }

            $transport = (string) ($detail['transport_used'] ?? '');
            if ($transport === 'brotli_profile') {
                $summary['transport_brotli_profile']++;
            } elseif ($transport === 'wp_remote_get') {
                $summary['transport_wp_remote_get']++;
            }

            if (!empty($detail['fallback_used'])) {
                $summary['fallback_used']++;
            }

            if (!empty($detail['brotli_attempted'])) {
                $summary['brotli_attempted']++;
            }

            if (!empty($detail['brotli_ok'])) {
                $summary['brotli_ok']++;
            }
        }

        return $summary;
    }

    private static function normalizeHeaders($headers): array
    {
        $out = [];

        if (is_array($headers)) {
            foreach ($headers as $k => $v) {
                $out[strtolower((string) $k)] = is_array($v) ? implode(', ', $v) : (string) $v;
            }
            return $out;
        }

        if (is_object($headers) && $headers instanceof \Requests_Utility_CaseInsensitiveDictionary) {
            foreach ($headers->getAll() as $k => $v) {
                $out[strtolower((string) $k)] = is_array($v) ? implode(', ', $v) : (string) $v;
            }
            return $out;
        }

        if (is_object($headers) && $headers instanceof \ArrayAccess) {
            foreach ($headers as $k => $v) {
                $out[strtolower((string) $k)] = is_array($v) ? implode(', ', $v) : (string) $v;
            }
        }

        return $out;
    }

    private static function extractLiteSpeedCachedAt(string $html): string
    {
        $probe = $html;

        // Scan the tail because LiteSpeed comments are usually at the end of HTML.
        if (strlen($probe) > 200000) {
            $probe = substr($probe, -200000);
        }

        if (preg_match('/<!--\s*Page cached by LiteSpeed Cache[^>]* on ([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})\s*-->/i', $probe, $m)) {
            return trim((string) $m[1]);
        }

        return '';
    }

    /**
     * Determine why targeted Brotli warmup should be skipped for this URL.
     *
     * @param string $url URL being warmed.
     * @param array<string,mixed> $brotliSupport Brotli-profile support result.
     * @return string Empty string means "run Brotli pass", otherwise skip reason.
     */
    private static function targetedBrotliSkipReason(string $url, array $brotliSupport): string
    {
        if (Settings::DEFAULT_TARGETED_BROTLI_WARMUP_ENABLED !== true) {
            return 'feature_disabled';
        }

        $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
        if ($scheme !== 'https') {
            return 'non_https_url';
        }

        if (empty($brotliSupport['supported'])) {
            return (string) ($brotliSupport['reason'] ?? 'wordpress_http_api_unavailable');
        }

        return '';
    }

    /**
     * Default result fields for the optional targeted Brotli pass.
     *
     * @return array<string,mixed> Default Brotli result payload.
     */
    private static function brotliDefaults(): array
    {
        return [
            'brotli_attempted' => false,
            'brotli_ok' => false,
            'brotli_code' => 0,
            'brotli_ms' => 0,
            'brotli_error' => '',
            'brotli_skip_reason' => '',
            'brotli_redirects' => 0,
            'brotli_final_url' => '',
            'brotli_content_encoding' => '',
            'brotli_vary' => '',
            'brotli_etag' => '',
            'brotli_cache_ctrl' => '',
            'brotli_age' => '',
            'brotli_lscache' => '',
            'brotli_lscache_ctrl' => '',
            'brotli_lscache_vary' => '',
            'transport_used' => '',
            'fallback_used' => false,
            'fallback_reason' => '',
            'wp_attempted' => false,
            'wp_ms' => 0,
        ];
    }
}
