<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup;

use Ekesto\CacheWarmup\Debug;
use Ekesto\CacheWarmup\Integrations\InvalidatorAdapterGate;
use Ekesto\CacheWarmup\Multilingual\AdapterFactory;
use Ekesto\CacheWarmup\Settings\Repository;

/**
 * Warmup triggers.
 * Consumes deterministic invalidation URL payloads emitted by Cache Invalidator (ECI).
 */
final class Triggers
{
    /** ECI confirms the post can emit a frontend target. */
    private const FRONTEND_ELIGIBILITY_ALLOW = 'allow';
    /** ECI confirms the post cannot emit a frontend target. */
    private const FRONTEND_ELIGIBILITY_DENY = 'deny';
    /** ECI is unavailable or unsafe to consult. */
    private const FRONTEND_ELIGIBILITY_NO_DECISION = 'no_decision';
    /** Prefix for short-lived pre-trash permalink capture. */
    private const PRE_TRASH_PERMALINK_TRANSIENT_PREFIX = 'ekesto_ecw_pre_trash_permalink_';
    /** Keep only long enough to bridge same-request and short async intake gaps. */
    private const PRE_TRASH_PERMALINK_TTL = 90;

    /** @var array<int,string> Request-local canonical pre-trash permalinks by post ID. */
    private static array $preTrashPermalinks = [];

    /**
     * Warmup master switch (plugin setting only).
     */
    private static function isWarmupEnabled(): bool
    {
        return Repository::isEnabled('general.enabled');
    }

    public static function register(): void
    {
        // invalidation urls from ekesto-cache-invalidator Plugin
        add_action('ekesto_ci_invalidation_urls', [self::class, 'handleInvalidationUrls'], 10, 2);
        add_action('wp_trash_post', [self::class, 'handlePreTrashPost'], 10, 2);
        add_action('save_post', [self::class, 'handleContentLifecycleSavePost'], 20, 3);
        add_action('deleted_post', [self::class, 'handleContentLifecycleDeletedPost'], 20, 2);
        add_action('transition_post_status', [self::class, 'handleContentLifecycleTransition'], 20, 3);
        add_action('created_term', [self::class, 'handleTaxonomyLifecycle'], 20, 3);
        add_action('edited_term', [self::class, 'handleTaxonomyLifecycle'], 20, 3);
        add_action('delete_term', [self::class, 'handleTaxonomyLifecycleDelete'], 20, 5);
        add_action('updated_option', [self::class, 'handleUpdatedOption'], 20, 3);
        add_action('init', [self::class, 'detectPostTypeStructureChange'], 100);
    }

    /**
     * Enqueue a full warmup run intent.
     *
     * Intake stays lightweight: we capture deterministic planning inputs now and let
     * the runner resolve sitemaps and materialize the final queue under the lease lock.
     */
    private static function enqueueFull(string $trigger, array $meta = []): void
    {
        Debug::verbose('Triggers::enqueueFull intent start', ['trigger' => $trigger]);

        if (!self::isWarmupEnabled()) {
            return;
        }

        Queue::enqueueFullIntent($trigger, $meta);
        Debug::log('Triggers::enqueueFull intent enqueued', [
            'trigger' => $trigger,
            'source' => 'intent',
        ]);
    }

    /**
     * Request a manual full refresh through ECI global invalidation ownership.
     *
     * @param string $trigger Trigger identifier for logging metadata.
     * @return bool True when the manual request signal was dispatched.
     */
    public static function enqueueManualFullWarmup(string $trigger = 'manual:settings_general'): bool
    {
        if ($trigger === '') {
            $trigger = 'manual:settings_general';
        }

        // Keep manual trigger_event keys deterministic and exact-match compatible
        // with TriggerKeyLabelMap::EXACT.
        $triggerEvent = sanitize_key(str_replace(':', '_', strtolower(trim($trigger))));
        if ($triggerEvent === '') {
            $triggerEvent = 'manual_settings_general';
        }

        $correlationId = function_exists('wp_generate_uuid4')
            ? ('ecw-' . wp_generate_uuid4())
            : ('ecw-' . md5(uniqid('ecw', true)));

        Debug::log('ECW manual full refresh requested via ECI', [
            'trigger' => $trigger,
            'trigger_event' => $triggerEvent,
            'correlation_id' => $correlationId,
        ]);

        if (has_action('ekesto_ci_manual_full_refresh') === false) {
            Debug::warn('ECW manual full refresh skipped: ECI system listener unavailable', [
                'trigger' => $trigger,
                'correlation_id' => $correlationId,
            ]);
            return false;
        }

        do_action('ekesto_ci_manual_full_refresh', [
            'trigger_class' => 'manual',
            'trigger_kind' => 'manual',
            'trigger_event' => $triggerEvent,
            'reason' => 'manual_full_refresh',
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'resolution' => 'GLOBAL_INVALIDATION',
            'intent' => 'GLOBAL_INVALIDATION',
            'correlation_id' => $correlationId,
        ]);

        return true;
    }

    /**
     * Enqueue warmups for URLs that were invalidated/purged by another component.
     *
     * @param array<int,string>   $urls
     * @param array<string,mixed> $context
     * @return void
     */
    public static function handleInvalidationUrls(array $urls, array $context): void
    {
        if (!self::isWarmupEnabled()) {
            return;
        }

        // Strict intake contract: context.urls is the only authoritative URL source.
        if (!isset($context['urls']) || !is_array($context['urls'])) {
            Debug::warn('intake skipped: invalid payload (missing context.urls)');
            return;
        }

        // Hook signature compatibility only; payload authority is context.urls.
        unset($urls);

        $skipCounts = [
            'not_in_sitemap' => 0,
            'membership_null_untrusted' => 0,
            'invalid_canonical' => 0,
            'structural_skipped' => 0,
            'deleted_self_url' => 0,
        ];

        // Canonicalize and dedupe every URL input with one shared contract.
        $canonicalizeUnique = static function (array $rawUrls, array &$rawByCanonical = []) use (&$skipCounts): array {
            $normalized = [];
            $seen = [];

            foreach ($rawUrls as $rawUrl) {
                $canon = Sitemap::canonicalizeUrl((string) $rawUrl);
                if ($canon === '') {
                    $skipCounts['invalid_canonical']++;
                    Debug::verbose('Triggers::handleInvalidationUrls skip invalid-canonical', [
                        'raw_url' => (string) $rawUrl,
                    ]);
                    continue;
                }

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

                $seen[$canon] = true;
                $rawByCanonical[$canon] = (string) $rawUrl;
                $normalized[] = $canon;
            }

            return $normalized;
        };

        $allRawByCanonical = [];
        $allUrls = $canonicalizeUnique($context['urls'], $allRawByCanonical);
        $canonicalUrls = $allUrls;
        $priorityUrls = $canonicalizeUnique(
            is_array($context['priority_urls'] ?? null) ? $context['priority_urls'] : []
        );
        $trustedUrls = $canonicalizeUnique(
            is_array($context['trusted_urls'] ?? null) ? $context['trusted_urls'] : []
        );

        $allSet = array_fill_keys($allUrls, true);

        $prioritySet = [];
        $ordered = [];
        foreach ($priorityUrls as $priorityUrl) {
            if (!isset($allSet[$priorityUrl]) || isset($prioritySet[$priorityUrl])) {
                continue;
            }

            $prioritySet[$priorityUrl] = true;
            $ordered[] = $priorityUrl;
        }

        foreach ($allUrls as $url) {
            if (isset($prioritySet[$url])) {
                continue;
            }

            $ordered[] = $url;
        }

        $trustedSource = (string) ($context['trusted_source'] ?? '');
        $sourceVerified = in_array($trustedSource, [
            'ekesto_ci_invalidation_urls',
            'cache_adapter_single_target',
        ], true);
        $trustedSet = [];
        if ($sourceVerified) {
            foreach ($trustedUrls as $trustedUrl) {
                if (!isset($allSet[$trustedUrl])) {
                    continue;
                }

                $trustedSet[$trustedUrl] = true;
            }

            if ($trustedSource === 'cache_adapter_single_target') {
                foreach ($canonicalUrls as $canon) {
                    $trustedSet[$canon] = true;
                }
            }
        }

        $deletedSelfUrl = '';
        $contextTriggerClass = sanitize_key((string) ($context['trigger_class'] ?? ''));
        $contextTriggerEvent = sanitize_key((string) ($context['trigger_event'] ?? ''));
        $contextTriggerPostId = absint($context['trigger_post_id'] ?? 0);
        if ($contextTriggerClass === 'post' && $contextTriggerEvent === 'deleted' && $contextTriggerPostId > 0) {
            $deletedSelfUrl = self::resolvePreTrashPermalink($contextTriggerPostId, true);
            if ($deletedSelfUrl === '') {
                $triggerPost = get_post($contextTriggerPostId);
                $permalink = $triggerPost instanceof \WP_Post
                    ? get_permalink($triggerPost)
                    : get_permalink($contextTriggerPostId);
                $deletedSelfUrl = self::canonicalizeDeletedSelfCandidate($permalink);
            }
        }
        if ($contextTriggerEvent === 'deleted') {
            Debug::log('ecw_deleted_self_filter_detected', [
                'trigger_event' => $contextTriggerEvent,
                'trigger_post_id' => $contextTriggerPostId,
                'deleted_self_url' => $deletedSelfUrl,
                'incoming_urls_count' => count($allUrls),
            ]);
        }

        $membershipSet = Sitemap::getMembershipSet();
        $membershipAvailable = ($membershipSet !== null);

        $membershipSource = null;
        $membershipDegraded = false;

        if ($membershipAvailable) {
            $membershipOption = get_option(Settings::MEMBERSHIP_OPTION, false);
            if (is_array($membershipOption)) {
                $rawSource = $membershipOption['source'] ?? null;
                if (is_string($rawSource) && in_array($rawSource, ['sitemap', 'fallback'], true)) {
                    $membershipSource = $rawSource;
                }

                $membershipDegraded = (($membershipOption['degraded'] ?? false) === true);
            }
        }

        $correlationId = trim((string) ($context['correlation_id'] ?? ''));

        static $loggedTriggerTrace = [];

        $shouldLogTriggerTrace = Debug::verboseEnabled()
            && ($correlationId === '' || !isset($loggedTriggerTrace[$correlationId]));

        if ($shouldLogTriggerTrace) {
            Debug::verbose('membership_source_used', [
                'source' => $membershipSource,
                'degraded' => $membershipDegraded,
                'membership_available' => $membershipAvailable,
            ]);

            Debug::verbose(sprintf(
                'intake: %d total, %d priority, %d trusted',
                count($allUrls),
                count($prioritySet),
                count($trustedSet)
            ));

            Debug::verbose('Triggers::handleInvalidationUrls', [
                'incoming_count' => count($allUrls),
                'language' => (string) ($context['language'] ?? ''),
                'reason' => $context['reason'] ?? '',
                'trigger_class' => $context['trigger_class'] ?? '',
                'trigger_event' => $context['trigger_event'] ?? '',
                'trigger_post_id' => $context['trigger_post_id'] ?? 0,
                'source' => $context['source'] ?? '',
                'correlation_id' => (string) ($context['correlation_id'] ?? ''),
            ]);

            if ($correlationId !== '') {
                $loggedTriggerTrace[$correlationId] = true;
            }
        }

        // Only warm when ECI reports an operational adapter.
        if (!InvalidatorAdapterGate::hasOperationalAdapter([
            'caller' => 'ecw_triggers',
            'reason' => 'warmup',
        ])) {
            Debug::info('Warmup skipped: no operational adapter available (ECI authority).');
            return;
        }

        $triggerMeta = self::buildTriggerMetaFromContext($context, count($allUrls), $correlationId);

        $resolution = strtoupper(sanitize_key((string) ($context['resolution'] ?? '')));
        if ($resolution === 'GLOBAL_INVALIDATION') {
            // Contract: GLOBAL_INVALIDATION is valid with context.urls = [].
            if ($shouldLogTriggerTrace) {
                Debug::verbose('intake global invalidation accepted', [
                    'url_count' => count($allUrls),
                    'correlation_id' => (string) ($context['correlation_id'] ?? ''),
                ]);
            }

            self::enqueueFullDebounced('cache_invalidation', array_merge($triggerMeta, [
                'resolution' => $context['resolution'] ?? null,
            ]));
            return;
        }

        $structuralSourceUrl = self::resolveStructuralSourceCanonicalUrl($context);
        $queueState = Queue::get();
        $iterateUrls = (Queue::isPreparingFullState($queueState) || Queue::isRunningFullState($queueState))
            ? array_reverse($ordered)
            : $ordered;
        $accepted = [];
        $acceptedTrusted = [];

        foreach ($iterateUrls as $canon) {
            if ($deletedSelfUrl !== '' && $canon === $deletedSelfUrl) {
                $skipCounts['deleted_self_url']++;
                continue;
            }

            if ($structuralSourceUrl !== '' && $canon === $structuralSourceUrl) {
                $skipCounts['structural_skipped']++;
                Debug::verbose('Triggers::handleInvalidationUrls skip structural-source', ['url' => $canon]);
                continue;
            }

            $isTrusted = isset($trustedSet[$canon]);

            if (!$isTrusted) {
                $membershipResult = Sitemap::inspectCachedMembership($canon);
                $membership = $membershipResult['membership'];
                if ($membership !== true) {
                    $rawUrl = $allRawByCanonical[$canon] ?? $canon;
                    $membershipCanonical = (string) ($membershipResult['canonical_url'] ?? $canon);
                    $matchContext = [
                        'match_type' => (string) ($membershipResult['match_type'] ?? 'none'),
                        'set_size' => (int) ($membershipResult['set_size'] ?? 0),
                    ];

                    if ($membership === false) {
                        $skipCounts['not_in_sitemap']++;
                        Debug::verbose('Triggers::handleInvalidationUrls skip not-in-sitemap', [
                            'raw_url' => $rawUrl,
                            'canonical_url' => $membershipCanonical,
                            'trusted' => false,
                            'membership_source' => $membershipSource,
                            'degraded' => $membershipDegraded,
                            'match_context' => $matchContext,
                        ]);
                    } else {
                        $skipCounts['membership_null_untrusted']++;
                        Debug::verbose('Triggers::handleInvalidationUrls skip membership=null untrusted', [
                            'raw_url' => $rawUrl,
                            'canonical_url' => $membershipCanonical,
                            'trusted' => false,
                            'membership_source' => $membershipSource,
                            'degraded' => $membershipDegraded,
                            'match_context' => $matchContext,
                        ]);
                    }
                    continue;
                }
            }

            if (!self::isTargetedUrlAdmitted($canon, 'cache_invalidation')) {
                continue;
            }

            $accepted[] = $canon;
            if ($isTrusted) {
                $acceptedTrusted[] = $canon;
            }
        }

        if ($contextTriggerEvent === 'deleted') {
            Debug::log('ecw_deleted_self_filter_summary', [
                'trigger_event' => $contextTriggerEvent,
                'trigger_post_id' => $contextTriggerPostId,
                'total_urls' => count($iterateUrls),
                'accepted' => count($accepted),
                'skipped_deleted_self' => $skipCounts['deleted_self_url'],
                'skipped_other_reasons' => max(0, array_sum($skipCounts) - $skipCounts['deleted_self_url']),
                'skip_counts' => $skipCounts,
            ]);
        }

        static $loggedIntakeBatch = [];

        $intakeBatchCorrelationId = $context['correlation_id'] ?? null;
        $intakeBatchCorrelationId = is_scalar($intakeBatchCorrelationId)
            ? trim((string) $intakeBatchCorrelationId)
            : '';

        if (
            $intakeBatchCorrelationId === ''
            || !isset($loggedIntakeBatch[$intakeBatchCorrelationId])
        ) {
            Debug::log('intake_batch', [
                'accepted' => count($accepted),
                'skipped' => $skipCounts,
            ]);

            if ($intakeBatchCorrelationId !== '') {
                $loggedIntakeBatch[$intakeBatchCorrelationId] = true;
            }
        }

        Queue::enqueueBatch($accepted, 'cache_invalidation', $triggerMeta, $acceptedTrusted);

        if ($shouldLogTriggerTrace) {
            if (array_sum($skipCounts) > 0) {
                Debug::verbose(sprintf(
                    'intake skips: not_in_sitemap=%d, membership_null_untrusted=%d, invalid_canonical=%d, structural_skipped=%d, deleted_self_url=%d',
                    $skipCounts['not_in_sitemap'],
                    $skipCounts['membership_null_untrusted'],
                    $skipCounts['invalid_canonical'],
                    $skipCounts['structural_skipped'],
                    $skipCounts['deleted_self_url']
                ));
            } else {
                Debug::verbose('intake skips: none');
            }
        }

        if ($shouldLogTriggerTrace) {
            $state = Queue::get();
            Debug::verbose('Triggers::handleInvalidationUrls after', [
                'run_id' => (string) ($state['run_id'] ?? ''),
                'urls_count' => Queue::getTotalUrls($state),
            ]);
        }
    }

    /**
     * Build queue/log trigger metadata from the ECI context.
     *
     * @param array<string,mixed> $context Incoming ECI context.
     * @param int                 $incomingUrlCount Number of incoming URLs.
     * @param string              $correlationId Correlation identifier.
     * @return array<string,mixed>
     */
    private static function buildTriggerMetaFromContext(array $context, int $incomingUrlCount, string $correlationId): array
    {
        $triggerMeta = [];

        $triggerClass = sanitize_key((string) ($context['trigger_class'] ?? ''));
        if ($triggerClass !== '') {
            $triggerMeta['trigger_class'] = $triggerClass;
        }

        $triggerEvent = self::resolvePrimaryTriggerEventFromContext(
            $context,
            sanitize_key((string) ($context['trigger_event'] ?? ''))
        );
        if ($triggerEvent !== '') {
            $triggerMeta['trigger_event'] = $triggerEvent;
        }

        $triggerPostType = sanitize_key((string) ($context['trigger_post_type'] ?? ''));
        if ($triggerPostType !== '') {
            $triggerMeta['trigger_post_type'] = $triggerPostType;
        }

        $reason = sanitize_key((string) ($context['reason'] ?? ''));
        if ($reason !== '') {
            $triggerMeta['reason'] = $reason;
        }

        $triggerKind = self::extractSingleContextKey($context['trigger_kinds'] ?? null, ['async']);
        if ($triggerKind !== '') {
            $triggerMeta['trigger_kind'] = $triggerKind;
        }

        $triggerMeta['incoming_url_count'] = $incomingUrlCount;
        if ($correlationId !== '') {
            $triggerMeta['correlation_id'] = $correlationId;
        }

        $correlationIds = [$correlationId];
        if (isset($context['merged_correlation_ids']) && is_array($context['merged_correlation_ids'])) {
            $correlationIds = array_merge($correlationIds, $context['merged_correlation_ids']);
        }

        $correlationIds = array_values(array_unique(array_filter(array_map(
            static function ($candidate): string {
                return is_scalar($candidate) ? trim((string) $candidate) : '';
            },
            $correlationIds
        ))));

        if ($correlationIds !== []) {
            $triggerMeta['correlation_ids'] = $correlationIds;
        }

        return $triggerMeta;
    }

    /**
     * Resolve the primary trigger event, promoting one specific async reason when safe.
     *
     * @param array<string,mixed> $context Incoming ECI context.
     * @param string              $triggerEvent Sanitized incoming trigger event.
     * @return string
     */
    private static function resolvePrimaryTriggerEventFromContext(array $context, string $triggerEvent): string
    {
        if ($triggerEvent !== '' && !self::isGenericAsyncTriggerEvent($triggerEvent)) {
            return $triggerEvent;
        }

        $reason = self::extractSingleContextKey($context['trigger_reasons'] ?? null, [
            'async_deep_invalidation',
        ]);

        return $reason !== '' ? $reason : $triggerEvent;
    }

    /**
     * Extract one unambiguous sanitized key from a context list.
     *
     * @param mixed             $value Raw context value.
     * @param array<int,string> $ignoredKeys Sanitized keys to ignore.
     * @return string
     */
    private static function extractSingleContextKey($value, array $ignoredKeys = []): string
    {
        if (!is_array($value)) {
            return '';
        }

        $ignored = [];
        foreach ($ignoredKeys as $ignoredKey) {
            $normalizedIgnoredKey = sanitize_key($ignoredKey);
            if ($normalizedIgnoredKey !== '') {
                $ignored[$normalizedIgnoredKey] = true;
            }
        }

        $seen = [];
        foreach ($value as $item) {
            if (!is_scalar($item)) {
                continue;
            }

            $key = sanitize_key((string) $item);
            if ($key === '' || isset($ignored[$key])) {
                continue;
            }

            $seen[$key] = true;
        }

        return count($seen) === 1 ? (string) array_key_first($seen) : '';
    }

    /**
     * Check whether a trigger event is generic async metadata.
     *
     * @param string $event Trigger event key.
     * @return bool
     */
    private static function isGenericAsyncTriggerEvent(string $event): bool
    {
        return $event === 'async_deep_invalidation';
    }

    /**
     * Schedule an async sitemap membership refresh after content updates.
     *
     * @param int      $postId Post ID.
     * @param \WP_Post $post Post object.
     * @param bool     $update Whether this is an update.
     * @return void
     */
    public static function handleContentLifecycleSavePost(int $postId, \WP_Post $post, bool $update): void
    {
        unset($post, $update);

        $postId = absint($postId);

        self::scheduleMembershipRefresh('post_lifecycle', current_filter(), [
            'post_id' => $postId,
            'post_type' => (string) get_post_type($postId),
        ]);
    }

    /**
     * Schedule an async sitemap membership refresh after content deletions.
     *
     * @param int           $postId Post ID.
     * @param \WP_Post|null $post Deleted post object when provided by WordPress.
     * @return void
     */
    public static function handleContentLifecycleDeletedPost(int $postId, ?\WP_Post $post = null): void
    {
        $postId = absint($postId);
        $permalink = self::resolvePreTrashPermalink($postId, true);
        if ($permalink === '') {
            $permalink = self::canonicalizeDeletedSelfCandidate(
                $post instanceof \WP_Post ? get_permalink($post) : get_permalink($postId)
            );
        }

        if ($permalink !== '') {
            $removeResult = Queue::removeUrl($permalink, 'deleted_post');
            Debug::log('ecw_deleted_post_queue_prune', [
                'post_id' => $postId,
                'url' => (string) ($removeResult['canonical_url'] ?? $permalink),
                'result' => !empty($removeResult['removed']) ? 'found' : 'not_found',
                'removed' => (int) ($removeResult['removed'] ?? 0),
                'state_removed' => (int) ($removeResult['state_removed'] ?? 0),
                'buffer_removed' => (int) ($removeResult['buffer_removed'] ?? 0),
            ]);
        } else {
            Debug::log('ecw_deleted_post_queue_prune', [
                'post_id' => $postId,
                'url' => '',
                'result' => 'no_permalink',
                'removed' => 0,
                'state_removed' => 0,
                'buffer_removed' => 0,
            ]);
        }

        self::scheduleMembershipRefresh('post_lifecycle', current_filter(), [
            'post_id' => $postId,
            'post_type' => $post instanceof \WP_Post ? (string) $post->post_type : (string) get_post_type($postId),
        ]);
    }

    /**
     * Capture the original permalink before WordPress changes a published post to trash.
     *
     * @param int    $postId Previous post ID.
     * @param string $previousStatus Status before trashing.
     * @return void
     */
    public static function handlePreTrashPost(int $postId, string $previousStatus = ''): void
    {
        $postId = absint($postId);
        if ($postId <= 0 || sanitize_key($previousStatus) !== 'publish') {
            return;
        }

        $canonical = self::canonicalizeDeletedSelfCandidate(get_permalink($postId));
        if ($canonical === '') {
            return;
        }

        self::$preTrashPermalinks[$postId] = $canonical;
        set_transient(
            self::preTrashPermalinkTransientKey($postId),
            $canonical,
            self::PRE_TRASH_PERMALINK_TTL
        );
    }

    /**
     * Schedule an async sitemap membership refresh after status transitions.
     *
     * @param string       $newStatus New status.
     * @param string       $oldStatus Old status.
     * @param \WP_Post|int $post Post object or ID.
     * @return void
     */
    public static function handleContentLifecycleTransition(string $newStatus, string $oldStatus, $post): void
    {
        $postObj = ($post instanceof \WP_Post) ? $post : get_post(absint($post));
        $postId = ($postObj instanceof \WP_Post) ? (int) $postObj->ID : absint($post);

        self::scheduleMembershipRefresh('post_lifecycle', current_filter(), [
            'post_id' => $postId,
            'post_type' => (string) get_post_type($postId),
            'old_status' => sanitize_key($oldStatus),
            'new_status' => sanitize_key($newStatus),
        ]);
    }

    /**
     * Schedule an async sitemap membership refresh after taxonomy changes.
     *
     * @param int $termId Term ID.
     * @param int $termTaxonomyId Term taxonomy ID.
     * @param string $taxonomy Taxonomy name.
     * @return void
     */
    public static function handleTaxonomyLifecycle(int $termId, int $termTaxonomyId, string $taxonomy): void
    {
        self::scheduleMembershipRefresh('taxonomy_lifecycle', current_filter(), [
            'term_id' => absint($termId),
            'term_taxonomy_id' => absint($termTaxonomyId),
            'taxonomy' => sanitize_key($taxonomy),
        ]);
    }

    /**
     * Schedule an async sitemap membership refresh after taxonomy deletions.
     *
     * @param int $termId Term ID.
     * @param int $termTaxonomyId Term taxonomy ID.
     * @param string $taxonomy Taxonomy name.
     * @param mixed $deletedTerm Deleted term payload.
     * @param array<int,int> $objectIds Related object IDs.
     * @return void
     */
    public static function handleTaxonomyLifecycleDelete(
        int $termId,
        int $termTaxonomyId,
        string $taxonomy,
        $deletedTerm,
        array $objectIds
    ): void {
        unset($deletedTerm, $objectIds);

        self::scheduleMembershipRefresh('taxonomy_lifecycle', current_filter(), [
            'term_id' => absint($termId),
            'term_taxonomy_id' => absint($termTaxonomyId),
            'taxonomy' => sanitize_key($taxonomy),
        ]);
    }

    /**
     * Schedule an async sitemap membership refresh after relevant option updates.
     *
     * @param string $optionName Updated option name.
     * @param mixed $oldValue Previous value.
     * @param mixed $value New value.
     * @return void
     */
    public static function handleUpdatedOption(string $optionName, $oldValue, $value): void
    {
        unset($oldValue, $value);

        if (Sitemap::shouldIgnoreOptionUpdate($optionName)) {
            return;
        }

        self::scheduleMembershipRefresh('option_update', current_filter(), [
            'option' => $optionName,
        ]);
    }

    /**
     * Detect post type structure changes that can alter sitemap membership.
     *
     * @return void
     */
    public static function detectPostTypeStructureChange(): void
    {
        $hash = self::buildPostTypeStructureHash();
        $storedHash = (string) get_option(Sitemap::POST_TYPE_HASH_OPTION, '');

        if ($hash === $storedHash) {
            return;
        }

        update_option(Sitemap::POST_TYPE_HASH_OPTION, $hash, false);
        self::scheduleMembershipRefresh('cpt_structure', 'init', [
            'hash_changed' => true,
        ]);
    }

    /**
     * Build a deterministic hash from registered post type sitemap-affecting fields.
     *
     * @return string Post type structure hash.
     */
    private static function buildPostTypeStructureHash(): string
    {
        $postTypes = get_post_types([], 'objects');
        if (!is_array($postTypes)) {
            return '';
        }

        $all = [];
        foreach ($postTypes as $object) {
            if (!$object instanceof \WP_Post_Type) {
                continue;
            }

            $rewrite = $object->rewrite;
            $rewriteSlug = '';
            if (is_array($rewrite) && isset($rewrite['slug'])) {
                $rewriteSlug = (string) $rewrite['slug'];
            }

            $row = [
                'has_archive' => (bool) $object->has_archive,
                'name' => (string) $object->name,
                'public' => (bool) $object->public,
                'publicly_queryable' => (bool) $object->publicly_queryable,
                'rewrite_slug' => $rewriteSlug,
            ];
            ksort($row);

            $all[(string) $object->name] = $row;
        }

        ksort($all);

        return md5((string) json_encode($all, JSON_UNESCAPED_SLASHES));
    }

    /**
     * Schedule a debounced membership rebuild for lifecycle churn.
     *
     * @param string $source Trigger source identifier.
     * @param string $hook Hook identifier.
     * @param array<string,mixed> $details Optional debug details.
     * @return void
     */
    private static function scheduleMembershipRefresh(string $source, string $hook, array $details = []): void
    {
        Sitemap::scheduleMembershipRebuild(false, [
            'source' => $source,
            'hook' => $hook,
            'details' => $details,
        ]);

        if (!Debug::verboseEnabled()) {
            return;
        }

        Debug::verbose('Triggers::scheduleMembershipRefresh', [
            'source' => sanitize_key($source),
            'hook' => sanitize_key($hook),
            'structural' => false,
            'details' => $details,
        ]);
    }

    /**
     * Canonicalize a deleted-self URL candidate and reject unsafe home/empty values.
     *
     * @param mixed $url Candidate URL.
     * @return string Canonical URL or empty string when unsafe.
     */
    private static function canonicalizeDeletedSelfCandidate($url): string
    {
        if (!is_scalar($url)) {
            return '';
        }

        $canonical = Sitemap::canonicalizeUrl((string) $url);
        if ($canonical === '') {
            return '';
        }

        $home = Sitemap::canonicalizeUrl(home_url('/'));
        if ($home !== '' && $canonical === $home) {
            return '';
        }

        return $canonical;
    }

    /**
     * Resolve a captured pre-trash permalink from request state or transient storage.
     *
     * @param int  $postId Post ID.
     * @param bool $deleteTransientAfterUse Whether to delete the transient when used.
     * @return string Canonical pre-trash URL or empty string.
     */
    private static function resolvePreTrashPermalink(int $postId, bool $deleteTransientAfterUse): string
    {
        $postId = absint($postId);
        if ($postId <= 0) {
            return '';
        }

        $key = self::preTrashPermalinkTransientKey($postId);
        if (isset(self::$preTrashPermalinks[$postId])) {
            if ($deleteTransientAfterUse) {
                delete_transient($key);
            }
            return self::canonicalizeDeletedSelfCandidate(self::$preTrashPermalinks[$postId]);
        }

        $stored = get_transient($key);
        $canonical = self::canonicalizeDeletedSelfCandidate($stored);
        if ($canonical !== '') {
            self::$preTrashPermalinks[$postId] = $canonical;
            if ($deleteTransientAfterUse) {
                delete_transient($key);
            }
            return $canonical;
        }

        if ($stored !== false && $deleteTransientAfterUse) {
            delete_transient($key);
        }

        return '';
    }

    /**
     * Build the post-scoped transient key for pre-trash permalink capture.
     *
     * @param int $postId Post ID.
     * @return string Transient key.
     */
    private static function preTrashPermalinkTransientKey(int $postId): string
    {
        return self::PRE_TRASH_PERMALINK_TRANSIENT_PREFIX . absint($postId);
    }

    /**
     * Resolve a permalink in the post's own language (adapter-safe).
     *
     * @param int $postId
     * @param string $postType
     * @return string
     */
    private static function resolvePostPermalink(int $postId, string $postType): string
    {
        $url = (string) get_permalink($postId);
        if ($url === '') {
            return '';
        }

        $adapter = AdapterFactory::create();
        if ($adapter === null || !$adapter->isActive()) {
            return $url;
        }

        $resolvedUrl = $adapter->resolvePostPermalink($postId, $postType, $url);
        return $resolvedUrl !== '' ? $resolvedUrl : $url;
    }

    /**
     * Ask ECI whether the post can emit a precise frontend target.
     *
     * Returns a tri-state decision so ECW can fail open when ECI is unavailable
     * or unsafe to call.
     *
     * @param \WP_Post $post Post object.
     * @return string
     */
    private static function getFrontendEligibilityDecision(\WP_Post $post): string
    {
        $visibilityClass = '\\Ekesto\\CacheInvalidator\\ContentVisibility';
        $method = 'canEmitPreciseTarget';

        if (
            !class_exists($visibilityClass)
            || !method_exists($visibilityClass, $method)
            || !is_callable([$visibilityClass, $method])
        ) {
            return self::FRONTEND_ELIGIBILITY_NO_DECISION;
        }

        try {
            return $visibilityClass::$method($post)
                ? self::FRONTEND_ELIGIBILITY_ALLOW
                : self::FRONTEND_ELIGIBILITY_DENY;
        } catch (\Throwable) {
            return self::FRONTEND_ELIGIBILITY_NO_DECISION;
        }
    }

    /**
     * Determine whether a post is a structural editor-only entity.
     *
     * Uses ECI visibility helpers when available. Ambiguous cases fail open so
     * ECW does not suppress valid public URLs by post type alone.
     *
     * @param \WP_Post $post Post object.
     * @return bool
     */
    private static function isStructuralEditorPost(\WP_Post $post): bool
    {
        $postType = sanitize_key((string) $post->post_type);
        if ($postType === '') {
            return false;
        }

        if (
            class_exists('\\Ekesto\\CacheInvalidator\\ContentVisibility')
            && method_exists('\\Ekesto\\CacheInvalidator\\ContentVisibility', 'shouldTreatAsStructuralReference')
        ) {
            return \Ekesto\CacheInvalidator\ContentVisibility::shouldTreatAsStructuralReference($post);
        }

        return in_array($postType, [
            'elementor_component',
            'wp_global_styles',
            'wp_block',
            'wp_pattern',
            'wp_navigation',
            'wp_template',
            'wp_template_part',
        ], true);
    }

    /**
     * Resolve the canonical structural source URL represented by invalidation context.
     *
     * @param array<string,mixed> $context Invalidation emission context.
     * @return string
     */
    private static function resolveStructuralSourceCanonicalUrl(array $context): string
    {
        $triggerPostId = absint($context['trigger_post_id'] ?? 0);
        if ($triggerPostId <= 0) {
            return '';
        }

        $post = get_post($triggerPostId);
        if (!$post instanceof \WP_Post || !self::isStructuralEditorPost($post)) {
            return '';
        }

        $triggerPostType = sanitize_key((string) $post->post_type);
        $url = self::resolvePostPermalink($triggerPostId, $triggerPostType);
        if ($url === '') {
            return '';
        }

        return Sitemap::canonicalizeUrl($url);
    }

    /**
     * Enqueue a full warm, but debounce duplicates that happen in the same PHP request.
     */
    private static function enqueueFullDebounced(string $trigger, array $meta = []): void
    {
        if (self::isDuplicateTriggerInSameRequest($trigger)) {
            Debug::log('Triggers::enqueueFullDebounced duplicate skipped', [
                'trigger' => $trigger,
            ]);
            return;
        }

        self::enqueueFull($trigger, $meta);
    }

    /**
     * Enqueue one already-canonical targeted URL when it passes narrow frontend admission.
     *
     * @param string $canonicalUrl Canonical target URL.
     * @param string $trigger Trigger identifier.
     * @param array<string,mixed> $meta Optional queue metadata.
     * @return void
     */
    private static function enqueueTargetedCanonicalUrl(string $canonicalUrl, string $trigger, array $meta = []): void
    {
        if (!self::isTargetedUrlAdmitted($canonicalUrl, $trigger)) {
            return;
        }

        Queue::enqueueSingle($canonicalUrl, $trigger, $meta);
    }

    /**
     * Reject known non-frontend targeted URLs before they enter the queue.
     *
     * This stays intentionally narrow: preview/admin/REST-style endpoints and the
     * Elementor library query form are rejected, while ordinary query-based frontend
     * URLs continue to pass.
     *
     * @param string $canonicalUrl Canonical target URL.
     * @param string $trigger Trigger identifier for debug context.
     * @return bool True when the URL can enter targeted warmup.
     */
    private static function isTargetedUrlAdmitted(string $canonicalUrl, string $trigger): bool
    {
        $parts = wp_parse_url($canonicalUrl);
        if (!is_array($parts)) {
            return false;
        }

        $path = isset($parts['path']) ? (string) $parts['path'] : '/';
        $path = '/' . ltrim($path, '/');

        parse_str((string) ($parts['query'] ?? ''), $queryArgs);
        $queryArgs = is_array($queryArgs) ? $queryArgs : [];

        $rejectedReason = '';

        if (str_starts_with($path, '/wp-admin/')) {
            $rejectedReason = 'admin_path';
        } elseif (str_starts_with($path, '/wp-json/')) {
            $rejectedReason = 'rest_path';
        } elseif (isset($queryArgs['rest_route'])) {
            $rejectedReason = 'rest_query';
        } elseif (isset($queryArgs['preview']) || isset($queryArgs['preview_id']) || isset($queryArgs['preview_nonce'])) {
            $rejectedReason = 'preview_query';
        } elseif (isset($queryArgs['elementor_library'])) {
            $rejectedReason = 'elementor_library_query';
        } else {
            $queryPostId = 0;

            foreach (['page_id', 'p', 'attachment_id'] as $idKey) {
                if (!isset($queryArgs[$idKey])) {
                    continue;
                }

                $queryPostId = absint($queryArgs[$idKey]);
                if ($queryPostId > 0) {
                    break;
                }
            }

            if ($queryPostId > 0) {
                $queryPost = get_post($queryPostId);
                if ($queryPost instanceof \WP_Post && !self::isPostPubliclyWarmable($queryPost)) {
                    $rejectedReason = 'non_public_query_target';
                }
            }
        }

        if ($rejectedReason !== '') {
            Debug::log('Triggers targeted URL rejected', [
                'trigger' => $trigger,
                'reason' => $rejectedReason,
                'url' => $canonicalUrl,
            ]);
            return false;
        }

        return true;
    }

    /**
     * Determine whether a post is eligible for front-end targeted warmup.
     *
     * @param \WP_Post $post Post object.
     * @return bool
     */
    private static function isPostPubliclyWarmable(\WP_Post $post): bool
    {
        $eligibility = self::getFrontendEligibilityDecision($post);
        if ($eligibility === self::FRONTEND_ELIGIBILITY_ALLOW) {
            return true;
        }

        if ($eligibility === self::FRONTEND_ELIGIBILITY_DENY) {
            return false;
        }

        if (function_exists('is_post_publicly_viewable')) {
            return is_post_publicly_viewable($post);
        }

        if (sanitize_key((string) $post->post_status) !== 'publish') {
            return false;
        }

        $postTypeObject = get_post_type_object((string) $post->post_type);
        return $postTypeObject instanceof \WP_Post_Type && !empty($postTypeObject->publicly_queryable);
    }

    /**
     * Returns true if we've already handled this trigger during THIS PHP request.
     *
     * Notes:
     * - This is *request-scoped*, not time-scoped:
     *   - It will NOT block a second user click (which is a new request).
     * - It WILL block duplicate invalidation events fired in the same request.
     */
    private static function isDuplicateTriggerInSameRequest(string $trigger): bool
    {
        static $seen = [];

        if (!isset($seen[$trigger])) {
            $seen[$trigger] = true;
            return false;
        }

        return true;
    }

}
