<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Listeners;

use Ekesto\CacheInvalidator\Async\AsyncInvalidationQueue;
use Ekesto\CacheInvalidator\Integrations\Caching\AdapterManager;
use Ekesto\CacheInvalidator\Core\EmissionBuilder;
use Ekesto\CacheInvalidator\Core\Config;
use Ekesto\CacheInvalidator\Core\EmissionHelper;
use Ekesto\CacheInvalidator\Core\GlobalInvalidationExecutor;
use Ekesto\CacheInvalidator\Core\InvalidationEngine;
use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Core\PurgeCoordinator;
use Ekesto\CacheInvalidator\Core\TrustDecider;
use Ekesto\CacheInvalidator\Integrations\Multilingual\FanoutPolicy;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverFactory;
use Ekesto\CacheInvalidator\Integrations\Multilingual\ResolverContext;
use Ekesto\CacheInvalidator\Resolvers\ArchiveResolver;
use Ekesto\CacheInvalidator\Resolvers\RelationshipGraphResolver;
use Ekesto\CacheInvalidator\Selectors\SelectorResolver;
use Ekesto\CacheInvalidator\Support\Debug;
use Ekesto\CacheInvalidator\Taxonomy\TaxonomyPolicy;

/**
 * Listens to taxonomy hooks and triggers invalidation for affected posts.
 */
final class TaxonomyInvalidationListener
{
    /**
     * Register taxonomy hooks.
     *
     * @return void
     */
    public static function registerHooks(): void
    {
        add_action('set_object_terms', [self::class, 'handleSetObjectTerms'], 20, 6);
        add_action('edited_term', [self::class, 'handleEditedTerm'], 20, 3);
        add_action('delete_term', [self::class, 'handleDeleteTerm'], 20, 5);
    }

    /**
     * Handle term assignment changes on a post.
     *
     * @param int    $objectId
     * @param array  $terms
     * @param array  $ttIds
     * @param string $taxonomy
     * @param bool   $append
     * @param array  $oldTtIds
     * @return void
     */
    public static function handleSetObjectTerms(int $objectId, array $terms, array $ttIds, string $taxonomy, bool $append, array $oldTtIds): void
    {
        unset($terms);

        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $oldTtIds = self::normalizeTermTaxonomyIds($oldTtIds);
        $ttIds = self::normalizeTermTaxonomyIds($ttIds);
        $operation = $append ? 'append' : 'replace';

        if (($append && empty($ttIds)) || (!$append && $oldTtIds === $ttIds)) {
            return;
        }

        if (!self::isTaxonomyRelevant($taxonomy)) {
            return;
        }

        $post = get_post($objectId);
        if (!$post instanceof \WP_Post || $post->post_status !== 'publish') {
            return;
        }
        $postType = sanitize_key((string) $post->post_type);
        if ($postType === '') {
            return;
        }

        $watchedPostTypes = RelationshipGraphResolver::getWatchedPostTypes();
        if (!in_array($postType, $watchedPostTypes, true)) {
            return;
        }

        if (InvalidationEngine::wasProcessedInRequest((int) $post->ID)) {
            return;
        }

        if ($append) {
            $finalTtIds = wp_get_object_terms(
                (int) $post->ID,
                $taxonomy,
                [
                    'fields' => 'tt_ids',
                    'update_term_meta_cache' => false,
                ]
            );
            $finalTtIds = is_array($finalTtIds) ? self::normalizeTermTaxonomyIds($finalTtIds) : [];
            $dedupeKey = self::buildTaxonomyAssignmentDedupeKey((int) $post->ID, $taxonomy, $operation, $ttIds, $finalTtIds);
        } else {
            $dedupeKey = self::buildTaxonomyAssignmentDedupeKey((int) $post->ID, $taxonomy, $operation, $oldTtIds, $ttIds);
        }

        if (!InvalidationEngine::claimProcessedEventInRequest($dedupeKey)) {
            return;
        }

        self::invalidateForTaxonomyChange($post, 'term_assignment', $taxonomy);
    }

    /**
     * Normalize term-taxonomy ID sets for stable comparison and keying.
     *
     * @param array $ttIds
     * @return int[]
     */
    private static function normalizeTermTaxonomyIds(array $ttIds): array
    {
        $ttIds = array_map('absint', $ttIds);
        $ttIds = array_filter($ttIds, static fn(int $ttId): bool => $ttId > 0);
        $ttIds = array_unique($ttIds);
        sort($ttIds, SORT_NUMERIC);

        return array_values($ttIds);
    }

    /**
     * Build a deterministic taxonomy assignment event key.
     *
     * @param int    $postId
     * @param string $taxonomy
     * @param string $operation
     * @param int[]  $setA
     * @param int[]  $setB
     * @return string
     */
    private static function buildTaxonomyAssignmentDedupeKey(
        int $postId,
        string $taxonomy,
        string $operation,
        array $setA,
        array $setB
    ): string {
        return md5(
            $postId . '|'
            . sanitize_key($taxonomy) . '|'
            . sanitize_key($operation) . '|'
            . implode(',', $setA) . '|'
            . implode(',', $setB)
        );
    }

    /**
     * Handle term edit (name, slug, description change).
     *
     * @param int    $termId
     * @param int    $ttId
     * @param string $taxonomy
     * @return void
     */
    public static function handleEditedTerm(int $termId, int $ttId, string $taxonomy): void
    {
        unset($ttId);

        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        if (!self::isTaxonomyRelevant($taxonomy)) {
            return;
        }

        self::invalidatePostsForTerm($termId, $taxonomy, 'term_edited');
    }

    /**
     * Handle term deletion.
     *
     * @param int    $termId
     * @param int    $ttId
     * @param string $taxonomy
     * @param mixed  $deletedTerm
     * @param array  $objectIds
     * @return void
     */
    public static function handleDeleteTerm(int $termId, int $ttId, string $taxonomy, $deletedTerm, array $objectIds): void
    {
        unset($ttId, $deletedTerm);

        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        if (empty($objectIds)) {
            return;
        }

        if (!self::isTaxonomyRelevant($taxonomy)) {
            return;
        }

        $watchedPostTypes = RelationshipGraphResolver::getWatchedPostTypes();

        foreach ($objectIds as $objectId) {
            $post = get_post((int) $objectId);
            if (!$post instanceof \WP_Post || $post->post_status !== 'publish') {
                continue;
            }
            $postType = sanitize_key((string) $post->post_type);
            if ($postType === '') {
                continue;
            }

            if (!in_array($postType, $watchedPostTypes, true)) {
                continue;
            }

            if (InvalidationEngine::wasProcessedInRequest((int) $post->ID)) {
                continue;
            }

            self::invalidateForTaxonomyChange($post, 'term_deleted', $taxonomy);
        }
    }

    /**
     * Check if a taxonomy is relevant to any watched post type.
     *
     * @param string $taxonomy
     * @return bool
     */
    private static function isTaxonomyRelevant(string $taxonomy): bool
    {
        $taxObj = get_taxonomy($taxonomy);
        if (!$taxObj || !is_array($taxObj->object_type)) {
            return false;
        }

        $watchedPostTypes = RelationshipGraphResolver::getWatchedPostTypes();
        $intersection = array_intersect($taxObj->object_type, $watchedPostTypes);

        return !empty($intersection);
    }

    /**
     * Enqueue or run invalidation for a taxonomy term lifecycle change.
     *
     * @param int    $termId
     * @param string $taxonomy
     * @param string $reason
     * @return void
     */
    private static function invalidatePostsForTerm(int $termId, string $taxonomy, string $reason): void
    {
        $settingsRepository = InvalidatorRuntime::settingsRepository();
        $watchedPostTypes = RelationshipGraphResolver::getWatchedPostTypes();
        if (empty($watchedPostTypes)) {
            return;
        }

        $activeConfiguredPostTypes = [];
        $fullFlushConfiguredPostTypes = [];
        $immediateTargetPageIds = [];
        $tagsEnabledForAnyRelevantPostType = false;
        $asyncDecision = $settingsRepository->getAsyncSchedulingDecision();

        foreach ($settingsRepository->getConfiguredPostTypes() as $configuredPostType) {
            $postTypeConfig = $settingsRepository->getPostTypeConfig((string) $configuredPostType);
            if ($postTypeConfig === null || !($postTypeConfig['enabled'] ?? true)) {
                continue;
            }

            if (!taxonomy_exists($taxonomy) || !is_object_in_taxonomy((string) $configuredPostType, $taxonomy)) {
                continue;
            }

            $targetMode = $settingsRepository->getPostTypeTargetMode((string) $configuredPostType);
            if ($targetMode === 'full_flush') {
                $fullFlushConfiguredPostTypes[] = (string) $configuredPostType;
                continue;
            }

            if ($taxonomy === 'post_tag') {
                $tagsEnabledForPostType = TaxonomyPolicy::isTaxonomyEnabledForPostType((string) $configuredPostType, $taxonomy);
                if (!$tagsEnabledForPostType) {
                    Debug::log('[ECI] taxonomy skipped: post_tag (tags_disabled)');
                    continue;
                }
                $tagsEnabledForAnyRelevantPostType = true;
            }

            $propagation = $postTypeConfig['propagation'] ?? [];
            if (!($propagation['taxonomies_enabled'] ?? true)) {
                continue;
            }

            $activeConfiguredPostTypes[] = (string) $configuredPostType;

            if ($targetMode === 'selected') {
                $configuredSelectedPageIds = $settingsRepository->getPostTypeTargetPageIds((string) $configuredPostType);
                $immediateTargetPageIds = array_merge($immediateTargetPageIds, $configuredSelectedPageIds);
            } elseif ($targetMode === 'all') {
                $immediateTargetPageIds = array_merge(
                    $immediateTargetPageIds,
                    $settingsRepository->getEffectivePostTypeTargetPageIds((string) $configuredPostType)
                );
            }

        }

        $fullFlushConfiguredPostTypes = array_values(array_unique(array_filter($fullFlushConfiguredPostTypes)));
        if (!empty($fullFlushConfiguredPostTypes)) {
            $asyncMode = (string) ($asyncDecision['async_mode'] ?? 'off');
            if (Debug::verboseEnabled() || $asyncMode !== 'off') {
                Debug::log('invalidation execution decision', [
                    'entry_point' => 'taxonomy_term_listener',
                    'taxonomy' => $taxonomy,
                    'term_id' => $termId,
                    'async_mode' => $asyncMode,
                    'execution_mode' => 'sync',
                    'reason' => (string) ($asyncDecision['reason'] ?? 'async_disabled'),
                ]);
            }
            self::executeTaxonomyFullFlushEscalation(
                $reason,
                'taxonomy_term',
                $taxonomy,
                0,
                '',
                null,
                $fullFlushConfiguredPostTypes
            );
            return;
        }

        $activeConfiguredPostTypes = array_values(array_unique(array_filter($activeConfiguredPostTypes)));
        $immediateTargetPageIds = array_values(array_unique(array_filter(array_map('absint', $immediateTargetPageIds), static fn(int $id): bool => $id > 0)));
        if ($taxonomy === 'post_tag' && !$tagsEnabledForAnyRelevantPostType) {
            Debug::log('[ECI] taxonomy skipped: post_tag (tags_disabled)');
            return;
        }
        if (empty($activeConfiguredPostTypes)) {
            return;
        }

        $asyncMode = (string) ($asyncDecision['async_mode'] ?? 'off');
        if (Debug::verboseEnabled() || $asyncMode !== 'off') {
            Debug::log('invalidation execution decision', [
                'entry_point' => 'taxonomy_term_listener',
                'taxonomy' => $taxonomy,
                'term_id' => $termId,
                'async_mode' => $asyncMode,
                'execution_mode' => (string) ($asyncDecision['execution_mode'] ?? 'sync'),
                'reason' => (string) ($asyncDecision['reason'] ?? 'async_disabled'),
            ]);
        }

        $termArchiveOutcome = [
            'urls' => [],
            'has_selectors' => false,
            'intent' => SelectorResolver::INTENT_TARGETED,
            'targeted_empty' => false,
        ];
        $termArchiveEnabledForAnyPostType = false;
        foreach ($activeConfiguredPostTypes as $configuredPostType) {
            $postTypeConfig = $settingsRepository->getPostTypeConfig((string) $configuredPostType);
            if ($postTypeConfig === null || !($postTypeConfig['enabled'] ?? false)) {
                continue;
            }

            if (!empty($postTypeConfig['archives_enabled']) && !empty($postTypeConfig['taxonomy_archives_enabled'])) {
                $termArchiveEnabledForAnyPostType = true;
                break;
            }
        }

        if ($termArchiveEnabledForAnyPostType) {
            $termArchiveOutcome = ArchiveResolver::resolveForTermOutcome($taxonomy, $termId);
        }

        $termArchiveHasSelectors = !empty($termArchiveOutcome['has_selectors']);
        $termArchiveIntent = (string) ($termArchiveOutcome['intent'] ?? SelectorResolver::INTENT_TARGETED);
        if ($termArchiveHasSelectors && $termArchiveIntent === SelectorResolver::INTENT_GLOBAL) {
            $adapter = AdapterManager::getActiveAdapter();
            if ($adapter === null) {
                return;
            }

            Debug::log('taxonomy term selector escalation: reason=selector_all decision=global_only');
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                LanguageResolverFactory::create(),
                null,
                'taxonomy_' . $reason,
                [
                    'trigger_class' => 'taxonomy',
                    'trigger_kind' => 'taxonomy_term',
                    'trigger_event' => 'taxonomy_' . $reason,
                    'reason' => 'taxonomy_' . $reason,
                    'trigger_post_id' => 0,
                    'trigger_post_type' => '',
                    'taxonomy' => $taxonomy,
                    'intent' => SelectorResolver::INTENT_GLOBAL,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_all',
                ]
            );
            return;
        }

        $termArchiveTargetedSelectorPresent = $termArchiveHasSelectors
            && $termArchiveIntent === SelectorResolver::INTENT_TARGETED;
        $termArchiveUrls = is_array($termArchiveOutcome['urls'] ?? null) ? $termArchiveOutcome['urls'] : [];
        $termTrustedUrls = TrustDecider::trustedUrlsFromOriginUrls([
            TrustDecider::ORIGIN_EDITED_TERM_ARCHIVE => $reason === 'term_edited'
                ? [(string) ($termArchiveOutcome['term_url'] ?? '')]
                : [],
        ]);
        $termFinalCombinedTargetedEmpty = empty($immediateTargetPageIds) && empty($termArchiveUrls);

        if ($termArchiveTargetedSelectorPresent && $termFinalCombinedTargetedEmpty) {
            $adapter = AdapterManager::getActiveAdapter();
            if ($adapter === null) {
                return;
            }

            Debug::log('taxonomy term selector escalation: reason=selector_targeted_empty decision=global_only');
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                LanguageResolverFactory::create(),
                null,
                'taxonomy_' . $reason,
                [
                    'trigger_class' => 'taxonomy',
                    'trigger_kind' => 'taxonomy_term',
                    'trigger_event' => 'taxonomy_' . $reason,
                    'reason' => 'taxonomy_' . $reason,
                    'trigger_post_id' => 0,
                    'trigger_post_type' => '',
                    'taxonomy' => $taxonomy,
                    'intent' => SelectorResolver::INTENT_TARGETED,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_targeted_empty',
                ]
            );
            return;
        }

        if (!empty($asyncDecision['should_schedule'])) {
            Debug::log('Taxonomy term async enqueue', [
                'taxonomy' => $taxonomy,
                'term_id' => $termId,
                'reason' => $reason,
                'post_types' => $activeConfiguredPostTypes,
            ]);

            $correlationId = EmissionHelper::getOrCreateRequestCorrelationId();
            AsyncInvalidationQueue::enqueue([
                'trigger_kind' => 'taxonomy_term',
                'taxonomy' => $taxonomy,
                'term_id' => $termId,
                'reason' => 'taxonomy_' . $reason,
                'trigger_language' => null,
                'immediate_target_page_ids' => $immediateTargetPageIds,
                'immediate_target_urls' => $termArchiveUrls,
                'immediate_trusted_urls' => $termTrustedUrls,
                'correlation_id' => $correlationId,
            ]);
            AsyncInvalidationQueue::schedule((int) ($asyncDecision['delay_seconds'] ?? Config::DEFAULT_ASYNC_DELAY_SECONDS));
            Debug::log('async_enqueue', [
                'trigger_kind' => 'taxonomy_term',
                'entry_point' => 'taxonomy_term',
                'taxonomy' => $taxonomy,
                'term_id' => $termId,
                'reason' => 'taxonomy_' . $reason,
                'target_page_ids' => count($immediateTargetPageIds),
                'target_urls' => count($termArchiveUrls),
                'trusted_urls' => count($termTrustedUrls),
                'correlation_id' => $correlationId,
            ]);

            return;
        }

        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Event-driven taxonomy invalidation discovery is scoped to one term and watched post types, and returns IDs only.
        $posts = get_posts([
            'post_type' => array_values($watchedPostTypes),
            'tax_query' => [
                [
                    'taxonomy' => $taxonomy,
                    'terms' => $termId,
                    'field' => 'term_id',
                ],
            ],
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'fields' => 'ids',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_tax_query

        $handled = false;
        if (is_array($posts) && !empty($posts)) {
            Debug::log('Taxonomy term affects posts', [
                'reason' => $reason,
                'taxonomy' => $taxonomy,
                'term_id' => $termId,
                'post_count' => count($posts),
                'sample' => array_slice(array_map('intval', $posts), 0, 10),
            ]);

            foreach ($posts as $postId) {
                $postId = (int) $postId;
                if (InvalidationEngine::wasProcessedInRequest($postId)) {
                    continue;
                }

                $post = get_post($postId);
                if (!$post instanceof \WP_Post) {
                    continue;
                }

                $postHandled = self::invalidateForTaxonomyChange(
                    $post,
                    $reason,
                    $taxonomy,
                    $termArchiveUrls,
                    $termTrustedUrls
                );
                $handled = $handled || $postHandled;
            }
        }

        // Preserve the term target even when no eligible post needs processing.
        if (!$handled) {
            $archiveUrls = $termArchiveUrls;
            if (empty($archiveUrls)) {
                return;
            }

            $adapter = AdapterManager::getActiveAdapter();
            if ($adapter === null) {
                return;
            }

            $resolver = LanguageResolverFactory::create();
            $result = PurgeCoordinator::purgeTargets(
                [],
                $archiveUrls,
                $adapter,
                $resolver,
                null,
                [
                    'trigger_class' => 'taxonomy',
                    'trigger_kind' => 'taxonomy_term',
                    'trigger_event' => 'taxonomy_' . $reason,
                    'reason' => 'taxonomy_' . $reason,
                    'trigger_post_id' => 0,
                    'trigger_post_type' => '',
                ]
            );

            $emittedUrls = $result['emitted_urls'] ?? [];
            if (empty($emittedUrls)) {
                return;
            }
            $fanoutLanguages = TrustDecider::normalizeStringList((array) ($result['fanout_languages'] ?? []));
            $trustedUrls = TrustDecider::intersectWithUrls(
                FanoutPolicy::expandUrls($termTrustedUrls, $resolver, $fanoutLanguages),
                $emittedUrls
            );
            Debug::verbose('[ECI] untrusted origin', [
                'origin' => 'archive',
                'count' => count(array_diff($archiveUrls, $trustedUrls)),
            ]);
            $emissionPayload = EmissionBuilder::build($trustedUrls, $emittedUrls);

            EmissionHelper::emit($emissionPayload['urls'], [
                'source' => $adapter->getKey(),
                'source_label' => $adapter->getLabel(),
                'reason' => 'taxonomy_' . $reason,
                'trigger_class' => 'taxonomy',
                'trigger_event' => 'taxonomy_' . $reason,
                'trigger_post_id' => 0,
                'trigger_post_type' => '',
                'taxonomy' => $taxonomy,
                'groups' => [],
                'archives' => $archiveUrls,
                'purged_urls_directly' => $result['purged_urls_directly'] ?? [],
                'purge_skipped_urls' => $result['purge_skipped_urls'] ?? [],
                'fanout_mode' => $result['fanout_mode'] ?? 'same_language',
                'fanout_languages' => $fanoutLanguages,
                'synchronous_targeted_resolution' => true,
                'trusted_urls' => $emissionPayload['trusted_urls'],
            ]);

            return;
        }
    }

    /**
     * Trigger invalidation for a taxonomy-related change on a post.
     *
     * @param \WP_Post $post
     * @param string   $reason
     * @param string   $taxonomy
     * @param string[] $termArchiveUrls Edited term archive targets to retain.
     * @param string[] $termTrustedUrls Native edited term URLs eligible for trust.
     * @return bool Whether targeted emission or global invalidation handled the change.
     */
    private static function invalidateForTaxonomyChange(
        \WP_Post $post,
        string $reason,
        string $taxonomy,
        array $termArchiveUrls = [],
        array $termTrustedUrls = []
    ): bool {
        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            return false;
        }

        $settingsRepository = InvalidatorRuntime::settingsRepository();
        $asyncDecision = $settingsRepository->getAsyncSchedulingDecision();

        $postType = sanitize_key((string) $post->post_type);
        if ($postType === '') {
            return false;
        }

        $asyncMode = (string) ($asyncDecision['async_mode'] ?? 'off');
        if (Debug::verboseEnabled() || $asyncMode !== 'off') {
            Debug::log('invalidation execution decision', [
                'entry_point' => 'taxonomy_assign_listener',
                'taxonomy' => $taxonomy,
                'post_id' => (int) $post->ID,
                'async_mode' => $asyncMode,
                'execution_mode' => 'sync',
                'reason' => (string) ($asyncDecision['reason'] ?? 'async_disabled'),
            ]);
        }

        $resolver = LanguageResolverFactory::create();
        $triggerLanguage = null;
        if ($resolver !== null) {
            $triggerLanguage = $resolver->getTriggerLanguage((int) $post->ID);
        }

        $candidateConfiguredPostTypes = array_values(array_unique(array_merge(
            [$postType],
            RelationshipGraphResolver::resolveAffectedHosts($postType)
        )));

        $activeConfiguredPostTypes = [];
        $fullFlushConfiguredPostTypes = [];
        $selectedTargetPostIds = [];
        $targetPostIds = [];
        $archiveUrls = [];

        foreach ($candidateConfiguredPostTypes as $configuredPostType) {
            $postTypeConfig = $settingsRepository->getPostTypeConfig((string) $configuredPostType);
            if ($postTypeConfig === null || !($postTypeConfig['enabled'] ?? true)) {
                continue;
            }

            $targetMode = $settingsRepository->getPostTypeTargetMode((string) $configuredPostType);
            if ($targetMode === 'full_flush') {
                $fullFlushConfiguredPostTypes[] = (string) $configuredPostType;
                continue;
            }

            if ($taxonomy === 'post_tag' && !TaxonomyPolicy::isTaxonomyEnabledForPostType((string) $configuredPostType, $taxonomy)) {
                Debug::log('[ECI] taxonomy skipped: post_tag (tags_disabled)');
                continue;
            }

            $propagation = $postTypeConfig['propagation'] ?? [];
            if (!($propagation['taxonomies_enabled'] ?? true)) {
                continue;
            }

            $activeConfiguredPostTypes[] = (string) $configuredPostType;

            if ($targetMode === 'selected') {
                $configuredSelectedPageIds = $settingsRepository->getPostTypeTargetPageIds((string) $configuredPostType);
                $selectedTargetPostIds = array_merge($selectedTargetPostIds, $configuredSelectedPageIds);
                $targetPostIds = array_merge($targetPostIds, $configuredSelectedPageIds);
            } elseif ($targetMode === 'all') {
                $targetPostIds = array_merge(
                    $targetPostIds,
                    $settingsRepository->getEffectivePostTypeTargetPageIds((string) $configuredPostType)
                );
            }
        }

        $fullFlushConfiguredPostTypes = array_values(array_unique(array_filter($fullFlushConfiguredPostTypes)));
        if (!empty($fullFlushConfiguredPostTypes)) {
            self::executeTaxonomyFullFlushEscalation(
                $reason,
                'taxonomy_assign',
                $taxonomy,
                (int) $post->ID,
                $postType,
                $triggerLanguage,
                $fullFlushConfiguredPostTypes
            );
            return true;
        }

        $activeConfiguredPostTypes = array_values(array_unique(array_filter($activeConfiguredPostTypes)));
        if (empty($activeConfiguredPostTypes)) {
            return false;
        }

        $ptObj = get_post_type_object($postType);
        if ($ptObj && $ptObj->publicly_queryable) {
            $targetPostIds[] = (int) $post->ID;
        }

        $selectedTargetPostIds = array_values(array_unique(array_filter(array_map('absint', $selectedTargetPostIds), static fn(int $id): bool => $id > 0)));
        $targetPostIds = array_values(array_unique(array_filter(array_map('absint', $targetPostIds), static fn(int $id): bool => $id > 0)));

        $archiveSelectorOutcome = [
            'urls' => [],
            'has_selectors' => false,
            'intent' => SelectorResolver::INTENT_TARGETED,
            'targeted_empty' => false,
        ];
        $triggerPostTypeConfig = $settingsRepository->getPostTypeConfig($postType);
        $archiveExpansionEnabled =
            $triggerPostTypeConfig !== null
            && ($triggerPostTypeConfig['enabled'] ?? false)
            && !empty($triggerPostTypeConfig['archives_enabled']);

        if ($archiveExpansionEnabled) {
            $archiveSelectorOutcome = ArchiveResolver::resolveForPostOutcome(
                $post,
                !empty($triggerPostTypeConfig['taxonomy_archives_enabled'])
            );
            $archiveUrls = is_array($archiveSelectorOutcome['urls'] ?? null) ? $archiveSelectorOutcome['urls'] : [];
        }

        $archiveHasSelectors = !empty($archiveSelectorOutcome['has_selectors']);
        $archiveIntent = (string) ($archiveSelectorOutcome['intent'] ?? SelectorResolver::INTENT_TARGETED);
        if ($archiveHasSelectors && $archiveIntent === SelectorResolver::INTENT_GLOBAL) {
            Debug::log('taxonomy selector escalation: reason=selector_all decision=global_only');
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                'taxonomy_' . $reason,
                [
                    'trigger_class' => 'taxonomy',
                    'trigger_kind' => 'taxonomy_assign',
                    'trigger_event' => 'taxonomy_' . $reason,
                    'reason' => 'taxonomy_' . $reason,
                    'trigger_post_id' => (int) $post->ID,
                    'trigger_post_type' => $postType,
                    'intent' => SelectorResolver::INTENT_GLOBAL,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_all',
                ]
            );
            return true;
        }

        $archiveUrls = TrustDecider::normalizeUrls(array_merge($archiveUrls, $termArchiveUrls));
        $archiveTargetedSelectorPresent = $archiveHasSelectors
            && $archiveIntent === SelectorResolver::INTENT_TARGETED;
        $finalCombinedTargetedEmpty = empty($targetPostIds) && empty($archiveUrls);

        if ($archiveTargetedSelectorPresent && $finalCombinedTargetedEmpty) {
            Debug::log('taxonomy selector escalation: reason=selector_targeted_empty decision=global_only');
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                'taxonomy_' . $reason,
                [
                    'trigger_class' => 'taxonomy',
                    'trigger_kind' => 'taxonomy_assign',
                    'trigger_event' => 'taxonomy_' . $reason,
                    'reason' => 'taxonomy_' . $reason,
                    'trigger_post_id' => (int) $post->ID,
                    'trigger_post_type' => $postType,
                    'intent' => SelectorResolver::INTENT_TARGETED,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_targeted_empty',
                ]
            );
            return true;
        }

        $result = PurgeCoordinator::purgeTargets(
            $targetPostIds,
            $archiveUrls,
            $adapter,
            $resolver,
            $triggerLanguage,
            [
                'trigger_class' => 'taxonomy',
                'trigger_kind' => 'taxonomy_assign',
                'trigger_event' => 'taxonomy_' . $reason,
                'reason' => 'taxonomy_' . $reason,
                'trigger_post_id' => (int) $post->ID,
                'trigger_post_type' => $postType,
            ]
        );
        $purgedUrls = $result['emitted_urls'] ?? [];
        if (empty($purgedUrls)) {
            return false;
        }
        $fanoutLanguages = TrustDecider::normalizeStringList((array) ($result['fanout_languages'] ?? []));
        $trustedUrls = TrustDecider::trustedUrlsFromOriginUrls([
            TrustDecider::ORIGIN_SELECTED => TrustDecider::resolveTrustedUrlsForSelectedPostIds(
                $selectedTargetPostIds,
                $resolver,
                $fanoutLanguages
            ),
            TrustDecider::ORIGIN_EDITED_TERM_ARCHIVE => FanoutPolicy::expandUrls(
                $termTrustedUrls,
                $resolver,
                $fanoutLanguages
            ),
        ]);
        $trustedUrls = TrustDecider::intersectWithUrls($trustedUrls, $purgedUrls);
        Debug::verbose('[ECI] trust classification', [
            'trusted' => count($trustedUrls),
            'origin' => $termTrustedUrls === [] ? 'selected' : 'selected,edited_term_archive',
        ]);

        $context = [
            'source' => $adapter->getKey(),
            'source_label' => $adapter->getLabel(),
            'reason' => 'taxonomy_' . $reason,
            'trigger_class' => 'taxonomy',
            'trigger_event' => 'taxonomy_' . $reason,
            'trigger_post_id' => (int) $post->ID,
            'trigger_post_type' => $postType,
            'taxonomy' => $taxonomy,
            'groups' => $activeConfiguredPostTypes,
            'archives' => $archiveUrls,
            'purged_urls_directly' => $result['purged_urls_directly'] ?? [],
            'purge_skipped_urls' => $result['purge_skipped_urls'] ?? [],
            'fanout_mode' => $result['fanout_mode'] ?? 'same_language',
            'fanout_languages' => $fanoutLanguages,
            'synchronous_targeted_resolution' => true,
            'trusted_urls' => $trustedUrls,
        ];
        $emissionPayload = EmissionBuilder::build((array) ($context['trusted_urls'] ?? []), $purgedUrls);
        $context['trusted_urls'] = $emissionPayload['trusted_urls'];

        $context = array_merge(
            $context,
            ResolverContext::buildContext($resolver, $triggerLanguage, [
                'trigger_post_id' => (int) $post->ID,
                'trigger_post_type' => $postType,
            ])
        );

        EmissionHelper::emit($emissionPayload['urls'], $context);
        return true;
    }

    /**
     * Escalate a taxonomy trigger to global invalidation when any affected host uses full flush mode.
     *
     * Global invalidation execution is request-idempotent through GlobalInvalidationExecutor.
     *
     * @param string        $reason
     * @param string        $triggerKind
     * @param string        $taxonomy
     * @param int           $triggerPostId
     * @param string        $triggerPostType
     * @param string|null   $triggerLanguage
     * @param string[]      $fullFlushConfiguredPostTypes
     * @return void
     */
    private static function executeTaxonomyFullFlushEscalation(
        string $reason,
        string $triggerKind,
        string $taxonomy,
        int $triggerPostId = 0,
        string $triggerPostType = '',
        ?string $triggerLanguage = null,
        array $fullFlushConfiguredPostTypes = []
    ): void {
        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            return;
        }

        $reason = sanitize_key($reason);
        $reason = $reason === '' ? 'taxonomy_term_change' : $reason;
        $eventReason = sanitize_key('taxonomy_' . $reason);
        $triggerKind = sanitize_key($triggerKind);
        $taxonomy = sanitize_key($taxonomy);
        $triggerPostType = sanitize_key($triggerPostType);
        $fullFlushConfiguredPostTypes = array_values(array_unique(array_filter(array_map('strval', $fullFlushConfiguredPostTypes))));
        sort($fullFlushConfiguredPostTypes, SORT_STRING);

        Debug::log('taxonomy full_flush escalation', [
            'full_flush_escalation' => true,
            'source' => 'taxonomy',
            'trigger_kind' => $triggerKind,
            'taxonomy' => $taxonomy,
            'reason' => $eventReason,
            'post_types' => $fullFlushConfiguredPostTypes,
            'trigger_post_id' => $triggerPostId,
            'trigger_post_type' => $triggerPostType,
        ]);

        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            LanguageResolverFactory::create(),
            $triggerLanguage,
            $eventReason,
            [
                'trigger_class' => 'taxonomy',
                'trigger_kind' => $triggerKind,
                'trigger_event' => $eventReason,
                'reason' => $eventReason,
                'trigger_post_id' => max(0, $triggerPostId),
                'trigger_post_type' => $triggerPostType,
                'taxonomy' => $taxonomy,
                'groups' => $fullFlushConfiguredPostTypes,
                'full_flush' => true,
                'target_mode' => 'full_flush',
                'intent' => 'GLOBAL_INVALIDATION',
                'resolution' => 'GLOBAL_INVALIDATION',
                'fallback' => false,
                'fallback_reason' => '',
                'full_flush_escalation' => true,
                'full_flush_escalation_source' => 'taxonomy',
            ]
        );
    }

}
