<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\Integrations\InvalidatorAdapterGate;
use Ekesto\CacheWarmup\Integrations\InvalidatorMultilingualBridge;
use Ekesto\CacheWarmup\Multilingual\AdapterFactory;
use Ekesto\CacheWarmup\Multilingual\LanguageAdapterInterface;
use Ekesto\CacheWarmup\Multilingual\LanguageContextSwitcherInterface;
use Ekesto\CacheWarmup\Settings;
use Ekesto\CacheWarmup\WarmupRuntime;
use Ekesto\CacheWarmup\Settings\Repository;

/**
 * Warmup queue state management.
 * Stores pending URLs and cursor in a single option.
 */
final class Queue
{
    /** Option storing the active queue state. */
    private const OPTION_STATE = 'ekesto_cache_warmup_state';

    /** Option storing the run-scoped preparing buffer for late targeted URLs. */
    private const OPTION_PREPARING_BUFFER = 'ekesto_cache_warmup_preparing_buffer';

    /** Option storing targeted work received while the runner owns the active run. */
    private const OPTION_TARGETED_BUFFER = 'ekesto_cache_warmup_targeted_buffer';

    /** Short lease serializing targeted buffer writes with runner checkpoints. */
    private const OPTION_TARGETED_MUTATION_LOCK = 'ekesto_cache_warmup_targeted_mutation_lock';

    /** Maximum lifetime of one targeted queue mutation lease. */
    private const TARGETED_MUTATION_LOCK_TTL = 5;

    /** Maximum exact correlation history retained for one targeted run. */
    private const MAX_CORRELATION_HISTORY = 1024;

    /** Planning snapshot version for async full preparation. */
    private const PLAN_VERSION_ASYNC_FULL_V2 = 'async_full_v2';

    /**
     * Option key used as a cross-process lease lock to prevent concurrent runners.
     *
     * Stored value shape:
     * - token: string unique owner token
     * - expires_at: int unix timestamp
     *
     * @var string
     */
    private const OPTION_RUN_LOCK = 'ekesto_cache_warmup_run_lock';

    /**
     * During upgrades/maintenance, some cache plugins (e.g. LiteSpeed) fire purge_all.
     * That creates duplicate/restarted full warmups.
     *
     * We mark a short "upgrade window" so purge_all triggers are ignored briefly.
     */
    private const UPGRADE_WINDOW_TRANSIENT = 'ekesto_ecw_upgrade_window_until';

    /**
     * Return the normalized empty queue shape.
     *
     * @return array<string,mixed>
     */
    private static function defaultState(): array
    {
        return [
            'active' => false,
            'status' => 'idle',
            'last_activity' => time(),
            'run_id' => '',
            'mode' => '',
            'trigger' => '',
            'started_at' => '',
            'finished_at' => '',
            'urls' => [],
            'total_urls' => 0,
            'cursor' => 0,
            'warmed' => 0,
            'failed' => 0,
            'errors' => [],
            'targeted_update_count' => 0,
            'seen_correlation_hashes' => [],
            'correlation_history_saturated' => false,
            'pending_priority_urls' => [],
            'intent_signature' => '',
            'intent_trigger_family' => '',
            'intent_hashes' => [],
            'intent_inputs' => [],
            'prepare_source' => '',
            'coalesced_full' => self::defaultCoalescedFullIntent(),
        ];
    }

    /**
     * Return the normalized empty coalesced-full-intent shape.
     *
     * @return array{has_intent:bool,latest_trigger:string,latest_meta:array<string,mixed>,updated_at:int}
     */
    private static function defaultCoalescedFullIntent(): array
    {
        return [
            'has_intent' => false,
            'latest_trigger' => '',
            'latest_meta' => [],
            'updated_at' => 0,
        ];
    }

    /**
     * Return the normalized empty preparing-buffer shape.
     *
     * @return array{run_id:string,urls:array<int,string>}
     */
    private static function defaultPreparingBuffer(): array
    {
        return [
            'run_id' => '',
            'urls' => [],
        ];
    }

    /**
     * Return the normalized empty targeted-intake buffer shape.
     *
     * @return array<string,mixed>
     */
    private static function defaultTargetedBuffer(): array
    {
        return [
            'run_id' => '',
            'urls' => [],
            'trusted_set' => [],
            'seen_correlation_hashes' => [],
            'targeted_update_count' => 0,
            'correlation_history_saturated' => false,
            'promote_trigger' => '',
            'promote_meta' => [],
        ];
    }

    /**
     * True if WP is currently in maintenance mode (core upgrader creates .maintenance).
     */
    private static function isWpMaintenanceContext(): bool
    {
        // wp_is_maintenance_mode() exists in modern WP and is the cleanest signal.
        if (function_exists('wp_is_maintenance_mode') && wp_is_maintenance_mode()) {
            return true;
        }

        // Fallback: core uses ABSPATH/.maintenance during upgrades.
        if (file_exists(ABSPATH . '.maintenance')) {
            return true;
        }

        // Sometimes set during installs/upgrades.
        if (defined('WP_INSTALLING') && WP_INSTALLING) {
            return true;
        }

        return false;
    }

    /**
     * Returns true if we recently observed an upgrade-like trigger.
     * Uses a transient with an expiry timestamp to suppress purge_all storms.
     */
    private static function isInUpgradeWindow(): bool
    {
        $until = (int) get_transient(self::UPGRADE_WINDOW_TRANSIENT);
        return ($until > time());
    }

    /**
     * Extend the upgrade suppression window.
     *
     * @param int $seconds How long purge_all should be ignored after upgrade-like triggers.
     */
    private static function markUpgradeWindow(int $seconds): void
    {
        $seconds = max(10, $seconds);

        $until = (int) get_transient(self::UPGRADE_WINDOW_TRANSIENT);
        $now = time();

        $newUntil = max($until, $now) + $seconds;

        // Store as seconds until expiration (WP transients expect TTL here)
        set_transient(self::UPGRADE_WINDOW_TRANSIENT, $newUntil, $newUntil - $now);
    }

    /**
     * Get current queue state.
     *
     * @return array<string,mixed>
     */
    public static function get(): array
    {
        $state = get_option(self::OPTION_STATE, []);
        return self::normalizeState(is_array($state) ? $state : []);
    }

    /**
     * Persist queue state.
     *
     * @param array<string,mixed> $state
     */
    public static function set(array $state): void
    {
        update_option(self::OPTION_STATE, self::normalizeState($state), false);
    }

    /**
     * Persist queue state only when the current queue still belongs to the expected run.
     *
     * @param string $runId Expected active run identifier.
     * @param array<string,mixed> $state Queue state to persist.
     * @return bool True when the state was persisted for the expected run.
     */
    public static function setIfRunOwned(string $runId, array $state): bool
    {
        global $wpdb;

        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        // Read the exact stored value so the ownership check and replacement can
        // be coupled by a compare-and-swap at the database boundary.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Run ownership requires the authoritative serialized option value.
        $storedValue = $wpdb->get_var($wpdb->prepare(
            "SELECT option_value FROM `{$wpdb->options}` WHERE option_name = %s LIMIT 1",
            self::OPTION_STATE
        ));
        $storedState = is_string($storedValue) ? maybe_unserialize($storedValue) : [];
        $currentState = self::normalizeState(is_array($storedState) ? $storedState : []);
        $currentRunId = (string) ($currentState['run_id'] ?? '');
        if ($currentRunId !== $runId) {
            Debug::log('Queue::setIfRunOwned skipped', [
                'expected_run_id' => $runId,
                'current_run_id' => $currentRunId,
            ]);
            return false;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic run-owned queue transition prevents superseded runners from restoring stale state.
        $updated = $wpdb->update(
            $wpdb->options,
            ['option_value' => maybe_serialize(self::normalizeState($state))],
            [
                'option_name' => self::OPTION_STATE,
                'option_value' => $storedValue,
            ],
            ['%s'],
            ['%s', '%s']
        );
        self::forgetOptionRuntimeCache(self::OPTION_STATE);

        if ($updated !== 1) {
            Debug::log('Queue::setIfRunOwned skipped', [
                'expected_run_id' => $runId,
                'current_run_id' => $currentRunId,
                'reason' => 'concurrent_queue_change',
            ]);
            return false;
        }

        return true;
    }

    /**
     * Clear active queue state, but keep a small persisted footprint.
     *
     * Why:
     * - The diagnostics panel should still show the last time the queue made progress,
     *   even after a run has finished and the queue was cleared.
     *
     * Important:
     * - We set active=false + status=idle, so Runner::run() will not process anything.
     * - This avoids regressions where other code expects "no active queue".
     */
    public static function clear(): void
    {
        $prev = self::get();

        $lastActivity = isset($prev['last_activity']) ? (int) $prev['last_activity'] : 0;
        if ($lastActivity <= 0) {
            $lastActivity = time();
        }

        $state = self::defaultState();
        $state['last_activity'] = $lastActivity;
        self::set($state);
        self::clearPreparingBuffer();
        self::clearTargetedBuffer();
    }

    /**
     * Clear queue state only when the current queue still belongs to the expected run.
     *
     * @param string $runId Expected active run identifier.
     * @return bool True when the queue was cleared for the expected run.
     */
    public static function clearIfRunOwned(string $runId): bool
    {
        $runId = trim($runId);
        $currentState = self::get();
        $currentRunId = (string) ($currentState['run_id'] ?? '');
        if ($runId === '' || $currentRunId !== $runId) {
            Debug::log('Queue::clearIfRunOwned skipped', [
                'expected_run_id' => $runId,
                'current_run_id' => $currentRunId,
            ]);
            return false;
        }

        $lastActivity = isset($currentState['last_activity']) ? (int) $currentState['last_activity'] : 0;
        if ($lastActivity <= 0) {
            $lastActivity = time();
        }

        $state = self::defaultState();
        $state['last_activity'] = $lastActivity;

        update_option(self::OPTION_STATE, self::normalizeState($state), false);
        self::clearPreparingBufferIfRunOwned($runId);
        self::clearTargetedBufferIfRunOwned($runId);

        return true;
    }

    /**
     * Remove one canonical URL from active queue storage and the preparing buffer.
     *
     * @param string $url Raw or canonical URL to remove.
     * @param string $reason Optional diagnostic reason.
     * @return array{removed:int,state_removed:int,buffer_removed:int,canonical_url:string}
     */
    public static function removeUrl(string $url, string $reason = ''): array
    {
        $canonicalUrl = Sitemap::canonicalizeUrl($url);
        if ($canonicalUrl === '') {
            return [
                'removed' => 0,
                'state_removed' => 0,
                'buffer_removed' => 0,
                'canonical_url' => '',
            ];
        }

        $state = self::get();
        $runId = trim((string) ($state['run_id'] ?? ''));
        $stateRemoved = 0;
        $stateChanged = false;
        $oldTotal = self::getTotalUrls($state);
        $activeQueueHadUrl = false;

        if (isset($state['urls']) && is_array($state['urls'])) {
            $currentUrls = array_values(array_map('strval', $state['urls']));
            $cursor = max(0, (int) ($state['cursor'] ?? 0));
            $nextUrls = [];
            $removedBeforeCursor = 0;

            foreach ($currentUrls as $index => $currentUrl) {
                if ($currentUrl === $canonicalUrl) {
                    $activeQueueHadUrl = true;
                    $stateRemoved++;
                    if ($index < $cursor) {
                        $removedBeforeCursor++;
                    }
                    continue;
                }

                $nextUrls[] = $currentUrl;
            }

            if ($stateRemoved > 0) {
                $state['urls'] = $nextUrls;
                $state['cursor'] = max(0, $cursor - $removedBeforeCursor);
                $stateChanged = true;
            }
        }

        if (isset($state['pending_priority_urls']) && is_array($state['pending_priority_urls'])) {
            $pendingUrls = self::normalizeUrlList($state['pending_priority_urls']);
            $nextPendingUrls = array_values(array_filter(
                $pendingUrls,
                static fn(string $currentUrl): bool => $currentUrl !== $canonicalUrl
            ));
            $removedPending = count($pendingUrls) - count($nextPendingUrls);

            if ($removedPending > 0) {
                $stateRemoved += $removedPending;
                $state['pending_priority_urls'] = $nextPendingUrls;
                $stateChanged = true;
            }
        }

        if ($stateChanged) {
            $state['total_urls'] = max(
                count(self::normalizeUrlList($state['urls'] ?? [])),
                count(self::normalizeUrlList($state['pending_priority_urls'] ?? []))
            );
            $state['last_activity'] = time();

            if ($runId !== '') {
                self::setIfRunOwned($runId, $state);
            } else {
                self::set($state);
            }

            $newTotal = self::getTotalUrls($state);
            if ($runId !== '' && $newTotal !== $oldTotal) {
                Logger::updateTotalUrls($runId, $newTotal);
            }
        }

        $buffer = self::getPreparingBuffer();
        $bufferUrls = self::normalizeUrlList($buffer['urls'] ?? []);
        $preparingBufferHadUrl = in_array($canonicalUrl, $bufferUrls, true);
        $nextBufferUrls = array_values(array_filter(
            $bufferUrls,
            static fn(string $currentUrl): bool => $currentUrl !== $canonicalUrl
        ));
        $bufferRemoved = count($bufferUrls) - count($nextBufferUrls);

        if ($bufferRemoved > 0) {
            $buffer['urls'] = $nextBufferUrls;
            self::setPreparingBuffer($buffer);

            $currentState = self::get();
            $currentRunId = trim((string) ($currentState['run_id'] ?? ''));
            $bufferRunId = trim((string) ($buffer['run_id'] ?? ''));
            if (
                self::isPreparingFullState($currentState)
                && $currentRunId !== ''
                && $currentRunId === $bufferRunId
            ) {
                $totalUrls = count(self::mergePreparingPendingUrls(
                    self::normalizeUrlList($currentState['pending_priority_urls'] ?? []),
                    $nextBufferUrls
                ));
                $previousTotal = self::getTotalUrls($currentState);
                $currentState['total_urls'] = $totalUrls;
                $currentState['last_activity'] = time();
                if (self::setIfRunOwned($currentRunId, $currentState) && $totalUrls !== $previousTotal) {
                    Logger::updateTotalUrls($currentRunId, $totalUrls);
                }
            }
        }

        $removed = $stateRemoved + $bufferRemoved;
        Debug::verbose('Queue::removeUrl trace', [
            'url' => $canonicalUrl,
            'reason' => sanitize_key($reason),
            'result' => $removed > 0 ? 'found' : 'not_found',
            'active_queue_had_url' => $activeQueueHadUrl,
            'preparing_buffer_had_url' => $preparingBufferHadUrl,
            'state_removed' => $stateRemoved,
            'buffer_removed' => $bufferRemoved,
        ]);

        return [
            'removed' => $removed,
            'state_removed' => $stateRemoved,
            'buffer_removed' => $bufferRemoved,
            'canonical_url' => $canonicalUrl,
        ];
    }

    /**
     * Whether a queue is currently active.
     */
    public static function isActive(): bool
    {
        return self::hasActiveRun(self::get());
    }

    /**
     * Whether the provided queue state represents active work.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @param string|null $mode Optional mode constraint.
     */
    public static function hasActiveRun(?array $state = null, ?string $mode = null): bool
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();

        if (empty($state['active'])) {
            return false;
        }

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

        if ($mode !== null && (string) ($state['mode'] ?? '') !== $mode) {
            return false;
        }

        $status = (string) ($state['status'] ?? '');
        if (!in_array($status, ['preparing', 'running'], true)) {
            return false;
        }

        if (($state['mode'] ?? '') === 'full' && $status === 'preparing') {
            return true;
        }

        return isset($state['urls']) && is_array($state['urls']) && $state['urls'] !== [];
    }

    /**
     * Whether manual "run next batch now" is currently actionable.
     *
     * Actionable means queued work remains (cursor < total_urls) and no runner lock is held.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return bool
     */
    public static function hasPendingWork(?array $state = null): bool
    {
        $state = self::normalizeState(is_array($state) ? $state : self::get());

        $cursor = max(0, (int) ($state['cursor'] ?? 0));
        $total  = max(0, (int) ($state['total_urls'] ?? 0));

        if ($total < 1) {
            return false;
        }

        return $cursor < $total && !self::isRunLocked();
    }

    /**
     * Whether the currently stored queue state still belongs to the expected run.
     *
     * @param string $runId Expected active run identifier.
     * @return bool True when the current queue run_id matches.
     */
    public static function isCurrentRun(string $runId): bool
    {
        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        $state = self::get();
        $currentRunId = (string) ($state['run_id'] ?? '');
        return $currentRunId === $runId;
    }

    /**
     * Whether the queue is an active full run in preparing state.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return bool
     */
    public static function isPreparingFullState(?array $state = null): bool
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();
        return self::hasActiveRun($state, 'full') && (($state['status'] ?? '') === 'preparing');
    }

    /**
     * Whether the queue is an active full run with materialized URLs.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return bool
     */
    public static function isRunningFullState(?array $state = null): bool
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();
        return self::hasActiveRun($state, 'full') && (($state['status'] ?? '') === 'running');
    }

    /**
     * Return the authoritative total URL count for the provided state.
     *
     * For preparing full runs this may reflect buffered invalidation URLs even before
     * the final `urls` list is materialized.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return int
     */
    public static function getTotalUrls(?array $state = null): int
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();

        if (self::isPreparingFullState($state)) {
            return max(0, count(self::getPreparingPendingUrls($state)));
        }

        if (self::hasActiveRun($state, 'single')) {
            $buffer = self::getTargetedBuffer();
            if ((string) ($buffer['run_id'] ?? '') === (string) ($state['run_id'] ?? '')) {
                return count($state['urls']) + count($buffer['urls']);
            }
        }

        return max(0, (int) ($state['total_urls'] ?? 0));
    }

    /**
     * Return runner-concurrent targeted URLs buffered for the active run.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return array<int,string>
     */
    public static function getTargetedBufferedUrls(?array $state = null): array
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();
        if (!self::hasActiveRun($state, 'single')) {
            return [];
        }

        $buffer = self::getTargetedBuffer();
        if ((string) ($buffer['run_id'] ?? '') !== (string) ($state['run_id'] ?? '')) {
            return [];
        }

        return self::normalizeUrlList($buffer['urls'] ?? []);
    }

    /**
     * Return effective targeted update metadata including runner-concurrent intake.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return array{update_count:int,history_saturated:bool}
     */
    public static function getTargetedUpdateMeta(?array $state = null): array
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();
        $updateCount = max(0, (int) ($state['targeted_update_count'] ?? 0));
        $historySaturated = !empty($state['correlation_history_saturated']);

        if (self::hasActiveRun($state, 'single')) {
            $buffer = self::getTargetedBuffer();
            if ((string) ($buffer['run_id'] ?? '') === (string) ($state['run_id'] ?? '')) {
                $updateCount = max($updateCount, (int) ($buffer['targeted_update_count'] ?? 0));
                $historySaturated = $historySaturated || !empty($buffer['correlation_history_saturated']);
            }
        }

        return [
            'update_count' => $updateCount,
            'history_saturated' => $historySaturated,
        ];
    }

    /**
     * Return the preparing-only pending URLs for the active full run.
     *
     * New writes live in the companion option. The legacy queue-state
     * `pending_priority_urls` field remains read-only compatibility for runs that were
     * already persisted before the companion buffer existed.
     *
     * @param array<string,mixed>|null $state Optional queue state snapshot.
     * @return array<int,string> Preparing-only pending URLs.
     */
    public static function getPreparingPendingUrls(?array $state = null): array
    {
        $state = is_array($state) ? self::normalizeState($state) : self::get();
        if (!self::isPreparingFullState($state)) {
            return [];
        }

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

        $legacyUrls = self::normalizeUrlList($state['pending_priority_urls'] ?? []);
        $buffer = self::getPreparingBuffer();
        $bufferRunId = trim((string) ($buffer['run_id'] ?? ''));

        if ($bufferRunId !== '' && $bufferRunId !== $runId) {
            if (self::isCurrentRun($runId)) {
                self::clearPreparingBuffer();
            }
            $buffer = self::defaultPreparingBuffer();
        }

        return self::mergePreparingPendingUrls(
            $legacyUrls,
            self::normalizeUrlList($buffer['urls'] ?? [])
        );
    }

    /**
     * Initialize the run-scoped preparing buffer for a new full run.
     *
     * @param string $runId Active full run identifier.
     * @return void
     */
    public static function primePreparingBuffer(string $runId): void
    {
        $runId = trim($runId);
        if ($runId === '') {
            self::clearPreparingBuffer();
            return;
        }

        self::setPreparingBuffer([
            'run_id' => $runId,
            'urls' => [],
        ]);
    }

    /**
     * Remove the companion preparing buffer.
     *
     * @return void
     */
    public static function clearPreparingBuffer(): void
    {
        delete_option(self::OPTION_PREPARING_BUFFER);
    }

    /**
     * Remove the companion preparing buffer only when it still belongs to the expected run.
     *
     * @param string $runId Expected active run identifier.
     * @return bool True when the buffer was cleared for the expected run.
     */
    public static function clearPreparingBufferIfRunOwned(string $runId): bool
    {
        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        $buffer = self::getPreparingBuffer();
        $bufferRunId = trim((string) ($buffer['run_id'] ?? ''));
        if ($bufferRunId !== $runId) {
            return false;
        }

        self::clearPreparingBuffer();
        return true;
    }

    /**
     * Drop a stale preparing buffer when it no longer matches the active run.
     *
     * @param string $runId Active queue run identifier.
     * @return bool True when a mismatched companion buffer was cleared.
     */
    public static function clearPreparingBufferIfRunMismatch(string $runId): bool
    {
        $runId = trim($runId);
        if ($runId === '' || !self::isCurrentRun($runId)) {
            return false;
        }

        $buffer = self::getPreparingBuffer();
        $bufferRunId = trim((string) ($buffer['run_id'] ?? ''));

        if ($bufferRunId === '' || $bufferRunId === $runId) {
            return false;
        }

        self::clearPreparingBuffer();
        return true;
    }

    /**
     * Buffer one targeted URL for an active preparing full run.
     *
     * @param array<string,mixed> $state Active queue state.
     * @param string $url Canonical target URL.
     * @return array{did_change:bool,log_result:string,total_urls:int}
     */
    public static function bufferPreparingSingle(array $state, string $url): array
    {
        $state = self::normalizeState($state);
        if (!self::isPreparingFullState($state)) {
            return [
                'did_change' => false,
                'log_result' => 'queued_buffer_preparing',
                'total_urls' => self::getTotalUrls($state),
            ];
        }

        $runId = trim((string) ($state['run_id'] ?? ''));
        if ($runId === '') {
            return [
                'did_change' => false,
                'log_result' => 'queued_buffer_preparing',
                'total_urls' => self::getTotalUrls($state),
            ];
        }

        self::clearPreparingBufferIfRunMismatch($runId);

        $buffer = self::getPreparingBuffer();
        if (trim((string) ($buffer['run_id'] ?? '')) !== $runId) {
            $buffer = [
                'run_id' => $runId,
                'urls' => [],
            ];
        }

        $pendingUrls = self::normalizeUrlList($buffer['urls'] ?? []);
        $existingIndex = array_search($url, $pendingUrls, true);
        $didChange = false;
        $logResult = 'queued_buffer_preparing';

        if ($existingIndex === false) {
            array_unshift($pendingUrls, $url);
            $didChange = true;
            $logResult = 'queued_buffer_preparing_new';
        } elseif ($existingIndex !== 0) {
            array_splice($pendingUrls, $existingIndex, 1);
            array_unshift($pendingUrls, $url);
            $didChange = true;
            $logResult = 'queued_buffer_preparing_move';
        }

        if ($didChange) {
            $buffer['urls'] = $pendingUrls;
            self::setPreparingBuffer($buffer);

            $state['total_urls'] = count(self::mergePreparingPendingUrls(
                self::normalizeUrlList($state['pending_priority_urls'] ?? []),
                $pendingUrls
            ));
            $state['last_activity'] = time();
            self::set($state);
        }

        return [
            'did_change' => $didChange,
            'log_result' => $logResult,
            'total_urls' => count(self::mergePreparingPendingUrls(
                self::normalizeUrlList($state['pending_priority_urls'] ?? []),
                $pendingUrls
            )),
        ];
    }

    /**
     * Drain any same-run preparing-buffer URLs into the running queue after prepare commit.
     *
     * This closes the final commit window where a concurrent targeted enqueue may still
     * have written into the companion buffer just before status flipped to `running`.
     *
     * @param string $runId Active run identifier.
     * @param int $maxUrls Configured run cap.
     * @param int $existingPendingCount Number of already-committed pending URLs at the queue head.
     * @return void
     */
    public static function drainPreparingBufferIntoRunningQueue(string $runId, int $maxUrls, int $existingPendingCount = 0): void
    {
        $runId = trim($runId);
        if ($runId === '') {
            self::clearPreparingBuffer();
            return;
        }

        $buffer = self::getPreparingBuffer();
        $bufferRunId = trim((string) ($buffer['run_id'] ?? ''));

        if ($bufferRunId === '' || $bufferRunId !== $runId) {
            if ($bufferRunId !== '' && self::isCurrentRun($runId)) {
                self::clearPreparingBuffer();
            }
            return;
        }

        $pendingUrls = self::normalizeUrlList($buffer['urls'] ?? []);
        if ($pendingUrls === []) {
            self::clearPreparingBufferIfRunOwned($runId);
            return;
        }

        $state = self::get();
        if (
            !self::isRunningFullState($state)
            || (string) ($state['run_id'] ?? '') !== $runId
            || !isset($state['urls'])
            || !is_array($state['urls'])
        ) {
            self::clearPreparingBufferIfRunOwned($runId);
            return;
        }

        $currentUrls = array_values(array_map('strval', $state['urls']));
        $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
        $cursor = max(0, min($cursor, count($currentUrls)));
        $existingPendingCount = max(0, min($existingPendingCount, count($currentUrls)));
        $insertUrls = [];
        foreach ($pendingUrls as $url) {
            if ($url === '') {
                continue;
            }

            $existingIndex = array_search($url, $currentUrls, true);
            if ($existingIndex !== false) {
                array_splice($currentUrls, $existingIndex, 1);
                if ($existingIndex < $cursor) {
                    $cursor = max(0, $cursor - 1);
                }
            }

            if (in_array($url, $insertUrls, true)) {
                continue;
            }

            $insertUrls[] = $url;
        }

        if ($insertUrls !== []) {
            $maxUrls = max(1, $maxUrls);
            array_splice($currentUrls, $cursor, 0, $insertUrls);

            $plannedCount = max(0, count($state['urls']) - $existingPendingCount);
            $excess = max(0, count($currentUrls) - $maxUrls);
            $dropPlanned = min($plannedCount, $excess);

            if ($dropPlanned > 0) {
                $currentUrls = array_slice($currentUrls, 0, count($currentUrls) - $dropPlanned);
            }

            $state['urls'] = $currentUrls;
            $state['total_urls'] = count($currentUrls);
            $state['last_activity'] = time();
            if (!self::setIfRunOwned($runId, $state)) {
                return;
            }
            Logger::updateTotalUrls($runId, count($currentUrls));
        }

        self::clearPreparingBufferIfRunOwned($runId);
    }

    /**
     * Refresh last_activity for a still-active preparing full run.
     *
     * The guarded re-read avoids overwriting a newer restart while the runner is
     * building a plan in memory.
     *
     * @param string $runId Active run identifier.
     * @param string $intentSignature Intent signature captured before preparation.
     * @return bool True when the queue still matched and was touched.
     */
    public static function touchPreparingActivity(string $runId, string $intentSignature): bool
    {
        $runId = trim($runId);
        $intentSignature = trim($intentSignature);

        if ($runId === '' || $intentSignature === '') {
            return false;
        }

        $state = self::get();
        if (
            !self::isPreparingFullState($state)
            || (string) ($state['run_id'] ?? '') !== $runId
            || (string) ($state['intent_signature'] ?? '') !== $intentSignature
        ) {
            return false;
        }

        $state['last_activity'] = time();
        return self::setIfRunOwned($runId, $state);
    }

    /**
     * Coalesce an incoming full intent while the active full run is still preparing.
     *
     * @param string $runId Active preparing full run identifier.
     * @param string $trigger Incoming trigger key.
     * @param array<string,mixed> $meta Trigger metadata.
     * @return bool True when intent was coalesced for this run.
     */
    public static function coalescePreparingFullIntent(string $runId, string $trigger, array $meta = []): bool
    {
        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        $state = self::get();
        if (
            !self::isPreparingFullState($state)
            || (string) ($state['run_id'] ?? '') !== $runId
        ) {
            return false;
        }

        $coalesced = self::normalizeCoalescedFullIntent($state['coalesced_full'] ?? []);
        $previousTrigger = isset($coalesced['latest_trigger']) ? (string) $coalesced['latest_trigger'] : '';
        if (!empty($coalesced['has_intent'])) {
            Debug::log('full_run_coalesced_overwrite', [
                'run_id' => $runId,
                'previous_trigger' => $previousTrigger,
                'new_trigger' => $trigger,
            ]);
        }

        if (!empty($meta['coalesced_replay'])) {
            // Prevent re-coalescing loops or recursive replay storms.
            unset($meta['coalesced_replay']);
        }

        $state['coalesced_full'] = [
            'has_intent' => true,
            'latest_trigger' => sanitize_text_field($trigger),
            'latest_meta' => is_array($meta) ? $meta : [],
            'updated_at' => time(),
        ];
        $state['last_activity'] = time();

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

        Logger::appendDetails($runId, [
            'event' => 'full_run_coalesced',
            'reason' => 'preparing_active',
            'incoming_trigger' => $trigger,
        ]);

        return true;
    }

    /**
     * Consume and clear coalesced full intent only if the run is still owned.
     *
     * @param string $runId Expected active run identifier.
     * @return array{has_intent:bool,latest_trigger:string,latest_meta:array<string,mixed>,updated_at:int}
     */
    public static function consumeCoalescedFullIntentIfRunOwned(string $runId): array
    {
        $empty = self::defaultCoalescedFullIntent();
        $runId = trim($runId);
        if ($runId === '') {
            return $empty;
        }

        $state = self::get();
        if ((string) ($state['run_id'] ?? '') !== $runId) {
            return $empty;
        }

        $coalesced = self::normalizeCoalescedFullIntent($state['coalesced_full'] ?? []);
        if (empty($coalesced['has_intent'])) {
            return $empty;
        }

        $state['coalesced_full'] = $empty;
        if (!self::setIfRunOwned($runId, $state)) {
            return $empty;
        }

        return $coalesced;
    }

    /**
     * Enqueue a full warmup intent.
     *
     * Full triggers persist only the deterministic planning snapshot. The runner
     * owns preparation and later materializes the final URL list under the lease lock.
     *
     * @param string $trigger Trigger key (e.g. "upgrade:update", "purge_all").
     */
    public static function enqueueFullIntent(string $trigger, array $meta = []): void
    {
        Debug::log('Queue::enqueueFullIntent called', ['trigger' => $trigger]);

        // 1) Gate full intent scheduling through ECI adapter authority.
        if (!InvalidatorAdapterGate::hasOperationalAdapter([
            'caller' => 'ecw_queue',
            'reason' => 'warmup',
        ])) {
            Debug::warn('Queue::enqueueFullIntent bail: no operational adapter via ECI authority');
            return;
        }

        // 1.1) Suppress purge-triggered full warmups during WP maintenance/upgrade windows.
//      Prevents the “purge_all run starts → upgrade run restarts it 2s later” pattern.
        if ($trigger === 'purge_all' && self::isWpMaintenanceContext()) {
            Debug::warn('Queue::enqueueFullIntent skip: purge_all during WP maintenance/upgrades');
            return;
        }

        // 1.2) De-dupe upgrade vs purge_all storms.
        //
        // If an upgrade/maintenance trigger happens, some cache plugins also emit purge_all.
        // Warming once is enough — ignore purge_all briefly to avoid 2s "restarted" noise.
        $isPurgeAll = ($trigger === 'purge_all');
        $isUpgradeLike = (str_starts_with($trigger, 'upgrade:') || str_starts_with($trigger, 'maintenance:'));

        // If we are in an upgrade window and a purge_all arrives, drop it.
        if ($isPurgeAll && self::isInUpgradeWindow()) {
            Debug::warn('Queue::enqueueFullIntent: skip purge_all (upgrade window active)');
            return;
        }

        // If this is an upgrade/maintenance trigger, start/extend the upgrade window now.
        // This ensures later purge_all calls (even in other requests) get ignored briefly.
        // Duration: 30s covers automatic cache plugin side effects during upgrade process.
        // Longer user-initiated purges (manual admin clicks) will not be suppressed.
        if ($isUpgradeLike) {
            self::markUpgradeWindow(30);
        }

        $intent = self::buildFullIntentSnapshot($trigger);
        $intentInputs = $intent['intent_inputs'];
        $intentSignature = (string) $intent['intent_signature'];
        $intentTriggerFamily = (string) $intent['intent_trigger_family'];
        $intentHashes = $intent['intent_hashes'];

        // 2) Load current state and recover stale runs first.
        $state = self::get();
        self::recoverIfStale($state);

        // 3) Equivalent non-invalidation full intents reuse the active run instead of restarting it.
        if (
            self::hasActiveRun($state, 'full')
            && $intentTriggerFamily !== 'cache_invalidation'
            && (string) ($state['intent_signature'] ?? '') === $intentSignature
        ) {
            if ($isUpgradeLike) {
                self::markUpgradeWindow(30);
            }

            Debug::log('Queue::enqueueFullIntent skip restart: active full intent matches', [
                'trigger' => $trigger,
                'intent_signature' => $intentSignature,
                'status' => (string) ($state['status'] ?? ''),
            ]);

            Runner::scheduleSoon();
            return;
        }

        // 4) If a single run is active, finalize it before starting a full intent.
        if (self::hasActiveRun($state, 'single')) {
            $oldRunId = (string) $state['run_id'];
            $oldTotal = self::getTotalUrls($state);

            Logger::finishRun(
                $oldRunId,
                'restarted',
                $oldTotal,
                (int) ($state['warmed'] ?? 0),
                (int) ($state['failed'] ?? 0),
                __('Superseded by full trigger.', 'cache-autopilot')
            );

            Debug::log('Queue::enqueueFullIntent restart: previous single run marked as restarted', ['run_id' => $oldRunId]);

            self::clear();
            $state = [];
        }

        // 5) For active full runs:
        //    - preparing => coalesce (no restart)
        //    - running => restart (existing behavior)
        if (self::hasActiveRun($state, 'full')) {
            $oldRunId = (string) $state['run_id'];
            $oldStatus = (string) ($state['status'] ?? '');

            if ($oldStatus === 'preparing') {
                $didCoalesce = self::coalescePreparingFullIntent($oldRunId, $trigger, $meta);

                Debug::log('full_run_coalesced', [
                    'run_id' => $oldRunId,
                    'reason' => 'preparing_active',
                    'incoming_trigger' => $trigger,
                    'coalesced' => $didCoalesce,
                ]);

                Runner::scheduleSoon();
                return;
            }

            $oldTotal = self::getTotalUrls($state);

            Logger::finishRun(
                $oldRunId,
                'restarted',
                $oldTotal,
                (int) ($state['warmed'] ?? 0),
                (int) ($state['failed'] ?? 0),
                __('Restarted by a new full trigger.', 'cache-autopilot')
            );

            Debug::log('Queue::enqueueFullIntent restart: previous full run marked as restarted', [
                'run_id' => $oldRunId,
                'status' => $oldStatus,
            ]);

            self::clear();
            $state = [];
        }

        // 6) Start a new full intent. Runner materializes `urls` later under the lease lock.
        $runId = self::newRunId();
        $nowUtc = gmdate('Y-m-d H:i:s');
        self::clearTargetedBuffer();

        self::set([
            'active' => true,
            'status' => 'preparing',
            'run_id' => $runId,
            'mode' => 'full',
            'trigger' => $trigger,
            'started_at' => $nowUtc,
            'urls' => [],
            'total_urls' => 0,
            'cursor' => 0,
            'warmed' => 0,
            'failed' => 0,
            'errors' => [],
            'last_activity' => time(),
            'pending_priority_urls' => [],
            'intent_signature' => $intentSignature,
            'intent_trigger_family' => $intentTriggerFamily,
            'intent_hashes' => $intentHashes,
            'intent_inputs' => $intentInputs,
            'prepare_source' => 'intent',
            'coalesced_full' => self::defaultCoalescedFullIntent(),
        ]);
        self::primePreparingBuffer($runId);

        // 7) Create one provisional log row. Runner later transitions it to running in-place.
        $startMeta = [
            'source' => 'intent',
            'plan_version' => self::PLAN_VERSION_ASYNC_FULL_V2,
            'intent_signature' => $intentSignature,
            'intent_trigger_family' => $intentTriggerFamily,
            'intent_hashes' => $intentHashes,
        ];

        if (!empty($meta)) {
            $startMeta = array_merge($startMeta, $meta);
        }

        if (empty($startMeta['label_locked']) && !self::isAdapterTrigger($trigger)) {
            $startMeta = array_merge(
                $startMeta,
                self::buildPrimaryIdentity($startMeta, $trigger)
            );
        }

        Logger::startRun($runId, $trigger, 'full', 0, $startMeta, 'preparing');

        // 8) Ensure WP-Cron fires quickly.
        Runner::scheduleSoon();
    }

    /**
     * Enqueue multiple URLs in one in-memory batch while preserving per-URL queue semantics.
     *
     * @param array<int,string>    $urls Raw URL list to normalize/canonicalize.
     * @param string               $trigger Trigger key.
     * @param array<string,mixed>  $meta Optional metadata persisted on new single-run start.
     * @return void
     */
    public static function enqueueBatch(array $urls, string $trigger, array $meta = [], array $trustedUrls = []): void
    {
        $canonicalUrls = [];
        $seen = [];

        foreach ($urls as $rawUrl) {
            $rawUrl = trim((string) $rawUrl);
            if ($rawUrl === '') {
                continue;
            }

            $canonicalUrl = Sitemap::canonicalizeUrl($rawUrl);
            if ($canonicalUrl === '' || isset($seen[$canonicalUrl])) {
                continue;
            }

            $seen[$canonicalUrl] = true;
            $canonicalUrls[] = $canonicalUrl;
        }

        $trustedCanonicalSet = [];
        foreach ($trustedUrls as $rawTrusted) {
            $rawTrusted = trim((string) $rawTrusted);
            if ($rawTrusted === '') {
                continue;
            }
            $canonicalTrusted = Sitemap::canonicalizeUrl($rawTrusted);
            if ($canonicalTrusted === '' || !isset($seen[$canonicalTrusted])) {
                continue;
            }
            $trustedCanonicalSet[$canonicalTrusted] = true;
        }

        if (!InvalidatorAdapterGate::hasOperationalAdapter([
            'caller' => 'ecw_queue',
            'reason' => 'warmup',
        ])) {
            Debug::warn('Queue::enqueueBatch bail: no operational adapter via ECI authority');
            return;
        }

        $state = self::get();
        self::recoverIfStale($state);

        $maxUrls = Repository::getInt('advanced.max_urls_per_run', Settings::DEFAULT_MAX_URLS_PER_RUN);

        $didPersistState = false;
        $shouldUpdateTotal = false;
        $totalUrlsForUpdate = 0;
        $detailRows = [];
        $didQueueWork = false;
        $runnerLocked = self::isRunLocked();

        /** @var array{run_id:string,trigger:string,mode:string,total:int,meta:array<string,mixed>}|null $startRunPayload */
        $startRunPayload = null;

        if (
            $runnerLocked
            && self::hasActiveRun($state, 'single')
            && isset($state['urls'])
            && is_array($state['urls'])
        ) {
            $buffered = self::bufferIntoRunningTargetedRun(
                $state,
                $canonicalUrls,
                $trustedCanonicalSet,
                $trigger,
                $meta,
                $maxUrls
            );

            if (empty($buffered['stale_run'])) {
                return;
            }

            // The runner completed while this intake waited for the hand-off lock.
            // Route the URLs again against the authoritative post-finish state.
            $state = self::get();
            self::recoverIfStale($state);
            $runnerLocked = self::isRunLocked();
        }

        if (self::isPreparingFullState($state)) {
            $buffered = self::bufferPreparingBatch($state, $canonicalUrls);
            $state = $buffered['state'];
            $detailRows = $buffered['detail_rows'];

            if ($buffered['did_change']) {
                $didPersistState = true;
                $didQueueWork = true;
                $shouldUpdateTotal = true;
                $totalUrlsForUpdate = $buffered['total_urls'];
            }
        } elseif (
            self::isRunningFullState($state)
            && isset($state['urls']) && is_array($state['urls'])
        ) {
            $spliced = self::spliceIntoRunningFullBatch($state, $canonicalUrls, $maxUrls);
            $state = $spliced['state'];
            $detailRows = $spliced['detail_rows'];

            if ($spliced['did_change']) {
                $didPersistState = true;
                $didQueueWork = true;
            }

            if ($spliced['did_add']) {
                $shouldUpdateTotal = true;
                $totalUrlsForUpdate = $spliced['total_urls'];
            }
        } elseif (
            self::hasActiveRun($state, 'single')
            && isset($state['urls']) && is_array($state['urls'])
        ) {
            $correlationResult = self::applyTargetedCorrelationMeta($state, $meta);
            $state = $correlationResult['state'];

            $promoted = self::maybePromoteTrigger($state, $trigger, $meta);
            $merged = self::appendIntoSingleRunBatch($state, $canonicalUrls, $maxUrls);
            $state = $merged['state'];
            $detailRows = $merged['detail_rows'];

            if ($promoted || $merged['did_change'] || $correlationResult['did_change']) {
                $didPersistState = true;
            }

            if ($merged['did_add']) {
                $didQueueWork = true;
                $shouldUpdateTotal = true;
                $totalUrlsForUpdate = $merged['total_urls'];
            }
        } else {
            if ($canonicalUrls === []) {
                return;
            }

            $firstUrl = array_shift($canonicalUrls);
            if (!is_string($firstUrl) || $firstUrl === '') {
                return;
            }

            $runId = self::newRunId();
            $nowUtc = gmdate('Y-m-d H:i:s');
            self::clearTargetedBuffer();

            $state = [
                'active' => true,
                'status' => 'running',
                'run_id' => $runId,
                'mode' => 'single',
                'trigger' => $trigger,
                'started_at' => $nowUtc,
                'urls' => [$firstUrl],
                'total_urls' => 1,
                'cursor' => 0,
                'warmed' => 0,
                'failed' => 0,
                'errors' => [],
                'last_activity' => time(),
            ];

            $correlationResult = self::applyTargetedCorrelationMeta($state, $meta, true);
            $state = $correlationResult['state'];

            $merged = self::appendIntoSingleRunBatch($state, $canonicalUrls, $maxUrls);
            $state = $merged['state'];
            $detailRows = $merged['detail_rows'];
            $didPersistState = true;
            $didQueueWork = true;

            if ($merged['did_add']) {
                $shouldUpdateTotal = true;
                $totalUrlsForUpdate = $merged['total_urls'];
            }

            $startMeta = ['source' => 'single'];
            if (!empty($meta)) {
                $startMeta = array_merge($startMeta, $meta);
            }

            if (empty($startMeta['label_locked']) && !self::isAdapterTrigger($trigger)) {
                $startMeta = array_merge(
                    $startMeta,
                    self::buildPrimaryIdentity($startMeta, $trigger)
                );
            }

            $startRunPayload = [
                'run_id' => $runId,
                'trigger' => $trigger,
                'mode' => 'single',
                'total' => 1,
                'meta' => $startMeta,
            ];
        }

        if ($trustedCanonicalSet !== []) {
            $existingTrusted = isset($state['trusted_set']) && is_array($state['trusted_set'])
                ? $state['trusted_set']
                : [];
            $mergedTrusted = $existingTrusted + $trustedCanonicalSet;
            if ($mergedTrusted !== $existingTrusted) {
                $state['trusted_set'] = $mergedTrusted;
                $didPersistState = true;
            }
        }

        if ($didPersistState) {
            self::set($state);
        }

        if ($startRunPayload !== null) {
            Logger::startRun(
                $startRunPayload['run_id'],
                $startRunPayload['trigger'],
                $startRunPayload['mode'],
                $startRunPayload['total'],
                $startRunPayload['meta'],
                'pending'
            );
        }

        $runId = (string) ($state['run_id'] ?? '');

        if ($shouldUpdateTotal && $runId !== '') {
            Logger::updateTotalUrls($runId, $totalUrlsForUpdate);
        }

        if ($detailRows !== [] && $runId !== '') {
            Logger::appendDetailsBatch($runId, $detailRows);
        }

        if ($didQueueWork) {
            if ($runnerLocked && $startRunPayload !== null) {
                Runner::scheduleSoon(false);
            } elseif (!$runnerLocked) {
                Runner::scheduleSoon($startRunPayload !== null);
            }
        }
    }

    /**
     * Backward-compatible shim for pre-3.5.11 callers.
    /**
     * Enqueue a single URL warmup.
     *
     * Behavior:
     * - Canonicalizes the URL and bails if invalid/empty.
     * - Requires an operational selected cache adapter; otherwise it bails without queuing work.
     * - Recovers stale runs before deciding how to enqueue.
     * - If a full run is active: prioritizes the URL near the current cursor.
     * - If a single run is active: merges the URL into the active run (deduped), capped.
     * - Otherwise: starts a new single run with exactly one URL.
     * - Always schedules WP-Cron immediately after enqueue/merge.
     *
     * @param string              $url     Raw URL to warm (e.g. permalink).
     * @param string              $trigger Trigger key (e.g. "save_post", "manual").
     * @param array<string,mixed> $meta    Optional metadata persisted on new run start.
     */
    public static function enqueueSingle(string $url, string $trigger, array $meta = []): void
    {
        // 1) Normalize and canonicalize URL.
        $url = trim($url);
        if ($url === '') {
            Debug::warn('Queue::enqueueSingle bail: empty URL');
            return;
        }

        $url = Sitemap::canonicalizeUrl($url);
        if ($url === '') {
            Debug::warn('Queue::enqueueSingle bail: canonicalized URL is empty');
            return;
        }

        // 2) Gate single-url scheduling through ECI adapter authority.
        if (!InvalidatorAdapterGate::hasOperationalAdapter([
            'caller' => 'ecw_queue',
            'reason' => 'warmup',
        ])) {
            Debug::warn('Queue::enqueueSingle bail: no operational adapter via ECI authority');
            return;
        }

        // 3) Load current state and recover stale runs first.
        $state = self::get();
        self::recoverIfStale($state);

        // 4) Read queue cap once for merge paths.
        $maxUrls = Repository::getInt('advanced.max_urls_per_run', Settings::DEFAULT_MAX_URLS_PER_RUN);

        /**
         * 5) If a full run is active, merge this URL into the active queue.
         *    This prevents losing frequent save_post signals during long full warmups.
         */
        if (
            self::isPreparingFullState($state)
        ) {
            $buffered = self::bufferPreparingSingle($state, $url);

            if ($buffered['did_change']) {
                Logger::updateTotalUrls((string) ($state['run_id'] ?? ''), $buffered['total_urls']);
                Debug::log('Queue::enqueueSingle buffered URL in preparing full run', ['result' => $buffered['log_result']]);
            }

            Logger::appendDetails((string) ($state['run_id'] ?? ''), [
                'url' => $url,
                'result' => $buffered['log_result'],
            ]);

            Runner::scheduleSoon();
            return;
        }

        if (
            self::isRunningFullState($state)
            && isset($state['urls']) && is_array($state['urls'])
        ) {
            $currentUrls = array_values(array_map('strval', $state['urls']));
            $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
            $cursor = max(0, min($cursor, count($currentUrls)));

            $existingIndex = array_search($url, $currentUrls, true);
            $didChange = false;
            $logResult = 'queued_prioritized_full';

            if ($existingIndex === false) {
                if (count($currentUrls) >= $maxUrls) {
                    Logger::appendDetails((string) ($state['run_id'] ?? ''), [
                        'url' => $url,
                        'result' => 'queued_drop_cap',
                        'error' => sprintf('Queue cap reached (%d).', $maxUrls),
                    ]);

                    Runner::scheduleSoon();
                    return;
                }

                array_splice($currentUrls, $cursor, 0, [$url]);
                $didChange = true;
                $logResult = 'queued_prioritized_full_new';
            } else {
                if ($existingIndex !== $cursor) {
                    array_splice($currentUrls, $existingIndex, 1);
                    if ($existingIndex < $cursor) {
                        $cursor = max(0, $cursor - 1);
                    }
                    array_splice($currentUrls, $cursor, 0, [$url]);
                    $didChange = true;
                    $logResult = 'queued_prioritized_full_move';
                }
            }

            if ($didChange) {
                $state['urls'] = $currentUrls;
                $state['total_urls'] = count($currentUrls);
                $state['last_activity'] = time();
                self::set($state);

                if ($logResult === 'queued_prioritized_full_new') {
                    Logger::updateTotalUrls((string) ($state['run_id'] ?? ''), count($currentUrls));
                }

                Debug::log('Queue::enqueueSingle prioritized URL in active full run', ['result' => $logResult]);
            }

            Logger::appendDetails((string) ($state['run_id'] ?? ''), [
                'url' => $url,
                'result' => $logResult,
            ]);

            Runner::scheduleSoon();
            return;
        }

        Debug::verbose('[ECW] enqueueSingle promotion check', [
            'trigger' => $trigger,
            'meta_trigger_event' => $meta['trigger_event'] ?? null,
        ]);

        /**
         * 6) If a single run is active, merge this URL into that run.
         */
        if (
            self::hasActiveRun($state, 'single')
            && isset($state['urls']) && is_array($state['urls'])
        ) {
            $runId = (string) ($state['run_id'] ?? '');
            $existingTrigger = (string) ($state['trigger'] ?? '');

            if (self::isRunLocked()) {
                $buffered = self::bufferIntoRunningTargetedRun($state, [$url], [], $trigger, $meta, $maxUrls);
                if (empty($buffered['stale_run'])) {
                    return;
                }

                self::enqueueSingle($url, $trigger, $meta);
                return;
            }

            $runLockedToSystemTrigger = ($runId !== '') && Logger::hasSystemPrimaryTriggerClass($runId);

            // Promote ECI trigger metadata over adapter-initiated trigger.
            // Require real ECI context (trigger_event) to prevent incomplete promotions.
            if (
                $trigger === 'cache_invalidation'
                && $existingTrigger !== 'cache_invalidation'
                && $runId !== ''
                && !$runLockedToSystemTrigger
                && !empty($meta['trigger_event'])
            ) {
                $state['trigger'] = 'cache_invalidation';
                $state['last_activity'] = time();
                self::set($state);

                Logger::promoteRunTrigger($runId, 'cache_invalidation', $meta);

                // Lock primary identity on ECI takeover when adapter left it unlocked.
                Logger::lockPrimaryIdentityIfUnlocked($runId, $trigger, $meta);

                Debug::verbose(sprintf(
                    'Queue::enqueueSingle promoted trigger: %s -> cache_invalidation',
                    $existingTrigger
                ));
            } elseif ($trigger === 'cache_invalidation' && $runLockedToSystemTrigger) {
                Debug::verbose('[ECW] Queue::enqueueSingle promotion skipped: primary system trigger preserved', [
                    'run_id' => $runId,
                    'existing_trigger' => $existingTrigger,
                ]);
            }

            $merged = self::appendIntoSingleRunBatch($state, [$url], $maxUrls);
            if ($merged['did_change']) {
                $state = $merged['state'];
                self::set($state);
                Logger::updateTotalUrls($runId, $merged['total_urls']);
                Logger::appendDetailsBatch($runId, $merged['detail_rows']);
                Debug::verbose('Queue::enqueueSingle merged into active single run');
            }

            Runner::scheduleSoon();
            return;
        }

        /**
         * 7) No active run: start a new single run with exactly one URL.
         */
        $runId = self::newRunId();
        $nowUtc = gmdate('Y-m-d H:i:s');
        self::clearTargetedBuffer();

        self::set([
            'active' => true,
            'status' => 'running',
            'run_id' => $runId,
            'mode' => 'single',
            'trigger' => $trigger,
            'started_at' => $nowUtc,
            'urls' => [$url],
            'total_urls' => 1,
            'cursor' => 0,
            'warmed' => 0,
            'failed' => 0,
            'errors' => [],
            'last_activity' => time(),
        ]);

        $startMeta = ['source' => 'single'];
        if (!empty($meta)) {
            $startMeta = array_merge($startMeta, $meta);
        }

        if (empty($startMeta['label_locked']) && !self::isAdapterTrigger($trigger)) {
            $startMeta = array_merge(
                $startMeta,
                self::buildPrimaryIdentity($startMeta, $trigger)
            );
        }

        Logger::startRun($runId, $trigger, 'single', 1, $startMeta, 'pending');

        Runner::scheduleSoon(!self::isRunLocked());
    }

    /**
     * Batch variant of preparing-full buffering with per-URL result parity.
     *
     * @param array<string,mixed> $state Active queue state.
     * @param array<int,string>   $urls Canonical URLs.
     * @return array{
     *   state:array<string,mixed>,
     *   detail_rows:array<int,array<string,mixed>>,
     *   did_change:bool,
     *   total_urls:int
     * }
     */
    private static function bufferPreparingBatch(array $state, array $urls): array
    {
        $state = self::normalizeState($state);
        $detailRows = [];

        if (!self::isPreparingFullState($state)) {
            return [
                'state' => $state,
                'detail_rows' => $detailRows,
                'did_change' => false,
                'total_urls' => self::getTotalUrls($state),
            ];
        }

        $runId = trim((string) ($state['run_id'] ?? ''));
        if ($runId === '') {
            foreach ($urls as $url) {
                $detailRows[] = [
                    'url' => $url,
                    'result' => 'queued_buffer_preparing',
                ];
            }

            return [
                'state' => $state,
                'detail_rows' => $detailRows,
                'did_change' => false,
                'total_urls' => self::getTotalUrls($state),
            ];
        }

        self::clearPreparingBufferIfRunMismatch($runId);

        $buffer = self::getPreparingBuffer();
        if (trim((string) ($buffer['run_id'] ?? '')) !== $runId) {
            $buffer = [
                'run_id' => $runId,
                'urls' => [],
            ];
        }

        $pendingUrls = self::normalizeUrlList($buffer['urls'] ?? []);
        $didChange = false;

        foreach ($urls as $url) {
            $existingIndex = array_search($url, $pendingUrls, true);
            $logResult = 'queued_buffer_preparing';

            if ($existingIndex === false) {
                array_unshift($pendingUrls, $url);
                $didChange = true;
                $logResult = 'queued_buffer_preparing_new';
            } elseif ($existingIndex !== 0) {
                array_splice($pendingUrls, $existingIndex, 1);
                array_unshift($pendingUrls, $url);
                $didChange = true;
                $logResult = 'queued_buffer_preparing_move';
            }

            $detailRows[] = [
                'url' => $url,
                'result' => $logResult,
            ];
        }

        $totalUrls = count(self::mergePreparingPendingUrls(
            self::normalizeUrlList($state['pending_priority_urls'] ?? []),
            $pendingUrls
        ));

        if ($didChange) {
            $buffer['urls'] = $pendingUrls;
            self::setPreparingBuffer($buffer);

            $state['total_urls'] = $totalUrls;
            $state['last_activity'] = time();
        }

        return [
            'state' => $state,
            'detail_rows' => $detailRows,
            'did_change' => $didChange,
            'total_urls' => $totalUrls,
        ];
    }

    /**
     * Batch splice targeted URLs into a running full queue with per-URL result parity.
     *
     * @param array<string,mixed> $state Active queue state.
     * @param array<int,string>   $urls Canonical URLs.
     * @param int                 $maxUrls Queue cap.
     * @return array{
     *   state:array<string,mixed>,
     *   detail_rows:array<int,array<string,mixed>>,
     *   did_change:bool,
     *   did_add:bool,
     *   total_urls:int
     * }
     */
    private static function spliceIntoRunningFullBatch(array $state, array $urls, int $maxUrls): array
    {
        $state = self::normalizeState($state);
        $detailRows = [];

        if (
            !self::isRunningFullState($state)
            || !isset($state['urls'])
            || !is_array($state['urls'])
        ) {
            return [
                'state' => $state,
                'detail_rows' => $detailRows,
                'did_change' => false,
                'did_add' => false,
                'total_urls' => max(0, (int) ($state['total_urls'] ?? 0)),
            ];
        }

        $currentUrls = array_values(array_map('strval', $state['urls']));
        $didChange = false;
        $didAdd = false;

        foreach ($urls as $url) {
            $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0;
            $cursor = max(0, min($cursor, count($currentUrls)));

            $existingIndex = array_search($url, $currentUrls, true);
            $logResult = 'queued_prioritized_full';

            if ($existingIndex === false) {
                if (count($currentUrls) >= $maxUrls) {
                    $detailRows[] = [
                        'url' => $url,
                        'result' => 'queued_drop_cap',
                        'error' => sprintf('Queue cap reached (%d).', $maxUrls),
                    ];
                    continue;
                }

                array_splice($currentUrls, $cursor, 0, [$url]);
                $didChange = true;
                $didAdd = true;
                $logResult = 'queued_prioritized_full_new';
            } elseif ($existingIndex !== $cursor) {
                array_splice($currentUrls, $existingIndex, 1);
                if ($existingIndex < $cursor) {
                    $cursor = max(0, $cursor - 1);
                }

                array_splice($currentUrls, $cursor, 0, [$url]);
                $didChange = true;
                $logResult = 'queued_prioritized_full_move';
            }

            $detailRows[] = [
                'url' => $url,
                'result' => $logResult,
            ];
        }

        if ($didChange) {
            $state['urls'] = $currentUrls;
            $state['total_urls'] = count($currentUrls);
            $state['last_activity'] = time();
        }

        return [
            'state' => $state,
            'detail_rows' => $detailRows,
            'did_change' => $didChange,
            'did_add' => $didAdd,
            'total_urls' => count($currentUrls),
        ];
    }

    /**
     * Promote active single-run trigger identity to ECI when eligible.
     *
     * @param array<string,mixed> $state Active queue state (updated in-memory).
     * @param string $trigger Incoming trigger key.
     * @param array<string,mixed> $meta Incoming trigger metadata.
     * @return bool True when queue trigger metadata was promoted.
     */
    private static function maybePromoteTrigger(array &$state, string $trigger, array $meta = []): bool
    {
        Debug::verbose('[ECW] enqueueBatch promotion check', [
            'trigger' => $trigger,
            'meta_trigger_event' => $meta['trigger_event'] ?? null,
        ]);

        $runId = (string) ($state['run_id'] ?? '');
        $existingTrigger = (string) ($state['trigger'] ?? '');
        $runLockedToSystemTrigger = ($runId !== '') && Logger::hasSystemPrimaryTriggerClass($runId);

        if (
            $trigger === 'cache_invalidation'
            && $existingTrigger !== 'cache_invalidation'
            && $runId !== ''
            && !$runLockedToSystemTrigger
            && !empty($meta['trigger_event'])
        ) {
            $state['trigger'] = 'cache_invalidation';
            $state['last_activity'] = time();

            Logger::promoteRunTrigger($runId, 'cache_invalidation', $meta);
            Logger::lockPrimaryIdentityIfUnlocked($runId, $trigger, $meta);

            Debug::verbose(sprintf(
                'Queue::enqueueBatch promoted trigger: %s -> cache_invalidation',
                $existingTrigger
            ));

            return true;
        }

        if ($trigger === 'cache_invalidation' && $runLockedToSystemTrigger) {
            Debug::verbose('[ECW] Queue::enqueueBatch promotion skipped: primary system trigger preserved', [
                'run_id' => $runId,
                'existing_trigger' => $existingTrigger,
            ]);
        }

        return false;
    }

    /**
     * Batch-append canonical URLs into an active single run.
     *
     * @param array<string,mixed> $state Active queue state.
     * @param array<int,string>   $urls Canonical URLs.
     * @param int                 $maxUrls Queue cap.
     * @return array{
     *   state:array<string,mixed>,
     *   detail_rows:array<int,array<string,mixed>>,
     *   did_change:bool,
     *   did_add:bool,
     *   total_urls:int
     * }
     */
    private static function appendIntoSingleRunBatch(array $state, array $urls, int $maxUrls): array
    {
        $state = self::normalizeState($state);
        $detailRows = [];
        $didChange = false;
        $didAdd = false;

        $existing = array_fill_keys(array_map('strval', $state['urls']), true);

        foreach ($urls as $url) {
            if (isset($existing[$url])) {
                continue;
            }

            if (count($existing) >= $maxUrls) {
                $detailRows[] = [
                    'url' => $url,
                    'result' => 'queued_drop_cap',
                    'error' => sprintf('Queue cap reached (%d).', $maxUrls),
                ];
                continue;
            }

            $state['urls'][] = $url;
            $state['total_urls'] = count($state['urls']);
            $state['last_activity'] = time();

            $existing[$url] = true;
            $didChange = true;
            $didAdd = true;

            $detailRows[] = [
                'url' => $url,
                'result' => 'queued_merge_single',
            ];
        }

        return [
            'state' => $state,
            'detail_rows' => $detailRows,
            'did_change' => $didChange,
            'did_add' => $didAdd,
            'total_urls' => max(0, (int) ($state['total_urls'] ?? 0)),
        ];
    }

    /**
     * Buffer targeted work received while the runner owns the active run.
     *
     * @param array<string,mixed> $state Active targeted queue state.
     * @param array<int,string> $urls Canonical URLs.
     * @param array<string,bool> $trustedSet Trusted canonical URL set.
     * @param string $trigger Incoming trigger key.
     * @param array<string,mixed> $meta Incoming trigger metadata.
     * @param int $maxUrls Maximum unique URLs allowed in the run.
     * @return array{did_add:bool,total_urls:int,stale_run:bool,lock_unavailable:bool}
     */
    private static function bufferIntoRunningTargetedRun(
        array $state,
        array $urls,
        array $trustedSet,
        string $trigger,
        array $meta,
        int $maxUrls
    ): array {
        $state = self::normalizeState($state);
        $runId = trim((string) ($state['run_id'] ?? ''));
        if ($runId === '') {
            return [
                'did_add' => false,
                'total_urls' => count($state['urls']),
                'stale_run' => true,
                'lock_unavailable' => false,
            ];
        }

        $mutationLock = self::acquireTargetedMutationLock();
        $mutationToken = (string) ($mutationLock['token'] ?? '');
        if (empty($mutationLock['acquired']) || $mutationToken === '') {
            Debug::error('Targeted queue intake could not acquire the mutation lock', [
                'run_id' => $runId,
                'url_count' => count($urls),
            ]);

            return [
                'did_add' => false,
                'total_urls' => count($state['urls']),
                'stale_run' => false,
                'lock_unavailable' => true,
            ];
        }

        try {
            self::forgetOptionRuntimeCache(self::OPTION_STATE);
            self::forgetOptionRuntimeCache(self::OPTION_RUN_LOCK);
            self::forgetOptionRuntimeCache(self::OPTION_TARGETED_BUFFER);

            $freshState = self::get();
            if (
                !self::hasActiveRun($freshState, 'single')
                || (string) ($freshState['run_id'] ?? '') !== $runId
                || !self::isRunLocked()
            ) {
                return [
                    'did_add' => false,
                    'total_urls' => self::getTotalUrls($freshState),
                    'stale_run' => true,
                    'lock_unavailable' => false,
                ];
            }

            return self::persistRunningTargetedBuffer(
                $freshState,
                $urls,
                $trustedSet,
                $trigger,
                $meta,
                $maxUrls
            );
        } finally {
            if ($mutationToken !== '') {
                self::releaseTargetedMutationLock($mutationToken);
            }
        }
    }

    /**
     * Persist targeted buffer work after run ownership was validated under the mutation lease.
     *
     * @param array<string,mixed> $state Current targeted queue state.
     * @param array<int,string> $urls Canonical URLs.
     * @param array<string,bool> $trustedSet Trusted canonical URL set.
     * @param string $trigger Incoming trigger key.
     * @param array<string,mixed> $meta Incoming trigger metadata.
     * @param int $maxUrls Maximum unique URLs allowed in the run.
     * @return array{did_add:bool,total_urls:int,stale_run:bool,lock_unavailable:bool}
     */
    private static function persistRunningTargetedBuffer(
        array $state,
        array $urls,
        array $trustedSet,
        string $trigger,
        array $meta,
        int $maxUrls
    ): array {
        $runId = trim((string) ($state['run_id'] ?? ''));
        $buffer = self::getTargetedBuffer();
        if ((string) ($buffer['run_id'] ?? '') !== $runId) {
            $buffer = self::defaultTargetedBuffer();
            $buffer['run_id'] = $runId;
            $buffer['seen_correlation_hashes'] = $state['seen_correlation_hashes'];
            $buffer['targeted_update_count'] = $state['targeted_update_count'];
            $buffer['correlation_history_saturated'] = $state['correlation_history_saturated'];
        }

        $correlationState = $state;
        $correlationState['seen_correlation_hashes'] = $buffer['seen_correlation_hashes'];
        $correlationState['targeted_update_count'] = $buffer['targeted_update_count'];
        $correlationState['correlation_history_saturated'] = $buffer['correlation_history_saturated'];
        $correlationResult = self::applyTargetedCorrelationMeta($correlationState, $meta);
        $buffer['seen_correlation_hashes'] = $correlationResult['state']['seen_correlation_hashes'];
        $buffer['targeted_update_count'] = $correlationResult['state']['targeted_update_count'];
        $buffer['correlation_history_saturated'] = $correlationResult['state']['correlation_history_saturated'];

        $uniqueUrls = array_fill_keys(array_map('strval', array_merge($state['urls'], $buffer['urls'])), true);
        $didAdd = false;
        $droppedAtCap = 0;

        foreach ($urls as $url) {
            if (isset($uniqueUrls[$url])) {
                continue;
            }

            if (count($uniqueUrls) >= $maxUrls) {
                $droppedAtCap++;
                continue;
            }

            $buffer['urls'][] = $url;
            $buffer['urls'] = self::normalizeUrlList($buffer['urls']);
            $uniqueUrls[$url] = true;
            $didAdd = true;
        }

        if ($droppedAtCap > 0) {
            Debug::warn('Targeted queue cap reached while runner was active', [
                'dropped' => $droppedAtCap,
                'limit' => $maxUrls,
            ]);
        }

        if ($trustedSet !== []) {
            $buffer['trusted_set'] = self::normalizeTrustedSet(
                (array) $buffer['trusted_set'] + $trustedSet
            );
        }

        if ($trigger === 'cache_invalidation' && (string) ($state['trigger'] ?? '') !== 'cache_invalidation') {
            $buffer['promote_trigger'] = $trigger;
            $buffer['promote_meta'] = $meta;
        }

        if ($didAdd || $correlationResult['did_change'] || $trustedSet !== [] || $buffer['promote_trigger'] !== '') {
            self::setTargetedBuffer($buffer);
        }

        return [
            'did_add' => $didAdd,
            'total_urls' => count($state['urls']) + count($buffer['urls']),
            'stale_run' => false,
            'lock_unavailable' => false,
        ];
    }

    /**
     * Merge runner-concurrent targeted intake into the active queue state.
     *
     * @param string $runId Active run identifier.
     * @return array<string,mixed> Fresh merged queue state.
     */
    public static function drainTargetedBuffer(string $runId): array
    {
        $runId = trim($runId);
        self::forgetOptionRuntimeCache(self::OPTION_STATE);
        self::forgetOptionRuntimeCache(self::OPTION_TARGETED_BUFFER);

        $state = self::get();
        if ($runId === '' || (string) ($state['run_id'] ?? '') !== $runId) {
            return $state;
        }

        $buffer = self::getTargetedBuffer();
        if ((string) ($buffer['run_id'] ?? '') !== $runId) {
            return $state;
        }

        foreach ($buffer['urls'] as $url) {
            $state['urls'][] = $url;
        }

        $state['total_urls'] = count($state['urls']);
        $state['trusted_set'] = self::normalizeTrustedSet(
            (array) ($state['trusted_set'] ?? []) + (array) ($buffer['trusted_set'] ?? [])
        );
        $state['seen_correlation_hashes'] = self::normalizeCorrelationHashes(
            $buffer['seen_correlation_hashes'] ?? $state['seen_correlation_hashes']
        );
        $state['targeted_update_count'] = max(
            (int) ($state['targeted_update_count'] ?? 0),
            (int) ($buffer['targeted_update_count'] ?? 0)
        );
        $state['correlation_history_saturated'] = !empty($state['correlation_history_saturated'])
            || !empty($buffer['correlation_history_saturated']);
        $state['last_activity'] = time();

        $promoteTrigger = sanitize_key((string) ($buffer['promote_trigger'] ?? ''));
        $promoteMeta = is_array($buffer['promote_meta'] ?? null) ? $buffer['promote_meta'] : [];
        if ($promoteTrigger !== '') {
            self::maybePromoteTrigger($state, $promoteTrigger, $promoteMeta);
        }

        self::setIfRunOwned($runId, $state);
        self::clearTargetedBufferIfRunOwned($runId);

        return self::get();
    }

    /**
     * Apply exact run-scoped correlation metadata to a targeted queue state.
     *
     * @param array<string,mixed> $state Targeted queue state.
     * @param array<string,mixed> $meta Incoming trigger metadata.
     * @param bool $isNewRun Whether the correlations establish a new run baseline.
     * @return array{state:array<string,mixed>,did_change:bool,did_observe:bool}
     */
    private static function applyTargetedCorrelationMeta(array $state, array $meta, bool $isNewRun = false): array
    {
        $state['targeted_update_count'] = max(0, (int) ($state['targeted_update_count'] ?? 0));
        $state['correlation_history_saturated'] = !empty($state['correlation_history_saturated']);
        $hashes = self::extractCorrelationHashes($meta);
        if ($hashes === []) {
            return [
                'state' => $state,
                'did_change' => false,
                'did_observe' => false,
            ];
        }

        $existingHashes = self::normalizeCorrelationHashes($state['seen_correlation_hashes'] ?? []);
        $existingSet = array_fill_keys($existingHashes, true);
        $needsBaseline = $existingHashes === [];
        $didChange = false;
        $didObserve = false;

        foreach ($hashes as $hash) {
            if (isset($existingSet[$hash])) {
                continue;
            }

            if (count($existingHashes) >= self::MAX_CORRELATION_HISTORY) {
                if (empty($state['correlation_history_saturated'])) {
                    $state['correlation_history_saturated'] = true;
                    $didChange = true;
                    Debug::warn('Targeted correlation history saturated', [
                        'run_id' => (string) ($state['run_id'] ?? ''),
                        'limit' => self::MAX_CORRELATION_HISTORY,
                    ]);
                }
                break;
            }

            $existingHashes[] = $hash;
            $existingSet[$hash] = true;
            $didChange = true;
            $didObserve = true;

            if (!$isNewRun) {
                if ($needsBaseline) {
                    $needsBaseline = false;
                } elseif (empty($state['correlation_history_saturated'])) {
                    $state['targeted_update_count'] = max(0, (int) ($state['targeted_update_count'] ?? 0)) + 1;
                }
            }
        }

        $state['seen_correlation_hashes'] = $existingHashes;
        if ($didChange) {
            $state['last_activity'] = time();
        }

        return [
            'state' => $state,
            'did_change' => $didChange,
            'did_observe' => $didObserve,
        ];
    }

    /**
     * Extract compact stable correlation hashes from incoming metadata.
     *
     * @param array<string,mixed> $meta Incoming trigger metadata.
     * @return array<int,string>
     */
    private static function extractCorrelationHashes(array $meta): array
    {
        $correlationIds = is_array($meta['correlation_ids'] ?? null)
            ? $meta['correlation_ids']
            : [$meta['correlation_id'] ?? ''];
        $hashes = [];

        foreach ($correlationIds as $correlationId) {
            if (!is_scalar($correlationId)) {
                continue;
            }

            $correlationId = trim((string) $correlationId);
            if ($correlationId === '') {
                continue;
            }

            $hash = substr(hash('sha256', $correlationId), 0, 32);
            $hashes[$hash] = true;
            if (count($hashes) > self::MAX_CORRELATION_HISTORY) {
                break;
            }
        }

        return array_keys($hashes);
    }

    /**
     * Create a UUID v4.
     */
    private static function newRunId(): string
    {
        $data = random_bytes(16);
        $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
        $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
    }

    /**
     * Recover from a stale active run to prevent the queue from being permanently locked.
     *
     * @param array<string,mixed> $state Current queue state (will be modified and persisted if stale).
     * @return void
     */
    private static function recoverIfStale(array &$state): void
    {
        if (empty($state['active'])) {
            return;
        }

        $last = isset($state['last_activity']) ? (int) $state['last_activity'] : 0;
        if ($last <= 0) {
            // If we don't have a timestamp, treat it as stale quickly (older versions).
            $last = time() - (60 * 60);
        }

        $staleMinutes = Repository::getInt('advanced.stale_run_minutes', Settings::DEFAULT_STALE_RUN_MINUTES);

        $age = time() - $last;
        if ($age < ($staleMinutes * MINUTE_IN_SECONDS)) {
            return;
        }

        // Mark any existing run as failed in logs (best-effort).
        $runId = isset($state['run_id']) ? (string) $state['run_id'] : '';
        if ($runId !== '') {
            $total = self::getTotalUrls($state);

            Logger::finishRun(
                $runId,
                'failed',
                $total,
                (int) ($state['warmed'] ?? 0),
                (int) ($state['failed'] ?? 0),
                __('Stale run detected and reset.', 'cache-autopilot')
            );
        }

        // Reset queue state.
        $state = self::defaultState();
        $state['last_activity'] = time();

        self::set($state);
        self::clearPreparingBuffer();
        self::clearTargetedBuffer();

        Debug::info('Queue stale run recovered and queue reset');
    }

    /**
     * Returns the User-Agent string for warmup HTTP requests.
     *
     * Order of precedence:
     * 1. Custom User-Agent configured in settings.
     * 2. Plugin default token from Settings::DEFAULT_USER_AGENT.
     *
     * Note:
     * Runner composes the final HTTP User-Agent header and appends this token.
     *
     * @return string
     */
    public static function userAgent(): string
    {
        $custom = Repository::getString('advanced.user_agent');
        if ($custom !== '') {
            return $custom;
        }

        return Settings::DEFAULT_USER_AGENT;
    }

    /**
     * Acquire the short lease guarding targeted buffer hand-offs.
     *
     * The lease is held only around option mutations, never around HTTP warmup work.
     * A bounded wait lets a concurrent checkpoint finish before the caller fails closed.
     *
     * @return array{acquired:bool,token:string}
     */
    public static function acquireTargetedMutationLock(): array
    {
        global $wpdb;

        $token = wp_generate_uuid4();
        $deadline = microtime(true) + self::TARGETED_MUTATION_LOCK_TTL + 1;
        $emptyPayload = [
            'token' => '',
            'expires_at' => 0,
        ];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic lock initialization requires an idempotent insert; the option cache is invalidated immediately afterward.
        $wpdb->query($wpdb->prepare(
            "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)",
            self::OPTION_TARGETED_MUTATION_LOCK,
            maybe_serialize($emptyPayload),
            'no'
        ));
        self::forgetOptionRuntimeCache(self::OPTION_TARGETED_MUTATION_LOCK);

        do {
            $now = time();
            $payload = [
                'token' => $token,
                'expires_at' => $now + self::TARGETED_MUTATION_LOCK_TTL,
            ];

            self::forgetOptionRuntimeCache(self::OPTION_TARGETED_MUTATION_LOCK);
            $existing = get_option(self::OPTION_TARGETED_MUTATION_LOCK, $emptyPayload);
            $expiresAt = is_array($existing) && isset($existing['expires_at'])
                ? (int) $existing['expires_at']
                : 0;

            if (
                $expiresAt <= $now
                && self::compareAndSwapTargetedMutationLock($existing, $payload)
            ) {
                return [
                    'acquired' => true,
                    'token' => $token,
                ];
            }

            usleep(10000);
        } while (microtime(true) < $deadline);

        Debug::error('Targeted queue mutation lock timed out');

        return [
            'acquired' => false,
            'token' => '',
        ];
    }

    /**
     * Release the targeted mutation lease only when owned by the caller.
     *
     * @param string $token Lease owner token.
     * @return void
     */
    public static function releaseTargetedMutationLock(string $token): void
    {
        global $wpdb;

        if ($token === '') {
            return;
        }

        self::forgetOptionRuntimeCache(self::OPTION_TARGETED_MUTATION_LOCK);
        $existing = get_option(self::OPTION_TARGETED_MUTATION_LOCK, []);
        if (!is_array($existing) || (string) ($existing['token'] ?? '') !== $token) {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic compare-and-delete prevents a stale owner from releasing a replacement lease; the option cache is invalidated immediately afterward.
        $wpdb->delete(
            $wpdb->options,
            [
                'option_name' => self::OPTION_TARGETED_MUTATION_LOCK,
                'option_value' => maybe_serialize($existing),
            ],
            ['%s', '%s']
        );
        self::forgetOptionRuntimeCache(self::OPTION_TARGETED_MUTATION_LOCK);
    }

    /**
     * Atomically replace the targeted mutation lease when its stored value is unchanged.
     *
     * @param mixed $expected Expected stored lease payload.
     * @param array<string,mixed> $replacement Replacement lease payload.
     * @return bool True when this request won the lease transition.
     */
    private static function compareAndSwapTargetedMutationLock(mixed $expected, array $replacement): bool
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic compare-and-swap serializes targeted queue mutations; the option cache is invalidated immediately afterward.
        $updated = $wpdb->update(
            $wpdb->options,
            [
                'option_value' => maybe_serialize($replacement),
                'autoload' => 'no',
            ],
            [
                'option_name' => self::OPTION_TARGETED_MUTATION_LOCK,
                'option_value' => maybe_serialize($expected),
            ],
            ['%s', '%s'],
            ['%s', '%s']
        );
        self::forgetOptionRuntimeCache(self::OPTION_TARGETED_MUTATION_LOCK);

        return $updated === 1;
    }

    /**
     * Attempt to acquire the global runner lease lock.
     *
     * This prevents overlapping Runner::run() executions across concurrent PHP requests
     * (e.g., server cron overlaps, manual kick overlaps, slow batches).
     *
     * The lock is implemented as an option created via add_option(), which is atomic at DB level.
     * The lock is a "lease": it has an expires_at timestamp and can be reclaimed after expiry.
     *
     * @param int $ttlSeconds Lease duration in seconds.
     * @return array{acquired:bool, token:string} Result and owner token.
     */
    public static function acquireRunLock(int $ttlSeconds): array
    {
        $ttlSeconds = max(5, $ttlSeconds);

        $token = wp_generate_uuid4();
        $now = time();

        $payload = [
            'token' => $token,
            'expires_at' => $now + $ttlSeconds,
        ];

        // Fast path: atomically create lock if it doesn't exist.
        $created = add_option(self::OPTION_RUN_LOCK, $payload, '', false);
        if ($created) {
            return [
                'acquired' => true,
                'token' => $token,
            ];
        }

        // Lock exists. Check if it's expired; if so, try to reclaim.
        $existing = get_option(self::OPTION_RUN_LOCK, []);
        $expiresAt = isset($existing['expires_at']) ? (int) $existing['expires_at'] : 0;

        if ($expiresAt > $now) {
            return [
                'acquired' => false,
                'token' => '',
            ];
        }

        // Expired: attempt to reclaim. (Small race possible, acceptable; token check on release protects.)
        delete_option(self::OPTION_RUN_LOCK);

        $created = add_option(self::OPTION_RUN_LOCK, $payload, '', 'no');
        if ($created) {
            return [
                'acquired' => true,
                'token' => $token,
            ];
        }

        return [
            'acquired' => false,
            'token' => '',
        ];
    }

    /**
     * Release the runner lease lock, but only if owned by the provided token.
     *
     * Token-guarded release prevents deleting another process's lock in case of races.
     *
     * @param string $token Owner token returned by acquireRunLock().
     * @return void
     */
    public static function releaseRunLock(string $token): void
    {
        if ($token === '') {
            return;
        }

        $existing = get_option(self::OPTION_RUN_LOCK, []);
        $existingToken = isset($existing['token']) ? (string) $existing['token'] : '';

        if ($existingToken !== $token) {
            return;
        }

        delete_option(self::OPTION_RUN_LOCK);
    }

    /**
     * Whether an unexpired runner lease lock currently exists.
     *
     * @return bool True when another process still holds the runner lock.
     */
    public static function isRunLocked(): bool
    {
        $existing = get_option(self::OPTION_RUN_LOCK, []);
        $expiresAt = isset($existing['expires_at']) ? (int) $existing['expires_at'] : 0;

        return $expiresAt > time();
    }

    /**
     * Normalize queue state for legacy payloads and new preparing full intents.
     *
     * @param array<string,mixed> $state Raw queue state.
     * @return array<string,mixed>
     */
    private static function normalizeState(array $state): array
    {
        $state = array_merge(self::defaultState(), $state);

        $state['active'] = !empty($state['active']);
        $state['status'] = sanitize_key((string) ($state['status'] ?? 'idle'));
        $state['run_id'] = sanitize_text_field((string) ($state['run_id'] ?? ''));
        $state['mode'] = sanitize_key((string) ($state['mode'] ?? ''));
        $state['trigger'] = sanitize_text_field((string) ($state['trigger'] ?? ''));
        $state['started_at'] = sanitize_text_field((string) ($state['started_at'] ?? ''));
        $state['finished_at'] = sanitize_text_field((string) ($state['finished_at'] ?? ''));
        $state['urls'] = $state['mode'] === 'single'
            ? self::normalizeAttemptUrlList($state['urls'] ?? [])
            : self::normalizeUrlList($state['urls'] ?? []);
        $state['trusted_set'] = self::normalizeTrustedSet($state['trusted_set'] ?? []);
        $state['cursor'] = max(0, (int) ($state['cursor'] ?? 0));
        $state['warmed'] = max(0, (int) ($state['warmed'] ?? 0));
        $state['failed'] = max(0, (int) ($state['failed'] ?? 0));
        $state['errors'] = is_array($state['errors']) ? array_values($state['errors']) : [];
        $state['last_activity'] = max(0, (int) ($state['last_activity'] ?? 0));
        $state['targeted_update_count'] = max(0, (int) ($state['targeted_update_count'] ?? 0));
        $state['seen_correlation_hashes'] = self::normalizeCorrelationHashes($state['seen_correlation_hashes'] ?? []);
        $state['correlation_history_saturated'] = !empty($state['correlation_history_saturated']);
        $state['pending_priority_urls'] = self::normalizeUrlList($state['pending_priority_urls'] ?? []);
        $state['intent_signature'] = sanitize_text_field((string) ($state['intent_signature'] ?? ''));
        $state['intent_trigger_family'] = sanitize_key((string) ($state['intent_trigger_family'] ?? ''));
        $state['intent_hashes'] = is_array($state['intent_hashes']) ? $state['intent_hashes'] : [];
        $state['intent_inputs'] = is_array($state['intent_inputs']) ? $state['intent_inputs'] : [];
        $state['prepare_source'] = sanitize_key((string) ($state['prepare_source'] ?? ''));
        $state['coalesced_full'] = self::normalizeCoalescedFullIntent($state['coalesced_full'] ?? []);

        if (!$state['active']) {
            $state['status'] = 'idle';
        } elseif ($state['status'] === '') {
            $state['status'] = ($state['mode'] === 'full' && $state['urls'] === []) ? 'preparing' : 'running';
        }

        if ($state['mode'] === 'full' && $state['status'] === 'preparing') {
            $state['cursor'] = 0;
        }

        if ($state['mode'] !== 'full') {
            $state['pending_priority_urls'] = [];
            $state['intent_signature'] = '';
            $state['intent_trigger_family'] = '';
            $state['intent_hashes'] = [];
            $state['intent_inputs'] = [];
            $state['prepare_source'] = '';
            $state['coalesced_full'] = self::defaultCoalescedFullIntent();
        }

        if ($state['mode'] !== 'single') {
            $state['targeted_update_count'] = 0;
            $state['seen_correlation_hashes'] = [];
            $state['correlation_history_saturated'] = false;
        }

        $explicitTotal = max(0, (int) ($state['total_urls'] ?? 0));
        $materializedTotal = count($state['urls']);
        $pendingTotal = count($state['pending_priority_urls']);
        $state['total_urls'] = max($explicitTotal, $materializedTotal, $pendingTotal);

        return $state;
    }

    /**
     * Read the companion preparing buffer from the options table.
     *
     * @return array{run_id:string,urls:array<int,string>}
     */
    private static function getPreparingBuffer(): array
    {
        $buffer = get_option(self::OPTION_PREPARING_BUFFER, []);
        return self::normalizePreparingBuffer(is_array($buffer) ? $buffer : []);
    }

    /**
     * Persist the companion preparing buffer.
     *
     * @param array<string,mixed> $buffer Raw buffer payload.
     * @return void
     */
    private static function setPreparingBuffer(array $buffer): void
    {
        update_option(self::OPTION_PREPARING_BUFFER, self::normalizePreparingBuffer($buffer), false);
    }

    /**
     * Normalize a raw companion-buffer payload.
     *
     * @param array<string,mixed> $buffer Raw buffer payload.
     * @return array{run_id:string,urls:array<int,string>}
     */
    private static function normalizePreparingBuffer(array $buffer): array
    {
        $buffer = array_merge(self::defaultPreparingBuffer(), $buffer);
        return [
            'run_id' => sanitize_text_field((string) ($buffer['run_id'] ?? '')),
            'urls' => self::normalizeUrlList($buffer['urls'] ?? []),
        ];
    }

    /**
     * Read the companion targeted-intake buffer from the options table.
     *
     * @return array<string,mixed>
     */
    private static function getTargetedBuffer(): array
    {
        $buffer = get_option(self::OPTION_TARGETED_BUFFER, []);
        return self::normalizeTargetedBuffer(is_array($buffer) ? $buffer : []);
    }

    /**
     * Forget one request-local option snapshot before a lock-authoritative read.
     *
     * @param string $optionName Option key.
     * @return void
     */
    private static function forgetOptionRuntimeCache(string $optionName): void
    {
        wp_cache_delete($optionName, 'options');
        wp_cache_delete('notoptions', 'options');
    }

    /**
     * Persist the companion targeted-intake buffer.
     *
     * @param array<string,mixed> $buffer Raw buffer payload.
     * @return void
     */
    private static function setTargetedBuffer(array $buffer): void
    {
        update_option(self::OPTION_TARGETED_BUFFER, self::normalizeTargetedBuffer($buffer), false);
    }

    /**
     * Remove the companion targeted-intake buffer.
     *
     * @return void
     */
    public static function clearTargetedBuffer(): void
    {
        delete_option(self::OPTION_TARGETED_BUFFER);
    }

    /**
     * Remove the targeted-intake buffer when owned by the expected run.
     *
     * @param string $runId Expected active run identifier.
     * @return bool True when the buffer was removed.
     */
    public static function clearTargetedBufferIfRunOwned(string $runId): bool
    {
        $runId = trim($runId);
        if ($runId === '') {
            return false;
        }

        $buffer = self::getTargetedBuffer();
        if ((string) ($buffer['run_id'] ?? '') !== $runId) {
            return false;
        }

        self::clearTargetedBuffer();
        return true;
    }

    /**
     * Normalize a raw targeted-intake buffer payload.
     *
     * @param array<string,mixed> $buffer Raw buffer payload.
     * @return array<string,mixed>
     */
    private static function normalizeTargetedBuffer(array $buffer): array
    {
        $buffer = array_merge(self::defaultTargetedBuffer(), $buffer);

        return [
            'run_id' => sanitize_text_field((string) ($buffer['run_id'] ?? '')),
            'urls' => self::normalizeUrlList($buffer['urls'] ?? []),
            'trusted_set' => self::normalizeTrustedSet($buffer['trusted_set'] ?? []),
            'seen_correlation_hashes' => self::normalizeCorrelationHashes($buffer['seen_correlation_hashes'] ?? []),
            'targeted_update_count' => max(0, (int) ($buffer['targeted_update_count'] ?? 0)),
            'correlation_history_saturated' => !empty($buffer['correlation_history_saturated']),
            'promote_trigger' => sanitize_key((string) ($buffer['promote_trigger'] ?? '')),
            'promote_meta' => is_array($buffer['promote_meta'] ?? null) ? $buffer['promote_meta'] : [],
        ];
    }

    /**
     * Normalize a raw coalesced-full payload.
     *
     * @param mixed $raw Raw payload value.
     * @return array{has_intent:bool,latest_trigger:string,latest_meta:array<string,mixed>,updated_at:int}
     */
    private static function normalizeCoalescedFullIntent($raw): array
    {
        $raw = is_array($raw) ? $raw : [];
        return [
            'has_intent' => !empty($raw['has_intent']),
            'latest_trigger' => is_string($raw['latest_trigger'] ?? '') ? $raw['latest_trigger'] : '',
            'latest_meta' => is_array($raw['latest_meta'] ?? null) ? $raw['latest_meta'] : [],
            'updated_at' => (int) ($raw['updated_at'] ?? 0),
        ];
    }

    /**
     * Merge legacy and companion pending URLs into one deduplicated list.
     *
     * @param array<int,string> $legacyUrls Read-only compatibility URLs from queue state.
     * @param array<int,string> $bufferUrls Authoritative companion-buffer URLs.
     * @return array<int,string> Combined pending URLs.
     */
    private static function mergePreparingPendingUrls(array $legacyUrls, array $bufferUrls): array
    {
        $merged = [];
        $seen = [];

        foreach (array_merge($bufferUrls, $legacyUrls) as $url) {
            $url = trim((string) $url);
            if ($url === '' || isset($seen[$url])) {
                continue;
            }

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

        return $merged;
    }

    /**
     * Normalize a list of URLs into a deduplicated canonical array.
     *
     * @param mixed $urls Raw list.
     * @return array<int,string>
     */
    private static function normalizeUrlList($urls): array
    {
        if (!is_array($urls)) {
            return [];
        }

        $normalized = [];
        $seen = [];

        foreach ($urls as $url) {
            if (!is_string($url)) {
                continue;
            }

            $url = trim($url);
            if ($url === '' || isset($seen[$url])) {
                continue;
            }

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

        return $normalized;
    }

    /**
     * Normalize queued URL attempts while preserving intentional retries.
     *
     * @param mixed $urls Raw URL-attempt list.
     * @return array<int,string>
     */
    private static function normalizeAttemptUrlList($urls): array
    {
        if (!is_array($urls)) {
            return [];
        }

        $normalized = [];
        foreach ($urls as $url) {
            if (!is_string($url)) {
                continue;
            }

            $url = trim($url);
            if ($url !== '') {
                $normalized[] = $url;
            }
        }

        return $normalized;
    }

    /**
     * Normalize a trusted URL lookup set.
     *
     * @param mixed $trustedSet Raw trusted URL set or list.
     * @return array<string,bool>
     */
    private static function normalizeTrustedSet($trustedSet): array
    {
        if (!is_array($trustedSet)) {
            return [];
        }

        $normalized = [];
        foreach ($trustedSet as $key => $value) {
            $url = is_string($key) && !is_int($key) ? trim($key) : trim((string) $value);
            if ($url !== '') {
                $normalized[$url] = true;
            }
        }

        return $normalized;
    }

    /**
     * Normalize compact correlation hashes for persisted queue state.
     *
     * @param mixed $hashes Raw correlation hashes.
     * @return array<int,string>
     */
    private static function normalizeCorrelationHashes($hashes): array
    {
        if (!is_array($hashes)) {
            return [];
        }

        $normalized = [];
        foreach ($hashes as $hash) {
            $hash = is_string($hash) ? strtolower(trim($hash)) : '';
            if ($hash === '' || preg_match('/^[a-f0-9]{32}$/', $hash) !== 1) {
                continue;
            }

            $normalized[$hash] = true;
            if (count($normalized) >= self::MAX_CORRELATION_HISTORY) {
                break;
            }
        }

        return array_keys($normalized);
    }

    /**
     * Build the deterministic planning snapshot for an async full run.
     *
     * @param string $trigger Trigger key that initiated the full run.
     * @return array{intent_inputs:array<string,mixed>,intent_signature:string,intent_trigger_family:string,intent_hashes:array<string,string>}
     */
    private static function buildFullIntentSnapshot(string $trigger): array
    {
        $triggerFamily = self::normalizeTriggerFamily($trigger);
        $sitemaps = Repository::getSitemaps();
        $manualExclusions = Repository::getManualExclusions();
        $postTypeOrder = Repository::normalizePostTypeOrderArray(Repository::get('priorities.post_type_order'));
        $priorityPageIds = Repository::normalizePriorityPageIdsArray(Repository::get('priorities.priority_pages'));
        $maxUrls = Repository::getInt('advanced.max_urls_per_run', Settings::DEFAULT_MAX_URLS_PER_RUN);
        $languageAdapter = AdapterFactory::create();
        $priorityTargets = InvalidatorMultilingualBridge::resolvePriorityTargets(
            $priorityPageIds,
            self::resolveDefaultHomeUrl($languageAdapter)
        );
        $priorityPageIds = Repository::normalizePriorityPageIdsArray($priorityTargets['page_ids'] ?? []);
        $priorityHomeUrls = self::normalizeUrlList($priorityTargets['home_urls'] ?? []);
        $priorityPageFanoutEnabled = !empty($priorityTargets['fanout_enabled']);

        $languageAdapterKey = $languageAdapter !== null && method_exists($languageAdapter, 'getKey')
            ? sanitize_key((string) $languageAdapter->getKey())
            : '';
        $defaultLanguage = $languageAdapter?->getDefaultLanguage();
        $defaultLanguage = is_string($defaultLanguage) ? strtolower(trim($defaultLanguage)) : '';

        $intentInputs = [
            'plan_version' => self::PLAN_VERSION_ASYNC_FULL_V2,
            'sitemaps' => $sitemaps,
            'sitemaps_hash' => \Ekesto\CacheWarmup\Settings\SitemapSnapshotService::hashSitemapUrls($sitemaps),
            'manual_exclusions' => $manualExclusions,
            'priority_post_type_order' => $postTypeOrder,
            'priority_page_ids' => $priorityPageIds,
            'priority_home_urls' => $priorityHomeUrls,
            'priority_page_fanout_enabled' => $priorityPageFanoutEnabled,
            'max_urls_per_run' => max(1, $maxUrls),
            'multilingual_adapter_key' => $languageAdapterKey,
            'default_language' => $defaultLanguage,
            'trigger_family' => $triggerFamily,
        ];

        $intentHashes = [
            'sitemaps' => (string) $intentInputs['sitemaps_hash'],
            'manual_exclusions' => self::hashIntentValue($manualExclusions),
            'priority_post_type_order' => self::hashIntentValue($postTypeOrder),
            'priority_page_ids' => self::hashIntentValue($priorityPageIds),
            'priority_home_urls' => self::hashIntentValue($priorityHomeUrls),
            'multilingual' => self::hashIntentValue([
                $languageAdapterKey,
                $defaultLanguage,
                $priorityPageFanoutEnabled,
            ]),
            'trigger_family' => self::hashIntentValue($triggerFamily),
        ];

        return [
            'intent_inputs' => $intentInputs,
            'intent_signature' => self::hashIntentValue($intentInputs),
            'intent_trigger_family' => $triggerFamily,
            'intent_hashes' => $intentHashes,
        ];
    }

    /**
     * Resolve Home in the multilingual adapter's default language.
     *
     * @param LanguageAdapterInterface|null $languageAdapter Active warmup language adapter.
     * @return string
     */
    private static function resolveDefaultHomeUrl(?LanguageAdapterInterface $languageAdapter): string
    {
        $homeUrl = home_url('/');
        $defaultLanguage = $languageAdapter?->getDefaultLanguage();
        $defaultLanguage = is_string($defaultLanguage) ? trim($defaultLanguage) : '';
        if (!$languageAdapter instanceof LanguageContextSwitcherInterface || $defaultLanguage === '') {
            return $homeUrl;
        }

        $languageSwitched = false;
        try {
            $languageAdapter->switchLanguage($defaultLanguage);
            $languageSwitched = true;
            return home_url('/');
        } catch (\Throwable $throwable) {
            Debug::verbose('default Home language fallback', [
                'error' => $throwable->getMessage(),
            ]);
            return $homeUrl;
        } finally {
            if ($languageSwitched) {
                try {
                    $languageAdapter->switchLanguage(null);
                } catch (\Throwable $throwable) {
                    Debug::verbose('default Home language restore failed', [
                        'error' => $throwable->getMessage(),
                    ]);
                }
            }
        }
    }

    /**
     * Normalize full-trigger families so intent comparison is stable across entrypoints.
     *
     * @param string $trigger Trigger key.
     * @return string
     */
    private static function normalizeTriggerFamily(string $trigger): string
    {
        $trigger = trim($trigger);
        if ($trigger === '') {
            return 'manual';
        }

        if ($trigger === 'cache_invalidation') {
            return 'cache_invalidation';
        }

        if (str_contains($trigger, ':')) {
            [$family] = explode(':', $trigger, 2);
            $family = sanitize_key($family);
            if ($family !== '') {
                return $family;
            }
        }

        $trigger = sanitize_key($trigger);
        return $trigger !== '' ? $trigger : 'manual';
    }

    /**
     * Build immutable primary trigger identity for run birth metadata.
     *
     * @param array<string,mixed> $meta Trigger metadata captured at intake.
     * @param string $triggerKey Run trigger key.
     * @return array<string,mixed>
     */
    /**
     * Check whether the trigger originates from a cache adapter purge hook.
     *
     * Adapter-origin triggers must not lock primary identity at run birth
     * so that higher-fidelity ECI metadata can claim identity when it arrives.
     *
     * @param string $trigger Trigger key.
     * @return bool
     */
    private static function isAdapterTrigger(string $trigger): bool
    {
        return in_array($trigger, [
            'purge_single_url',
            'purge_front_page',
            'purge_pages',
            'purge_all',
        ], true);
    }

    /**
     * Public wrapper for buildPrimaryIdentity, used by Logger for deferred identity locking.
     *
     * @param array<string,mixed> $meta Trigger metadata.
     * @param string $triggerKey Run trigger key.
     * @return array<string,mixed>
     */
    public static function buildPrimaryIdentityPublic(array $meta, string $triggerKey): array
    {
        return self::buildPrimaryIdentity($meta, $triggerKey);
    }

    private static function buildPrimaryIdentity(array $meta, string $triggerKey): array
    {
        return [
            'label_locked' => true,
            'primary_trigger_key' => self::normalizePrimaryIdentityValue($triggerKey),
            'primary_trigger_class' => self::normalizePrimaryIdentityValue($meta['trigger_class'] ?? null),
            'primary_trigger_event' => self::normalizePrimaryIdentityValue($meta['trigger_event'] ?? null),
            'primary_trigger_post_type' => self::normalizePrimaryIdentityValue($meta['trigger_post_type'] ?? null),
            'primary_resolution' => self::normalizePrimaryIdentityValue($meta['resolution'] ?? null),
            'primary_reason' => self::normalizePrimaryIdentityValue($meta['reason'] ?? null),
        ];
    }

    /**
     * Normalize nullable primary identity values.
     *
     * @param mixed $value
     * @return string|null
     */
    private static function normalizePrimaryIdentityValue($value): ?string
    {
        if (!is_scalar($value) && $value !== null) {
            return null;
        }

        $normalized = trim((string) ($value ?? ''));
        return $normalized !== '' ? $normalized : null;
    }

    /**
     * Hash an intent fragment into a stable signature string.
     *
     * @param mixed $value
     * @return string
     */
    private static function hashIntentValue($value): string
    {
        return md5(wp_json_encode($value) ?: '');
    }

}
