<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\Settings\Repository;
use Ekesto\CacheWarmup\Settings\SitemapSnapshotService;

/**
 * XML sitemap discovery and URL extraction.
 * Supports sitemap indexes that link to other sitemap XML files.
 */
final class Sitemap
{
    /** Maximum number of sitemap XML documents to follow. */
    private const MAX_SITEMAPS = 50;

    /** Maximum number of URLs to collect per full warmup. */
    private const MAX_URLS = 5000;

    /** Cron hook used to rebuild persistent sitemap membership state. */
    public const MEMBERSHIP_REBUILD_HOOK = 'ekesto_ecw_rebuild_membership';

    /** Schema version for persistent sitemap membership payloads. */
    private const MEMBERSHIP_VERSION = 1;

    /** Delay before async membership rebuild execution. */
    private const MEMBERSHIP_REBUILD_DELAY_SECONDS = 30;

    /** Short-lived lock key to avoid overlapping cron rebuild executions. */
    private const MEMBERSHIP_REBUILD_LOCK_KEY = 'ekesto_ecw_sitemap_rebuild_lock';

    /** Stores scheduled rebuild timestamp for execution timing diagnostics. */
    private const MEMBERSHIP_REBUILD_SCHEDULED_AT_KEY = 'ekesto_ecw_sitemap_rebuild_scheduled_at';

    /** Option flag indicating lifecycle changes require a full membership rebuild. */
    public const MEMBERSHIP_REBUILD_NEEDED_OPTION = 'ekesto_ecw_membership_rebuild_needed';

    /** Option key for tracked post type sitemap structure hash. */
    public const POST_TYPE_HASH_OPTION = 'ekesto_ecw_post_type_hash';

    /** Persisted failure code when every unavailable sitemap endpoint returned 404/410. */
    public const MEMBERSHIP_ERROR_NOT_FOUND = 'sitemap_not_found';

    /** Persisted failure code when some configured sitemap endpoints returned 404/410. */
    public const MEMBERSHIP_ERROR_PARTIAL_NOT_FOUND = 'sitemap_partial_not_found';

    /** Persisted failure code for retryable or otherwise unknown sitemap resolution failures. */
    public const MEMBERSHIP_ERROR_FETCH_FAILED = 'sitemap_fetch_failed';

    /** Tracks whether any sitemap fetch failed during the last resolveUrls() call. */
    private static int $fetchErrorCount = 0;

    /** Tracks sitemap fetch failures caused specifically by 404/410 responses. */
    private static int $notFoundErrorCount = 0;

    /** Tracks configured root sitemap URLs that returned 404/410. */
    private static int $configuredNotFoundErrorCount = 0;

    /** Tracks sitemap documents that were fetched but could not be parsed as sitemap XML. */
    private static int $parseErrorCount = 0;

    /** Tracks sitemap documents that were fetched and parsed successfully. */
    private static int $parsedDocumentCount = 0;

    /**
     * Returns true if the last resolveUrls() call encountered one or more fetch errors.
     * A fetch error means the server was unreachable, returned a non-2xx response,
     * or returned an empty response body.
     *
     * @return bool Whether the last resolution encountered fetch errors.
     */
    public static function hadFetchErrors(): bool
    {
        return self::$fetchErrorCount > 0;
    }

    /**
     * Return the stable failure code for the last unsuccessful resolveUrls() call.
     *
     * @return string|null Failure code, or null when no resolution error occurred.
     */
    private static function lastResolutionErrorCode(): ?string
    {
        if (self::$fetchErrorCount === 0 && self::$parseErrorCount === 0) {
            return null;
        }

        if (
            self::$fetchErrorCount > 0
            && self::$notFoundErrorCount === self::$fetchErrorCount
            && self::$configuredNotFoundErrorCount === self::$fetchErrorCount
            && self::$parseErrorCount === 0
        ) {
            return self::$parsedDocumentCount === 0
                ? self::MEMBERSHIP_ERROR_NOT_FOUND
                : self::MEMBERSHIP_ERROR_PARTIAL_NOT_FOUND;
        }

        return self::MEMBERSHIP_ERROR_FETCH_FAILED;
    }

    /**
     * Resolve URLs from configured sitemap URLs.
     *
     * @param array<int,string> $sitemapUrls One or more sitemap URLs.
     * @return array<int,string> Canonical, de-duplicated URLs.
     */
    public static function resolveUrls(array $sitemapUrls): array
    {
        self::$fetchErrorCount = 0;
        self::$notFoundErrorCount = 0;
        self::$configuredNotFoundErrorCount = 0;
        self::$parseErrorCount = 0;
        self::$parsedDocumentCount = 0;

        $sitemapUrls = array_values(array_filter(array_map('trim', $sitemapUrls), static fn($v) => $v !== ''));
        if (empty($sitemapUrls)) {
            return [];
        }

        Debug::verbose('Sitemap::resolveUrls starting', ['urls' => count($sitemapUrls)]);

        $configuredSitemapUrls = [];
        foreach ($sitemapUrls as $sitemapUrl) {
            $normalized = self::normalizeSitemapUrl($sitemapUrl);
            if ($normalized !== '') {
                $configuredSitemapUrls[$normalized] = true;
            }
        }

        $visited = [];
        $queue = $sitemapUrls;
        $urls = [];

        while (!empty($queue) && count($visited) < self::MAX_SITEMAPS && count($urls) < self::MAX_URLS) {
            $current = array_shift($queue);
            if ($current === null) {
                break;
            }

            $current = self::normalizeSitemapUrl($current);
            if ($current === '') {
                Debug::warn('Sitemap URL normalization failed');
                continue;
            }

            if (isset($visited[$current])) {
                Debug::verbose('Sitemap already visited', ['url' => $current]);
                continue;
            }
            $visited[$current] = true;

            Debug::verbose('Fetching sitemap', ['url' => $current]);

            $fetch = self::fetchXml($current);
            if ($fetch['body'] === null) {
                self::$fetchErrorCount++;
                if ($fetch['failure_type'] === 'not_found') {
                    self::$notFoundErrorCount++;
                    if (isset($configuredSitemapUrls[$current])) {
                        self::$configuredNotFoundErrorCount++;
                    }
                }
                Debug::error('Sitemap fetch failed', [
                    'url' => $current,
                    'failure_type' => $fetch['failure_type'],
                    'http_status' => $fetch['http_status'],
                ]);
                continue;
            }

            $body = $fetch['body'];

            Debug::verbose('Sitemap fetched', ['url' => $current, 'bytes' => strlen($body)]);

            $parsed = self::parseXml($body);
            if ($parsed === null) {
                self::$parseErrorCount++;
                Debug::warn('Sitemap XML parsing failed', ['url' => $current]);
                continue;
            }

            self::$parsedDocumentCount++;

            Debug::verbose('Sitemap parsed', ['type' => $parsed['type'], 'items' => count($parsed['locs'])]);

            if ($parsed['type'] === 'index') {
                foreach ($parsed['locs'] as $loc) {
                    $loc = self::normalizeSitemapUrl($loc);
                    if ($loc !== '' && !isset($visited[$loc])) {
                        $queue[] = $loc;
                    }
                }
                continue;
            }

            foreach ($parsed['locs'] as $loc) {
                $canon = self::canonicalizeWithContract($loc);
                if ($canon === '') {
                    Debug::warn('URL canonical conversion failed', ['loc' => $loc]);
                    continue;
                }
                $urls[$canon] = true;
                if (count($urls) >= self::MAX_URLS) {
                    break;
                }
            }
        }

        $urlList = array_keys($urls);

        Debug::log('Sitemap::resolveUrls collected', [
            'urls' => count($urlList),
            'fetchErrors' => self::$fetchErrorCount,
            'notFoundErrors' => self::$notFoundErrorCount,
            'parseErrors' => self::$parseErrorCount,
        ]);

        return $urlList;
    }

    /**
     * Backward-compatible canonical URL wrapper.
     *
     * @param string $url Raw URL.
     * @param string $homeHost Ignored legacy parameter kept for compatibility.
     * @return string Canonicalized URL or empty string when invalid.
     */
    public static function canonicalUrl(string $url, string $homeHost = ''): string
    {
        unset($homeHost);
        return self::canonicalizeWithContract($url);
    }

    /**
     * Normalize sitemap URL to an absolute URL when possible.
     */
    private static function normalizeSitemapUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        if (str_starts_with($url, '/')) {
            return home_url($url);
        }

        return $url;
    }

    /**
     * Fetch an XML document and retain a stable failure classification.
     *
     * @param string $url Sitemap URL.
     * @return array{body:string|null,failure_type:string|null,http_status:int}
     */
    private static function fetchXml(string $url): array
    {
        $response = wp_remote_get($url, [
            'timeout' => 15,
            'redirection' => 5,
            'headers' => [
                'Accept' => 'application/xml,text/xml;q=0.9,*/*;q=0.8',
                'User-Agent' => Queue::userAgent(),
            ],
        ]);

        if (is_wp_error($response)) {
            return [
                'body' => null,
                'failure_type' => 'transport_error',
                'http_status' => 0,
            ];
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        if ($code < 200 || $code >= 300) {
            return [
                'body' => null,
                'failure_type' => in_array($code, [404, 410], true) ? 'not_found' : 'http_error',
                'http_status' => $code,
            ];
        }

        $body = (string) wp_remote_retrieve_body($response);
        if ($body === '') {
            return [
                'body' => null,
                'failure_type' => 'empty_response',
                'http_status' => $code,
            ];
        }

        return [
            'body' => $body,
            'failure_type' => null,
            'http_status' => $code,
        ];
    }

    /**
     * Parse a sitemap XML document.
     *
     * @return array{type:'index'|'urlset', locs:array<int,string>}|null
     */
    private static function parseXml(string $xml): ?array
    {
        libxml_use_internal_errors(true);

        $doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NONET);
        if (!$doc instanceof \SimpleXMLElement) {
            return null;
        }

        $name = $doc->getName();

        if ($name === 'sitemapindex') {
            $locs = [];
            foreach ($doc->sitemap as $sitemap) {
                if (isset($sitemap->loc)) {
                    $locs[] = (string) $sitemap->loc;
                }
            }
            return ['type' => 'index', 'locs' => $locs];
        }

        if ($name === 'urlset') {
            $locs = [];
            foreach ($doc->url as $url) {
                if (isset($url->loc)) {
                    $locs[] = (string) $url->loc;
                }
            }
            return ['type' => 'urlset', 'locs' => $locs];
        }

        // Some sitemaps use namespaces; attempt a namespace-aware lookup.
        $namespaces = $doc->getNamespaces(true);
        if (!empty($namespaces)) {
            foreach ($namespaces as $prefix => $ns) {
                $doc->registerXPathNamespace($prefix !== '' ? $prefix : 'ns', $ns);
            }

            $indexNodes = $doc->xpath('//*[local-name()="sitemapindex"]');
            if (is_array($indexNodes) && !empty($indexNodes)) {
                $locs = [];
                $locNodes = $doc->xpath('//*[local-name()="sitemap"]/*[local-name()="loc"]');
                if (is_array($locNodes)) {
                    foreach ($locNodes as $node) {
                        $locs[] = (string) $node;
                    }
                }
                return ['type' => 'index', 'locs' => $locs];
            }

            $urlsetNodes = $doc->xpath('//*[local-name()="urlset"]');
            if (is_array($urlsetNodes) && !empty($urlsetNodes)) {
                $locs = [];
                $locNodes = $doc->xpath('//*[local-name()="url"]/*[local-name()="loc"]');
                if (is_array($locNodes)) {
                    foreach ($locNodes as $node) {
                        $locs[] = (string) $node;
                    }
                }
                return ['type' => 'urlset', 'locs' => $locs];
            }
        }

        return null;
    }

    /**
     * Backward-compatible canonical URL wrapper.
     *
     * @param string $url Raw URL.
     * @return string Canonicalized absolute URL, or empty string if invalid.
     */
    public static function canonicalizeUrl(string $url): string
    {
        return self::canonicalizeWithContract($url);
    }

    /**
     * Canonicalize a URL with the shared ECW URL contract.
     *
     * Contract:
     * - lower-case scheme + host
     * - strip fragments
     * - strip query strings
     * - normalize trailing slash via WordPress rules
     * - strip default ports only (80/443), keep non-default ports
     * - allow cross-host URLs
     *
     * @param string $url Raw URL from sitemap or intake paths.
     * @return string Canonicalized absolute URL, or empty string if invalid.
     */
    private static function canonicalizeWithContract(string $url): string
    {
        $url = trim($url);

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

        // Accept relative URLs for purge hooks.
        if (str_starts_with($url, '/')) {
            $url = home_url($url);
        }

        // Ensure we store a valid URL string according to WP’s URL sanitizer.
        $url = esc_url_raw($url);

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

        $parts = wp_parse_url($url);

        // We only handle absolute http/https URLs here.
        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
            return '';
        }

        $scheme = strtolower((string) $parts['scheme']);
        if ($scheme !== 'http' && $scheme !== 'https') {
            return '';
        }

        $host = strtolower((string) $parts['host']);

        $port = isset($parts['port']) ? (int) $parts['port'] : 0;
        // Drop default ports to avoid duplicates.
        if (($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
            $port = 0;
        }

        $path = isset($parts['path']) ? (string) $parts['path'] : '/';
        if ($path === '') {
            $path = '/';
        }

        $path = '/' . ltrim($path, '/');
        $path = preg_replace('#/+#', '/', $path) ?? $path;
        $path = user_trailingslashit($path);

        $canonical = $scheme . '://' . $host;

        if ($port > 0) {
            $canonical .= ':' . $port;
        }

        $canonical .= $path;

        return $canonical;
    }

    /**
     * Cached sitemap URL set for this request.
     *
     * @var array<string,bool>|null
     */
    private static ?array $sitemapUrlSet = null;

    /**
     * Prime and persist sitemap URL membership set from already-resolved URLs.
     *
     * @param array<int,string> $urls Resolved sitemap URLs.
     * @return void
     */
    public static function primeSitemapUrlSetCache(array $urls): void
    {
        $set = self::buildSitemapUrlSetFromUrls($urls);
        self::persistSitemapUrlSet($set, $urls, 'sitemap', false, null);
    }

    /**
     * Checks whether a URL exists in the resolved sitemap URL set.
     *
     * @param string $url Absolute URL.
     * @return bool
     */
    public static function isUrlInSitemap(string $url): bool
    {
        $canon = self::canonicalizeWithContract($url);
        if ($canon === '') {
            self::logMembershipDebug('invalid-canonical', [
                'input_url' => $url,
            ]);
            return false;
        }

        $set = self::getSitemapUrlSet();
        $initialSetSize = count($set);
        $refreshed = false;

        while (true) {
            $matchType = null;
            if (self::containsUrlInSet($canon, $set, $matchType)) {
                if ($refreshed) {
                    self::logMembershipDebug('refresh-hit', [
                        'input_url' => $url,
                        'canonical_url' => $canon,
                        'match_type' => $matchType,
                        'initial_set_size' => $initialSetSize,
                        'refreshed_set_size' => count($set),
                    ]);
                }

                return true;
            }

            if ($refreshed) {
                self::logMembershipDebug('miss-after-refresh', [
                    'input_url' => $url,
                    'canonical_url' => $canon,
                    'initial_set_size' => $initialSetSize,
                    'refreshed_set_size' => count($set),
                    'host_path_keys' => self::hostPathMembershipKeys($canon),
                ]);
                return false;
            }

            $refreshed = true;
            $set = self::getSitemapUrlSet(true);
        }
    }

    /**
     * Check cached sitemap membership without triggering any live sitemap rebuild.
     *
     * @param string $url Absolute URL.
     * @return bool|null True/false when cached membership data exists, null when unavailable.
     */
    public static function isUrlInSitemapCachedOnly(string $url): ?bool
    {
        $result = self::inspectCachedMembership($url);

        return is_bool($result['membership']) ? $result['membership'] : null;
    }

    /**
     * Inspect cached sitemap membership with diagnostic match context.
     *
     * @param string $url Absolute URL.
     * @return array{
     *     membership:bool|null,
     *     canonical_url:string,
     *     match_type:string,
     *     set_size:int
     * }
     */
    public static function inspectCachedMembership(string $url): array
    {
        $canon = self::canonicalizeWithContract($url);
        if ($canon === '') {
            self::logMembershipDebug('cached-only-invalid-canonical', [
                'input_url' => $url,
            ]);
            return [
                'membership' => false,
                'canonical_url' => '',
                'match_type' => 'invalid_canonical',
                'set_size' => 0,
            ];
        }

        $set = self::getSitemapUrlSetCachedOnly();
        if ($set === null) {
            self::logMembershipDebug('cached-only-unavailable', [
                'input_url' => $url,
                'canonical_url' => $canon,
            ]);
            return [
                'membership' => null,
                'canonical_url' => $canon,
                'match_type' => 'cache_unavailable',
                'set_size' => 0,
            ];
        }

        $matchType = null;
        $matched = self::containsUrlInSet($canon, $set, $matchType);

        self::logMembershipDebug($matched ? 'cached-only-hit' : 'cached-only-miss', [
            'input_url' => $url,
            'canonical_url' => $canon,
            'match_type' => $matchType,
            'set_size' => count($set),
        ]);

        return [
            'membership' => $matched,
            'canonical_url' => $canon,
            'match_type' => is_string($matchType) && $matchType !== ''
                ? $matchType
                : ($matched ? 'exact' : 'none'),
            'set_size' => count($set),
        ];
    }

    /**
     * Build (or load) the sitemap URL membership set from request memory/option state.
     *
     * Heavy rebuild work is async-only and must not run in the request lifecycle.
     *
     * @param bool $forceRefresh Whether to request an async refresh and bypass request cache.
     * @return array<string,bool> Canonical URL membership set.
     */
    private static function getSitemapUrlSet(bool $forceRefresh = false): array
    {
        if (!$forceRefresh && self::$sitemapUrlSet !== null) {
            return self::$sitemapUrlSet;
        }

        if ($forceRefresh) {
            self::scheduleMembershipRebuild(false);
        }

        $cached = self::getSitemapUrlSetCachedOnly();
        return $cached ?? [];
    }

    /**
     * Load cached sitemap membership data without rebuilding from live sitemap XML.
     *
     * @return array<string,bool>|null Membership set, or null when no cached source exists.
     */
    private static function getSitemapUrlSetCachedOnly(): ?array
    {
        if (self::$sitemapUrlSet !== null) {
            return self::$sitemapUrlSet;
        }

        $set = self::getMembershipSet();
        return $set;
    }

    /**
     * Return the persisted membership set as a fast lookup map.
     *
     * Access is restricted to admin/cron/authenticated REST contexts to avoid
     * any frontend option reads.
     *
     * @return array<string,bool>|null Membership set map, or null when unavailable.
     */
    public static function getMembershipSet(): ?array
    {
        if (!self::isAllowedContext()) {
            self::logContextBlocked('getMembershipSet');
            return null;
        }

        if (self::$sitemapUrlSet !== null) {
            return self::$sitemapUrlSet;
        }

        $saved = get_option(Settings::MEMBERSHIP_OPTION, false);
        if ($saved === false) {
            if (Repository::getSitemaps() !== [] && !wp_next_scheduled(self::MEMBERSHIP_REBUILD_HOOK)) {
                self::scheduleMembershipRebuild(true);
            }
            return null;
        }

        $validation = self::validateMembershipOption($saved);
        if (!$validation['valid']) {
            self::logMembershipCorruption($validation['reason'], $saved);

            if (!wp_next_scheduled(self::MEMBERSHIP_REBUILD_HOOK)) {
                self::scheduleMembershipRebuild(true);
            }

            return null;
        }

        $keys = array_values(array_filter(
            $saved['set'],
            static fn($key): bool => is_string($key) && $key !== ''
        ));

        $flipped = array_flip($keys);
        self::$sitemapUrlSet = array_fill_keys(array_keys($flipped), true);
        return self::$sitemapUrlSet;
    }

    /**
     * Return the canonical URL list persisted with membership state.
     *
     * Access is restricted to admin/cron/authenticated REST contexts to avoid
     * any frontend option reads.
     *
     * @return array<int,string> Canonical URLs or an empty list when unavailable.
     */
    public static function getMembershipUrls(): array
    {
        if (!self::isAllowedContext()) {
            self::logContextBlocked('getMembershipUrls');
            return [];
        }

        $saved = get_option(Settings::MEMBERSHIP_OPTION, false);
        if ($saved === false) {
            return [];
        }

        $validation = self::validateMembershipOption($saved);
        if (!$validation['valid']) {
            self::logMembershipCorruption($validation['reason'], $saved);

            if (!wp_next_scheduled(self::MEMBERSHIP_REBUILD_HOOK)) {
                self::scheduleMembershipRebuild(true);
            }

            return [];
        }

        $urls = [];
        foreach ($saved['urls'] as $url) {
            $canon = self::canonicalizeWithContract((string) $url);
            if ($canon === '') {
                continue;
            }

            $urls[$canon] = true;
        }

        return array_keys($urls);
    }

    /**
     * Persist membership state in request memory and the non-autoloaded option.
     *
     * @param array<string,bool> $set Tolerant membership key set.
     * @param array<int,string> $urls Canonical URL list for fallback preparation.
     * @param string $source Membership source tag.
     * @param bool $degraded Whether the snapshot uses degraded fallback data.
     * @param string|null $lastError Optional last rebuild error.
     * @param string|null $lastErrorCode Optional stable last rebuild failure code.
     * @return void
     */
    private static function persistSitemapUrlSet(
        array $set,
        array $urls,
        string $source = 'sitemap',
        bool $degraded = false,
        ?string $lastError = null,
        ?string $lastErrorCode = null
    ): void {
        self::$sitemapUrlSet = $set;

        $canonicalUrls = [];
        foreach ($urls as $url) {
            $canon = self::canonicalizeWithContract((string) $url);
            if ($canon === '') {
                continue;
            }

            if (isset($canonicalUrls[$canon])) {
                continue;
            }

            $canonicalUrls[$canon] = true;
        }

        $urlList = array_keys($canonicalUrls);
        $payload = [
            'version' => self::MEMBERSHIP_VERSION,
            'set' => array_keys($set),
            'urls' => $urlList,
            'url_count' => count($urlList),
            'built_at' => gmdate('c'),
            'sitemaps_hash' => SitemapSnapshotService::hashSitemapUrls(Repository::getSitemaps()),
            'source' => $source,
            'degraded' => $degraded,
            'last_error' => $lastError,
            'last_error_code' => $lastErrorCode,
        ];

        update_option(Settings::MEMBERSHIP_OPTION, $payload, false);
        $sizeBytes = strlen(serialize($payload));

        Debug::log('Sitemap::membership_persisted', [
            'count' => count($urlList),
            'source' => $source,
            'degraded' => $degraded,
        ]);
        Debug::log('[Sitemap] membership_size_bytes', [
            'bytes' => $sizeBytes,
            'approx_kb' => (int) round($sizeBytes / 1024),
            'url_count' => count($urlList),
            'set_size' => count($set),
            'source' => $source,
        ]);
        Debug::log('[Sitemap] membership_state_snapshot', [
            'source' => $source,
            'degraded' => $degraded,
            'url_count' => count($urlList),
            'set_size' => count($set),
            'hash' => (string) ($payload['sitemaps_hash'] ?? ''),
        ]);
    }

    /**
     * Schedule a debounced async membership rebuild.
     *
     * @param bool $force Whether to schedule even when another rebuild is already queued.
     * @param array<string,mixed> $context Small trigger-origin context for debug logs.
     * @return void
     */
    public static function scheduleMembershipRebuild(bool $force = false, array $context = []): void
    {
        $scheduleContext = self::normalizeScheduleContext($context);

        if (!$force) {
            update_option(self::MEMBERSHIP_REBUILD_NEEDED_OPTION, '1', false);
        }

        if (!$force && wp_next_scheduled(self::MEMBERSHIP_REBUILD_HOOK)) {
            return;
        }

        // WP-Cron execution is traffic-driven and non-deterministic; this sets earliest eligibility.
        $scheduledAt = time() + self::MEMBERSHIP_REBUILD_DELAY_SECONDS;
        wp_schedule_single_event(
            $scheduledAt,
            self::MEMBERSHIP_REBUILD_HOOK
        );
        set_transient(self::MEMBERSHIP_REBUILD_SCHEDULED_AT_KEY, (string) $scheduledAt, DAY_IN_SECONDS);

        Debug::log('[Sitemap] rebuild_scheduled', [
            'delay' => self::MEMBERSHIP_REBUILD_DELAY_SECONDS,
            'trigger_source' => $scheduleContext['source'],
            'trigger_hook' => $scheduleContext['hook'],
            'trigger_details' => $scheduleContext['details'],
        ]);
    }

    /**
     * Replace pending sitemap rebuilds with one immediately eligible async retry.
     *
     * @param array<string,mixed> $context Small trigger-origin context for debug logs.
     * @return void
     */
    public static function requestImmediateMembershipRebuild(array $context = []): void
    {
        $scheduleContext = self::normalizeScheduleContext($context);
        $scheduledAt = time();

        update_option(self::MEMBERSHIP_REBUILD_NEEDED_OPTION, '1', false);
        wp_clear_scheduled_hook(self::MEMBERSHIP_REBUILD_HOOK);
        wp_schedule_single_event($scheduledAt, self::MEMBERSHIP_REBUILD_HOOK);
        set_transient(self::MEMBERSHIP_REBUILD_SCHEDULED_AT_KEY, (string) $scheduledAt, DAY_IN_SECONDS);

        Debug::log('[Sitemap] rebuild_scheduled', [
            'delay' => 0,
            'trigger_source' => $scheduleContext['source'],
            'trigger_hook' => $scheduleContext['hook'],
            'trigger_details' => $scheduleContext['details'],
        ]);

        if (function_exists('spawn_cron')) {
            spawn_cron();
        }
    }

    /**
     * Normalize schedule context to small scalar-only debug fields.
     *
     * @param array<string,mixed> $context Raw context.
     * @return array{source:string,hook:string,details:array<string,string|int|float|bool|null>}
     */
    private static function normalizeScheduleContext(array $context): array
    {
        $source = isset($context['source']) && is_scalar($context['source'])
            ? sanitize_key((string) $context['source'])
            : 'unknown';
        $hook = isset($context['hook']) && is_scalar($context['hook'])
            ? sanitize_key((string) $context['hook'])
            : 'unknown';

        $details = [];
        $rawDetails = $context['details'] ?? [];
        if (is_array($rawDetails)) {
            foreach ($rawDetails as $key => $value) {
                if (!is_string($key) || $key === '' || (!is_scalar($value) && $value !== null)) {
                    continue;
                }

                $details[sanitize_key($key)] = $value;
            }
        }

        return [
            'source' => $source !== '' ? $source : 'unknown',
            'hook' => $hook !== '' ? $hook : 'unknown',
            'details' => $details,
        ];
    }

    /**
     * Determine whether an option update should be ignored for membership scheduling.
     *
     * @param string $optionName Updated option name.
     * @return bool True when the option is internal/noisy and should not schedule.
     */
    public static function shouldIgnoreOptionUpdate(string $optionName): bool
    {
        $optionName = trim($optionName);
        if ($optionName === '') {
            return true;
        }

        if (str_starts_with($optionName, '_transient_')) {
            return true;
        }

        if (str_starts_with($optionName, '_site_transient_')) {
            return true;
        }

        if (str_starts_with($optionName, 'cron')) {
            return true;
        }

        if (str_starts_with($optionName, 'ekesto_')) {
            return true;
        }

        return false;
    }

    /**
     * Execute the async membership rebuild callback.
     *
     * @return void
     */
    public static function executeMembershipRebuild(): void
    {
        $scheduledAtRaw = get_transient(self::MEMBERSHIP_REBUILD_SCHEDULED_AT_KEY);
        $scheduledAt = is_numeric($scheduledAtRaw) ? (int) $scheduledAtRaw : null;
        $executedAt = time();
        $rebuildNeeded = get_option(self::MEMBERSHIP_REBUILD_NEEDED_OPTION, false) === '1';

        Debug::log('[Sitemap] rebuild_execution_timing', [
            'scheduled_at' => $scheduledAt,
            'executed_at' => $executedAt,
            'delta_seconds' => ($scheduledAt !== null) ? max(0, $executedAt - $scheduledAt) : null,
            'expected_delay' => self::MEMBERSHIP_REBUILD_DELAY_SECONDS,
        ]);

        if (get_transient(self::MEMBERSHIP_REBUILD_LOCK_KEY)) {
            Debug::warn('[Sitemap] rebuild_skipped_lock_active', []);
            return;
        }

        set_transient(self::MEMBERSHIP_REBUILD_LOCK_KEY, '1', 30);
        $startedAt = microtime(true);

        try {
            $sitemaps = Repository::getSitemaps();
            $existing = get_option(Settings::MEMBERSHIP_OPTION, false);
            $existingValidation = self::validateMembershipOption($existing);
            if (!$existingValidation['valid'] && $existing !== false) {
                self::logMembershipCorruption($existingValidation['reason'], $existing);
            }
            $previousHash = $existingValidation['valid'] ? (string) ($existing['sitemaps_hash'] ?? '') : '';

            Debug::log('[Sitemap] rebuild_start', [
                'source_config_count' => count($sitemaps),
                'previous_hash' => $previousHash,
            ]);

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

            $newHash = SitemapSnapshotService::hashSitemapUrls($sitemaps);

            // Config-only rebuilds may short-circuit; lifecycle-driven rebuilds must refresh membership.
            if (!$rebuildNeeded && $existingValidation['valid'] && $newHash !== '' && $previousHash === $newHash) {
                Debug::log('[Sitemap] rebuild_skipped_hash_match', [
                    'new_hash' => $newHash,
                    'previous_hash' => $previousHash,
                ]);
                return;
            }

            $urls = self::resolveUrls($sitemaps);
            $resolutionErrorCode = self::lastResolutionErrorCode();
            if ($urls !== []) {
                $set = self::buildSitemapUrlSetFromUrls($urls);
                $partialError = $resolutionErrorCode !== null
                    ? 'Sitemap refresh partially failed; using available sitemap URLs'
                    : null;
                self::persistSitemapUrlSet(
                    $set,
                    $urls,
                    'sitemap',
                    false,
                    $partialError,
                    $resolutionErrorCode
                );
                Debug::log('[Sitemap] rebuild_success', [
                    'url_count' => count($urls),
                    'set_size' => count($set),
                    'source' => 'sitemap',
                    'degraded' => false,
                    'partial_failure' => $resolutionErrorCode !== null,
                    'failure_code' => $resolutionErrorCode,
                    'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
                ]);
                return;
            }

            if ($resolutionErrorCode !== null) {
                $fallbackUrls = self::getFallbackUrlsForRebuild();
                $fallbackError = 'Sitemap refresh failed; using published pages fallback';

                if ($fallbackUrls !== []) {
                    if (!$existingValidation['valid'] || !empty($existing['degraded'])) {
                        Debug::warn('[Sitemap] rebuild_fallback_used', [
                            'reason' => $resolutionErrorCode,
                            'fallback_count' => count($fallbackUrls),
                        ]);
                        $fallbackSet = self::buildSitemapUrlSetFromUrls($fallbackUrls);
                        self::persistSitemapUrlSet(
                            $fallbackSet,
                            $fallbackUrls,
                            'fallback',
                            true,
                            $fallbackError,
                            $resolutionErrorCode
                        );
                        Debug::log('[Sitemap] rebuild_success', [
                            'url_count' => count($fallbackUrls),
                            'set_size' => count($fallbackSet),
                            'source' => 'fallback',
                            'degraded' => true,
                            'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
                        ]);
                    } else {
                        self::updateMembershipLastError(
                            'Sitemap refresh failed; using previous membership cache',
                            $resolutionErrorCode
                        );
                        Debug::warn('[Sitemap] fallback_skipped_existing_valid_cache', [
                            'existing_degraded' => false,
                            'attempted_fallback_count' => count($fallbackUrls),
                            'reason' => $resolutionErrorCode,
                        ]);
                    }
                    return;
                }

                self::updateMembershipLastError(
                    'Sitemap fetch failed and fallback URLs were empty',
                    $resolutionErrorCode
                );
                Debug::error('[Sitemap] rebuild_failed', [
                    'reason' => $resolutionErrorCode,
                    'had_fetch_errors' => self::hadFetchErrors(),
                    'fallback_urls_empty' => true,
                ]);
                return;
            }

            self::persistSitemapUrlSet([], [], 'sitemap', false, null);
            Debug::log('[Sitemap] rebuild_success', [
                'url_count' => 0,
                'set_size' => 0,
                'source' => 'sitemap',
                'degraded' => false,
                'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
            ]);
        } catch (\Throwable $throwable) {
            self::updateMembershipLastError(
                'Sitemap rebuild failed unexpectedly',
                self::MEMBERSHIP_ERROR_FETCH_FAILED
            );
            Debug::error('[Sitemap] rebuild_failed', [
                'reason' => 'exception',
                'had_fetch_errors' => self::hadFetchErrors(),
                'message' => $throwable->getMessage(),
            ]);
        } finally {
            delete_transient(self::MEMBERSHIP_REBUILD_LOCK_KEY);
            delete_transient(self::MEMBERSHIP_REBUILD_SCHEDULED_AT_KEY);
            delete_option(self::MEMBERSHIP_REBUILD_NEEDED_OPTION);
        }
    }

    /**
     * Return fallback URLs used for degraded membership rebuilds.
     *
     * @return array<int,string> Canonical fallback URLs.
     */
    private static function getFallbackUrlsForRebuild(): array
    {
        $postTypes = get_post_types([
            'public' => true,
            'publicly_queryable' => true,
        ], 'names');

        if (!is_array($postTypes)) {
            $postTypes = [];
        }

        $postTypes = array_values(array_filter(
            array_map(static fn($postType): string => sanitize_key((string) $postType), $postTypes),
            static fn(string $postType): bool => $postType !== '' && $postType !== 'attachment'
        ));

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

        $query = new \WP_Query([
            'post_type' => $postTypes,
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'orderby' => 'date',
            'order' => 'DESC',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        $urls = [];
        foreach ($query->posts as $post) {
            if (!$post instanceof \WP_Post) {
                continue;
            }

            $permalink = get_permalink($post->ID);
            if (!$permalink) {
                continue;
            }

            $canon = self::canonicalizeWithContract((string) $permalink);
            if ($canon === '' || isset($urls[$canon])) {
                continue;
            }

            $urls[$canon] = true;
        }

        wp_reset_postdata();

        return array_keys($urls);
    }

    /**
     * Update only the last rebuild error fields on an existing valid membership payload.
     *
     * @param string $error Error message.
     * @param string $errorCode Stable failure code.
     * @return void
     */
    private static function updateMembershipLastError(string $error, string $errorCode): void
    {
        $saved = get_option(Settings::MEMBERSHIP_OPTION, false);
        $validation = self::validateMembershipOption($saved);
        if (!$validation['valid']) {
            return;
        }

        $saved['last_error'] = $error;
        $saved['last_error_code'] = $errorCode;
        update_option(Settings::MEMBERSHIP_OPTION, $saved, false);
    }

    /**
     * Guard membership option reads to safe execution contexts only.
     *
     * @return bool
     */
    private static function isAllowedContext(): bool
    {
        return wp_doing_cron()
            || is_admin()
            || (defined('REST_REQUEST') && REST_REQUEST && is_user_logged_in());
    }

    /**
     * Validate membership option schema integrity.
     *
     * @param mixed $data Raw option payload.
     * @return array{valid:bool,reason:string}
     */
    private static function validateMembershipOption(mixed $data): array
    {
        if (!is_array($data)) {
            return [
                'valid' => false,
                'reason' => 'invalid_structure',
            ];
        }

        if (
            !array_key_exists('version', $data)
            || !array_key_exists('set', $data)
            || !array_key_exists('urls', $data)
        ) {
            return [
                'valid' => false,
                'reason' => 'missing_keys',
            ];
        }

        if ((int) $data['version'] !== self::MEMBERSHIP_VERSION) {
            return [
                'valid' => false,
                'reason' => 'version_mismatch',
            ];
        }

        if (!is_array($data['set']) || !is_array($data['urls'])) {
            return [
                'valid' => false,
                'reason' => 'invalid_structure',
            ];
        }

        return [
            'valid' => true,
            'reason' => '',
        ];
    }

    /**
     * Log structured corruption diagnostics for membership option payloads.
     *
     * @param string $reason Corruption reason key.
     * @param mixed $payload Raw payload snapshot.
     * @return void
     */
    private static function logMembershipCorruption(string $reason, mixed $payload): void
    {
        $snapshot = wp_json_encode($payload);
        if (!is_string($snapshot)) {
            $snapshot = '';
        }

        if ($snapshot !== '' && strlen($snapshot) > 500) {
            $snapshot = substr($snapshot, 0, 500) . '...(truncated)';
        }

        $context = [
            'reason' => $reason,
            'option_key' => Settings::MEMBERSHIP_OPTION,
        ];

        if ($snapshot !== '') {
            $context['snapshot'] = $snapshot;
        }

        Debug::error('[Sitemap] membership_corrupt_detected', $context);
    }

    /**
     * Emit a debug-only context-blocked trace when membership access is guarded.
     *
     * @param string $method Method name.
     * @return void
     */
    private static function logContextBlocked(string $method): void
    {
        if (!Debug::verboseEnabled()) {
            return;
        }

        Debug::verbose('[Sitemap] membership_access_blocked_context', [
            'method' => $method,
            'is_admin' => is_admin(),
            'is_ajax' => wp_doing_ajax(),
            'is_cron' => wp_doing_cron(),
            'is_rest' => (defined('REST_REQUEST') && REST_REQUEST),
            'is_user_logged_in' => is_user_logged_in(),
        ]);
    }

    /**
     * Build a canonical sitemap URL membership set from resolved URLs.
     *
     * @param array<int,string> $urls Resolved sitemap URLs.
     * @return array<string,bool> Canonical URL membership set.
     */
    private static function buildSitemapUrlSetFromUrls(array $urls): array
    {
        $set = [];

        foreach ($urls as $u) {
            $c = self::canonicalizeWithContract((string) $u);
            if ($c === '') {
                continue;
            }

            $set[$c] = true;

            // Add tolerant variants for URLs without query strings.
            if (strpos($c, '?') === false) {
                $set[trailingslashit($c)] = true;
                $set[untrailingslashit($c)] = true;

                foreach (self::hostPathMembershipKeys($c) as $membershipKey) {
                    $set[$membershipKey] = true;
                }
            }
        }

        return $set;
    }

    /**
     * Check whether a canonical URL is present in a sitemap membership set.
     *
     * @param string $canon Canonical absolute URL.
     * @param array<string,bool> $set Sitemap membership set.
     * @param string|null $matchType Matching strategy used when found.
     * @return bool True when URL exists in set.
     */
    private static function containsUrlInSet(string $canon, array $set, ?string &$matchType = null): bool
    {
        $matchType = null;

        if (isset($set[$canon])) {
            $matchType = 'exact';
            return true;
        }

        // Tolerate trailing slash differences for URLs without a query string.
        if (strpos($canon, '?') === false) {
            $trail = trailingslashit($canon);
            if (isset($set[$trail])) {
                $matchType = 'trailing_slash';
                return true;
            }

            $untrail = untrailingslashit($canon);
            if (isset($set[$untrail])) {
                $matchType = 'untrailing_slash';
                return true;
            }

            foreach (self::hostPathMembershipKeys($canon) as $membershipKey) {
                if (isset($set[$membershipKey])) {
                    $matchType = 'host_path';
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Build scheme-insensitive host+path membership keys for a canonical URL.
     *
     * @param string $canonical Canonical absolute URL.
     * @return array<int,string> Membership keys.
     */
    private static function hostPathMembershipKeys(string $canonical): array
    {
        $parts = wp_parse_url($canonical);
        if (!is_array($parts) || empty($parts['host'])) {
            return [];
        }

        $host = strtolower((string) $parts['host']);
        $port = isset($parts['port']) ? (int) $parts['port'] : 0;
        if ($port > 0) {
            $host .= ':' . $port;
        }

        $path = isset($parts['path']) ? (string) $parts['path'] : '/';
        if ($path === '') {
            $path = '/';
        }

        $path = preg_replace('#/+#', '/', $path) ?? $path;

        $keys = [];
        $keys[] = 'ecw_hp::' . $host . '|' . $path;
        $keys[] = 'ecw_hp::' . $host . '|' . trailingslashit($path);
        $keys[] = 'ecw_hp::' . $host . '|' . untrailingslashit($path);

        return array_values(array_unique($keys));
    }

    /**
     * Log sitemap membership diagnostics in debug mode.
     *
     * @param string $event Short event key.
     * @param array<string,mixed> $context Diagnostic context.
     * @return void
     */
    private static function logMembershipDebug(string $event, array $context): void
    {
        Debug::verbose('Sitemap::isUrlInSitemap ' . $event, $context);
    }

}
