<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Core;

use Ekesto\CacheInvalidator\Async\AsyncInvalidationQueue;
use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Integrations\Builders\BuilderDetector;
use Ekesto\CacheInvalidator\Integrations\Caching\AdapterManager;
use Ekesto\CacheInvalidator\Integrations\Caching\CacheAdapterInterface;
use Ekesto\CacheInvalidator\ContentVisibility;
use Ekesto\CacheInvalidator\Integrations\Multilingual\CurrentLanguageResolverInterface;
use Ekesto\CacheInvalidator\Integrations\Multilingual\FanoutPolicy;
use Ekesto\CacheInvalidator\Integrations\Multilingual\Internal\ObjectSiblingResolver;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverFactory;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverInterface;
use Ekesto\CacheInvalidator\Integrations\Multilingual\ResolverContext;
use Ekesto\CacheInvalidator\Integrations\Multilingual\ResolverMetadataInterface;
use Ekesto\CacheInvalidator\Resolvers\ArchiveResolver;
use Ekesto\CacheInvalidator\Resolvers\PostTypeExpansionResolver;
use Ekesto\CacheInvalidator\Resolvers\RelationshipGraphResolver;
use Ekesto\CacheInvalidator\Resolvers\RelationshipResolver;
use Ekesto\CacheInvalidator\Selectors\PostTargetMappingResolver;
use Ekesto\CacheInvalidator\Selectors\SelectorResolver;
use Ekesto\CacheInvalidator\Core\Elementor\ElementorDocumentClassification;
use Ekesto\CacheInvalidator\Core\Elementor\ElementorIntegrationRegistry;
use Ekesto\CacheInvalidator\Core\WooCommerce\WooCommerceIntegrationRegistry;
use Ekesto\CacheInvalidator\Integrations\Structural\Core\CoreStructuralEntityClassifier;
use Ekesto\CacheInvalidator\Integrations\Structural\Core\StructuralContainerResolver;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Invalidation engine.
 *
 * Detects content changes for watched post types and purges configured target pages.
 * Supports deep invalidation via relationship propagation and taxonomy hooks.
 * After purging, emits the purged URLs so a warmup system can re-warm exactly what was invalidated.
 *
 * Emission policy:
 * - Synchronous mode: emits once at end of processing.
 * - Async mode: does NOT emit; defers to AsyncInvalidationProcessor which emits once.
 */
final class InvalidationEngine
{
    /** @var array<int|string,true> Post IDs and event keys already invalidated in this request. */
    private static array $processedInRequest = [];

    /** Prefix for atomic, cross-request content lifecycle correlation leases. */
    private const OPTION_CONTENT_LIFECYCLE_CORRELATION_PREFIX = 'ekesto_ca_content_correlation_';

    /** Lifetime shared with the existing lifecycle duplicate window. */
    private const CONTENT_LIFECYCLE_CORRELATION_TTL = 5;

    /**
     * Elementor library template types that are condition-driven presentation templates.
     *
     * @var string[]
     */
    private const ELEMENTOR_PRESENTATION_TEMPLATE_TYPES = [
        'archive',
        'error-404',
        'footer',
        'header',
        'popup',
        'search-results',
        'single-page',
        'single-post',
        'ehp-footer',
        'ehp-header',
        'floating-buttons',
    ];

    /**
     * Early lifecycle sources that must be suppressed for presentation templates.
     *
     * @var string[]
     */
    private const ELEMENTOR_PRESENTATION_EARLY_SOURCES = [
        'transition_post_status',
    ];

    /**
     * Persistent previous-condition baseline for Elementor presentation templates.
     */
    private const ELEMENTOR_CONDITIONS_BASELINE_META_KEY = '_ekesto_eci_last_conditions';

    /**
     * Check if a post was already invalidated in this request.
     *
     * @param int $postId
     * @return bool
     */
    public static function wasProcessedInRequest(int $postId): bool
    {
        return isset(self::$processedInRequest[$postId]);
    }

    /**
     * Claim an event-scoped invalidation key for the current request.
     *
     * @param string $eventKey Deterministic event key.
     * @return bool True when this call claimed the key; false when already claimed or invalid.
     */
    public static function claimProcessedEventInRequest(string $eventKey): bool
    {
        $eventKey = trim($eventKey);
        if ($eventKey === '') {
            return false;
        }

        $requestKey = 'event:' . $eventKey;
        if (isset(self::$processedInRequest[$requestKey])) {
            return false;
        }

        self::$processedInRequest[$requestKey] = true;
        return true;
    }

    /**
     * Registers WordPress hooks for invalidation triggers.
     *
     * @return void
     */
    public static function registerHooks(): void
    {
        add_action('transition_post_status', [self::class, 'handleTransitionPostStatus'], 10, 3);
        add_action('save_post', [self::class, 'handleSavePost'], 20, 3);
        add_action('deleted_post', [self::class, 'handleDeletedPost'], 20, 2);
        add_action('untrashed_post', [self::class, 'handleUntrashedPost'], 20, 1);
    }

    /**
     * Handles status transitions for watched post types.
     *
     * @param string        $newStatus New post status.
     * @param string        $oldStatus Old post status.
     * @param \WP_Post|int  $post      Post object or post ID.
     * @return void
     */
    public static function handleTransitionPostStatus(string $newStatus, string $oldStatus, $post): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $postObj = self::resolvePost($post);
        if (!$postObj instanceof \WP_Post) {
            return;
        }

        $postId = (int) $postObj->ID;
        $oldPost = clone $postObj;
        $oldPost->post_status = $oldStatus;
        $newPost = clone $postObj;
        $newPost->post_status = $newStatus;

        if ($oldStatus === 'publish' && $newStatus === 'trash') {
            if (self::shouldSuppressElementorPresentationTrigger($postObj, 'transition_post_status', false)) {
                return;
            }

            if (self::routeStructuralTrigger($postObj, 'transition_post_status')) {
                return;
            }

            if (self::shouldSkipDuplicateEvent($postId, 'deleted:content')) {
                return;
            }

            self::invalidateForPost($postObj, 'deleted');
            return;
        }

        if ($oldStatus === 'publish' && $newStatus === 'private') {
            if (self::shouldSuppressElementorPresentationTrigger($postObj, 'transition_post_status', false)) {
                return;
            }

            if (self::routeStructuralTrigger($postObj, 'transition_post_status')) {
                return;
            }

            if (self::shouldSkipDuplicateEvent($postId, 'access_restricted:content')) {
                return;
            }

            self::invalidateForPost($postObj, 'access_restricted');
            return;
        }

        if ($oldStatus === 'publish' || $newStatus !== 'publish') {
            return;
        }

        if (self::shouldSuppressElementorPresentationTrigger($postObj, 'transition_post_status', false)) {
            return;
        }

        if (self::routeStructuralTrigger($postObj, 'transition_post_status')) {
            return;
        }
    }

    /**
     * Handles generic saves that end with the post published.
     *
     * @param int      $postId Post ID.
     * @param \WP_Post $post   Post object.
     * @param bool     $update Whether this is an update.
     * @return void
     */
    public static function handleSavePost(int $postId, \WP_Post $post, bool $update): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        // Ignore autosaves and revisions.
        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }

        if (self::shouldSuppressElementorPresentationTrigger($post, 'save_post', false)) {
            return;
        }

        if (self::routeStructuralTrigger($post, 'save_post')) {
            return;
        }

        // Canonical generic lifecycle owner: any non-structural save that ends published.
        if ($post->post_status !== 'publish') {
            return;
        }

        if (self::shouldSkipDuplicateEvent($postId, 'update_published:content')) {
            return;
        }

        self::invalidateForPost(
            $post,
            'update_published',
            self::resolveContentLifecycleCorrelationId($postId, 'update_published:content')
        );
    }

    /**
     * Handle WooCommerce variation lifecycle changes by invalidating the parent product.
     *
     * @param int   $variationId Variation post ID.
     * @param mixed $variation   WooCommerce variation object.
     * @return void
     */
    public static function handleWooCommerceProductVariationChange(int $variationId, $variation = null): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $parentId = WooCommerceIntegrationRegistry::current()->resolveProductVariationParentId($variationId, $variation);

        if ($parentId <= 0 || self::wasProcessedInRequest($parentId)) {
            return;
        }

        if (!self::claimProcessedEventInRequest('woocommerce_variation_parent:' . $parentId)) {
            return;
        }

        $parentPost = get_post($parentId);
        if (!$parentPost instanceof \WP_Post) {
            return;
        }

        if (sanitize_key((string) $parentPost->post_type) !== 'product' || (string) $parentPost->post_status !== 'publish') {
            return;
        }

        self::invalidateForPost($parentPost, 'update_published');
    }

    /**
     * Handles deletions.
     *
     * @param int           $postId Post ID.
     * @param \WP_Post|null $post   Post object provided by WordPress when available.
     * @return void
     */
    public static function handleDeletedPost(int $postId, ?\WP_Post $post = null): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $postObj = $post instanceof \WP_Post ? $post : get_post($postId);
        LifecycleProbe::log('deleted_post_noop', $postObj instanceof \WP_Post ? $postObj : null, [
            'hook_name' => 'deleted_post',
            'hook_priority' => 20,
            'old_status' => $postObj instanceof \WP_Post ? (string) $postObj->post_status : '',
            'new_status' => '',
            'dedupe_decisions' => [],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);
    }

    /**
     * Handles untrash events.
     *
     * @param int $postId
     * @return void
     */
    public static function handleUntrashedPost(int $postId): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $postObj = get_post($postId);
        LifecycleProbe::log('untrashed_post_noop', $postObj instanceof \WP_Post ? $postObj : null, [
            'hook_name' => 'untrashed_post',
            'hook_priority' => 20,
            'old_status' => 'trash',
            'new_status' => $postObj instanceof \WP_Post ? (string) $postObj->post_status : '',
            'dedupe_decisions' => [],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);
    }

    /**
     * Handle Elementor Document hook saves for Elementor structural posts.
     *
     * Entry point for `elementor/document/after_save` — delegates to the
     * shared structural router for Elementor library and component documents.
     *
     * @param int $postId Post ID from Elementor document.
     * @return void
     */
    public static function handleElementorDocumentSave(int $postId): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

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

        $postType = $post->post_type;

        if (!in_array($postType, ['elementor_component', 'elementor_library'], true)) {
            return;
        }

        LifecycleProbe::log('elementor_document_after_save_engine_enter', $post, [
            'hook_name' => 'elementor/document/after_save',
            'hook_priority' => 10,
            'old_status' => '',
            'new_status' => (string) $post->post_status,
            'lifecycle_classifier_result' => ContentLifecycle::classifyTransition(null, $post, [
                'source_hook' => 'elementor/document/after_save',
            ]),
            'elementor_state' => LifecycleProbe::elementorState($post),
            'dedupe_decisions' => [],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);

        Debug::log(
            'elementor_document_save routed',
            [
                'post_id' => $postId,
                'post_type' => $postType,
                'source' => 'elementor_document_hook',
            ]
        );

        if (self::shouldSuppressElementorPresentationTrigger($post, 'elementor_document_hook', false)) {
            return;
        }

        self::routeStructuralTrigger($post, 'elementor_document_hook');
    }

    /**
     * Handle Elementor condition meta writes for presentation templates.
     *
     * @param mixed $metaId Meta row ID.
     * @param mixed $postId Post ID.
     * @param mixed $metaKey Meta key.
     * @param mixed $metaValue Meta value.
     * @return void
     */
    public static function handleElementorConditionsMetaChange($metaId, $postId, $metaKey, $metaValue): void
    {
        unset($metaId, $metaValue);

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

        $postId = absint($postId);
        if ($postId <= 0) {
            return;
        }

        $metaKey = (string) $metaKey;
        if ($metaKey !== '_elementor_conditions') {
            return;
        }

        if (get_post_type($postId) !== 'elementor_library') {
            return;
        }

        $templateType = self::resolveElementorLibraryTemplateType($postId);
        if (!in_array($templateType, self::ELEMENTOR_PRESENTATION_TEMPLATE_TYPES, true)) {
            return;
        }

        $status = get_post_status($postId);
        if ($status !== 'publish') {
            return;
        }

        static $processed = [];
        $requestKey = 'meta:' . $postId . ':' . $metaKey;
        $probePost = get_post($postId);

        if (isset($processed[$requestKey])) {
            LifecycleProbe::log('elementor_conditions_meta_dedupe', $probePost instanceof \WP_Post ? $probePost : null, [
                'hook_name' => current_filter(),
                'hook_priority' => 10,
                'dedupe_system' => 'elementor_condition_meta_request_local',
                'dedupe_decisions' => [
                    'request_local' => 'skip',
                    'request_key' => $requestKey,
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            Debug::log('eci_elementor_meta_dedupe_skip', [
                'post_id' => $postId,
                'reason' => 'request',
                'hook' => current_filter(),
            ]);
            return;
        }
        $processed[$requestKey] = true;
        LifecycleProbe::log('elementor_conditions_meta_dedupe', $probePost instanceof \WP_Post ? $probePost : null, [
            'hook_name' => current_filter(),
            'hook_priority' => 10,
            'dedupe_system' => 'elementor_condition_meta_request_local',
            'dedupe_decisions' => [
                'request_local' => 'claim',
                'request_key' => $requestKey,
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);

        $previous = self::getStoredConditions($postId);
        $current = self::normalizeElementorConditions(get_post_meta($postId, '_elementor_conditions', true));

        $hash = md5(wp_json_encode($current));
        $hashKey = 'ekesto_eci_meta_hash_' . $postId . '_' . $metaKey;

        if (get_transient($hashKey) === $hash) {
            LifecycleProbe::log('elementor_conditions_meta_dedupe', $probePost instanceof \WP_Post ? $probePost : null, [
                'hook_name' => current_filter(),
                'hook_priority' => 10,
                'dedupe_system' => 'eci_meta_hash',
                'dedupe_decisions' => [
                    'hash_key' => $hashKey,
                    'hash' => $hash,
                    'outcome' => 'skip_same_hash',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            Debug::log('eci_elementor_meta_dedupe_skip', [
                'post_id' => $postId,
                'reason' => 'same_hash',
            ]);
            return;
        }

        set_transient($hashKey, $hash, 15);
        LifecycleProbe::log('elementor_conditions_meta_dedupe', $probePost instanceof \WP_Post ? $probePost : null, [
            'hook_name' => current_filter(),
            'hook_priority' => 10,
            'dedupe_system' => 'eci_meta_hash',
            'dedupe_decisions' => [
                'hash_key' => $hashKey,
                'hash' => $hash,
                'outcome' => 'claim_new_hash',
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);

        $dedupeTransientKey = 'ekesto_eci_meta_dedupe_' . $postId;
        if (get_transient($dedupeTransientKey)) {
            LifecycleProbe::log('elementor_conditions_meta_dedupe', $probePost instanceof \WP_Post ? $probePost : null, [
                'hook_name' => current_filter(),
                'hook_priority' => 10,
                'dedupe_system' => 'eci_meta_dedupe',
                'dedupe_decisions' => [
                    'transient_key' => $dedupeTransientKey,
                    'outcome' => 'skip_transient',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            Debug::log('eci_elementor_meta_dedupe_skip', [
                'post_id' => $postId,
                'reason' => 'transient',
                'hook' => current_filter(),
            ]);
            return;
        }
        set_transient($dedupeTransientKey, 1, 2);

        set_transient('ekesto_eci_meta_seen_' . $postId, 1, 20);
        LifecycleProbe::log('elementor_conditions_meta_seen', $probePost instanceof \WP_Post ? $probePost : null, [
            'hook_name' => current_filter(),
            'hook_priority' => 10,
            'dedupe_system' => 'eci_meta_seen',
            'dedupe_decisions' => [
                'transient_key' => 'ekesto_eci_meta_seen_' . $postId,
                'outcome' => 'set',
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);

        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive: this is a debug context key, not a WP_Query/get_posts argument.
        Debug::log('eci_elementor_meta_trigger_final', [
            'post_id' => $postId,
            'meta_key' => $metaKey,
            'conditions_raw' => $current,
            'status' => $status,
            'template_type' => $templateType,
            'hook' => current_filter(),
        ]);
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key

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

        $context = [
            'previous_conditions' => $previous,
        ];

        self::routeStructuralTrigger($post, 'elementor_conditions_meta', $context);

        self::storeConditions($postId, $current);
    }

    /**
     * Route structural post triggers through the structural invalidation handlers.
     *
     * Do not call structural handlers directly. Use engine entrypoints to ensure
     * request-local intent deduplication runs before structural dispatch.
     *
     * @param \WP_Post $post Trigger post.
     * @param string   $source Entry source for diagnostics.
     * @param array<string,mixed> $context Additional handler context.
     * @return bool True when this post is a structural trigger and content routing must stop.
     */
    private static function routeStructuralTrigger(\WP_Post $post, string $source, array $context = []): bool
    {
        if (self::isElementorStructuralPostType($post) && !ElementorIntegrationRegistry::current()->isAvailable()) {
            LifecycleProbe::log('route_structural_trigger', $post, [
                'hook_name' => $source,
                'structural_classification_result' => [
                    'route' => 'elementor_provider_unavailable',
                    'handler' => '',
                ],
                'dedupe_decisions' => [
                    'structural' => 'not_checked',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => true,
                'generic_lifecycle_must_stop' => true,
            ]);
            Debug::log('elementor structural trigger skipped: provider unavailable', [
                'post_id' => (int) $post->ID,
                'post_type' => sanitize_key((string) $post->post_type),
                'source' => sanitize_key($source),
            ]);
            return true;
        }

        if (self::shouldSuppressElementorPresentationTrigger($post, $source, true)) {
            LifecycleProbe::log('route_structural_trigger', $post, [
                'hook_name' => $source,
                'structural_classification_result' => [
                    'route' => 'suppressed_elementor_presentation',
                    'handler' => '',
                ],
                'dedupe_decisions' => [
                    'structural' => 'not_checked',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => true,
                'generic_lifecycle_must_stop' => true,
            ]);
            return true;
        }

        $route = self::classifyStructuralTrigger($post);
        if ($route === null) {
            LifecycleProbe::log('route_structural_trigger', $post, [
                'hook_name' => $source,
                'structural_classification_result' => [
                    'route' => null,
                    'handler' => '',
                ],
                'dedupe_decisions' => [
                    'structural' => 'not_applicable',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            return false;
        }

        if (
            sanitize_key($source) !== 'elementor_conditions_meta'
            && self::shouldSkipDuplicateEvent((int) $post->ID, $route['event_key'])
        ) {
            LifecycleProbe::log('route_structural_trigger', $post, [
                'hook_name' => $source,
                'structural_classification_result' => $route,
                'dedupe_decisions' => [
                    'structural' => 'skip_duplicate',
                    'event_key' => $route['event_key'],
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => true,
                'generic_lifecycle_must_stop' => true,
            ]);
            return true;
        }

        LifecycleProbe::log('route_structural_trigger', $post, [
            'hook_name' => $source,
            'structural_classification_result' => $route,
            'dedupe_decisions' => [
                'structural' => sanitize_key($source) === 'elementor_conditions_meta' ? 'bypassed_for_conditions_meta' : 'claim',
                'event_key' => $route['event_key'],
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => true,
            'generic_lifecycle_must_stop' => true,
        ]);

        Debug::log(
            'structural trigger routed',
            [
                'post_id' => (int) $post->ID,
                'post_type' => sanitize_key((string) $post->post_type),
                'event_key' => $route['event_key'],
                'source' => sanitize_key($source),
            ]
        );

        $handlerContext = array_merge($context, [
            'trigger_source' => sanitize_key($source),
        ]);

        switch ($route['handler']) {
            case 'template':
                self::handleTemplatePartUpdate($post);
                break;

            case 'global_styles':
                Debug::log(sprintf(
                    'template/global style trigger: post_type=%s post_id=%d',
                    $post->post_type,
                    (int) $post->ID
                ));
                self::executeGlobalInvalidationForPostTrigger($post, 'template_change', 'post');
                break;

            case 'navigation':
                self::handleNavigationUpdate($post);
                break;

            case 'synced_pattern':
                self::handleSyncedPatternUpdate($post);
                break;

            case 'elementor_component':
                self::handleElementorStructuralUpdate($post);
                break;

            case 'elementor_library':
                self::handleElementorLibraryUpdate($post, $handlerContext);
                break;
        }

        return true;
    }

    /**
     * Determine whether the post belongs to Elementor structural ingress.
     *
     * @param \WP_Post $post Trigger post.
     * @return bool True when the post is an Elementor structural post type.
     */
    private static function isElementorStructuralPostType(\WP_Post $post): bool
    {
        return in_array(
            sanitize_key((string) $post->post_type),
            ['elementor_component', 'elementor_library'],
            true
        );
    }

    /**
     * Determine whether Elementor presentation-template trigger handling should be suppressed.
     *
     * @param \WP_Post $post Trigger post.
     * @param string   $source Trigger source.
     * @param bool     $includeMetaSeen Whether meta-authoritative guard should be applied.
     * @return bool
     */
    private static function shouldSuppressElementorPresentationTrigger(
        \WP_Post $post,
        string $source,
        bool $includeMetaSeen = true
    ): bool {
        $postId = (int) $post->ID;
        $templateType = self::resolveElementorPresentationTemplateType($post);
        if ($templateType === '') {
            return false;
        }

        $source = sanitize_key($source);
        $reason = '';
        $metaSeen = (bool) get_transient('ekesto_eci_meta_seen_' . $postId);

        if (in_array($source, self::ELEMENTOR_PRESENTATION_EARLY_SOURCES, true)) {
            $reason = 'early_source';
        } elseif (
            $includeMetaSeen
            && $source !== 'elementor_conditions_meta'
            && $metaSeen
        ) {
            $reason = 'meta_seen';
        }

        if ($reason === '') {
            LifecycleProbe::log('elementor_presentation_suppression', $post, [
                'hook_name' => $source,
                'dedupe_system' => 'eci_meta_seen',
                'elementor_presentation_suppression_triggered' => false,
                'template_type' => $templateType,
                'include_meta_seen' => $includeMetaSeen,
                'eci_meta_seen_influenced_behavior' => false,
                'dedupe_decisions' => [
                    'ekesto_eci_meta_seen_' . $postId => $metaSeen ? 'present_not_used' : 'absent',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            return false;
        }

        LifecycleProbe::log('elementor_presentation_suppression', $post, [
            'hook_name' => $source,
            'dedupe_system' => 'eci_meta_seen',
            'elementor_presentation_suppression_triggered' => true,
            'template_type' => $templateType,
            'include_meta_seen' => $includeMetaSeen,
            'reason' => $reason,
            'eci_meta_seen_influenced_behavior' => $reason === 'meta_seen',
            'dedupe_decisions' => [
                'ekesto_eci_meta_seen_' . $postId => $metaSeen ? 'present' : 'absent',
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => true,
            'generic_lifecycle_must_stop' => true,
        ]);

        return true;
    }

    /**
     * Resolve one Elementor library template type.
     *
     * @param int $postId Post ID.
     * @return string
     */
    private static function resolveElementorLibraryTemplateType(int $postId): string
    {
        return sanitize_key((string) get_post_meta($postId, '_elementor_template_type', true));
    }

    /**
     * Resolve the invalidation reason for an Elementor library document type.
     *
     * @param string $templateType Normalized Elementor template type.
     * @return string
     */
    private static function resolveElementorLibraryChangeReason(string $templateType): string
    {
        return $templateType === 'widget'
            ? 'elementor_global_widget_change'
            : 'elementor_template_change';
    }

    /**
     * Resolve presentation template type for Elementor library posts.
     *
     * @param \WP_Post $post Trigger post.
     * @return string Empty string when not a presentation template.
     */
    private static function resolveElementorPresentationTemplateType(\WP_Post $post): string
    {
        if (sanitize_key((string) $post->post_type) !== 'elementor_library') {
            return '';
        }

        $templateType = self::resolveElementorLibraryTemplateType((int) $post->ID);
        if (!in_array($templateType, self::ELEMENTOR_PRESENTATION_TEMPLATE_TYPES, true)) {
            return '';
        }

        return $templateType;
    }

    /**
     * Classify structural trigger posts and map them to intent-scoped routes.
     *
     * @param \WP_Post $post Trigger post.
     * @return array{event_key:string,handler:string}|null Structural route or null for content posts.
     */
    private static function classifyStructuralTrigger(\WP_Post $post): ?array
    {
        $postType = sanitize_key((string) $post->post_type);

        if ($postType === 'wp_template' || $postType === 'wp_template_part') {
            return [
                'event_key' => 'template_change:structural',
                'handler' => 'template',
            ];
        }

        if ($postType === 'wp_global_styles') {
            return [
                'event_key' => 'template_change:structural',
                'handler' => 'global_styles',
            ];
        }

        if ($postType === 'wp_navigation') {
            return [
                'event_key' => 'navigation_change:structural',
                'handler' => 'navigation',
            ];
        }

        if ($postType === 'wp_block' && CoreStructuralEntityClassifier::isSyncedPattern($post)) {
            return [
                'event_key' => 'synced_pattern_change:structural',
                'handler' => 'synced_pattern',
            ];
        }

        if ($postType === 'elementor_component') {
            return [
                'event_key' => 'elementor_component_change:structural',
                'handler' => 'elementor_component',
            ];
        }

        if ($postType === 'elementor_library') {
            return [
                'event_key' => 'elementor_template_change:structural',
                'handler' => 'elementor_library',
            ];
        }

        return null;
    }

    /**
     * Purge post IDs for a deep invalidation job (called by AsyncInvalidationProcessor).
     *
     * @param int[]                          $postIds  Post IDs to purge.
     * @param CacheAdapterInterface          $adapter  Active cache adapter.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string[]                       $fanoutLanguages Pre-resolved fanout languages.
     * @return string[] Purged URLs.
     */
    public static function purgePostIds(
        array $postIds,
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        array $fanoutLanguages
    ): array {
        $postIds = array_values(array_unique(array_filter(
            array_map('absint', $postIds),
            static fn(int $postId): bool => $postId > 0
        )));

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

        $languages = array_values(array_unique(array_filter(array_map(
            static fn($language): string => trim((string) $language),
            $fanoutLanguages
        ))));

        if ($resolver !== null && $languages !== []) {
            return self::purgePostIdsByLanguage($postIds, $adapter, $resolver, $languages);
        }

        $purgedUrls = [];

        foreach ($postIds as $postId) {
            foreach (self::purgePageId($postId, $adapter, $resolver, $fanoutLanguages) as $purgedUrl) {
                $purgedUrls[] = $purgedUrl;
            }
        }

        return array_values(array_unique(array_filter($purgedUrls)));
    }

    /**
     * Purge translated post IDs while resolving permalinks in their target language context.
     *
     * @param int[]                     $postIds Source post IDs.
     * @param CacheAdapterInterface     $adapter Active cache adapter.
     * @param LanguageResolverInterface $resolver Active multilingual resolver.
     * @param string[]                  $languages Target language codes.
     * @return string[] Purged frontend URLs.
     */
    private static function purgePostIdsByLanguage(
        array $postIds,
        CacheAdapterInterface $adapter,
        LanguageResolverInterface $resolver,
        array $languages
    ): array {
        $postTypes = [];
        foreach ($postIds as $postId) {
            $postType = get_post_type($postId);
            if (is_string($postType) && $postType !== '') {
                $postTypes[$postId] = $postType;
            }
        }

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

        $resolverModel = ResolverContext::getResolverModel($resolver);
        $previousLanguage = $resolver instanceof CurrentLanguageResolverInterface
            ? $resolver->getCurrentLanguage()
            : null;
        $purgeByPostId = [];
        $purgedUrls = [];

        try {
            foreach ($languages as $language) {
                $resolver->switchLanguage($language);

                foreach ($postTypes as $postId => $postType) {
                    $translatedId = $resolver->mapObjectId($postId, $postType, $language);

                    if ($translatedId === null && $resolverModel === ResolverMetadataInterface::MODEL_URL) {
                        $translatedId = $postId;
                    }

                    if ($translatedId === null) {
                        continue;
                    }

                    if (!array_key_exists($translatedId, $purgeByPostId)) {
                        $purgeByPostId[$translatedId] = $adapter->purgePostId($translatedId);
                    }

                    if (!$purgeByPostId[$translatedId]) {
                        continue;
                    }

                    $url = self::resolvePreciseEmissionUrlForPostId($translatedId);
                    if ($url === '') {
                        continue;
                    }

                    $purgedUrls[] = $resolver->mapPermalink($url, $language);
                }
            }
        } finally {
            $resolver->switchLanguage($previousLanguage);
        }

        return array_values(array_unique(array_filter($purgedUrls)));
    }

    /**
     * Resolves a post object from \WP_Post or ID.
     *
     * @param \WP_Post|int $post
     * @return \WP_Post|null
     */
    private static function resolvePost($post): ?\WP_Post
    {
        if ($post instanceof \WP_Post) {
            return $post;
        }

        $postId = absint($post);
        if ($postId <= 0) {
            return null;
        }

        $obj = get_post($postId);
        return ($obj instanceof \WP_Post) ? $obj : null;
    }

    /**
     * Purges target pages for configured post types affected by the trigger post type.
     * Also triggers deep invalidation (relationship propagation) when enabled.
     *
     * Emission policy:
     * - If async work is scheduled: do NOT emit here (processor emits once).
     * - If all work is synchronous: emit here.
     *
     * @param \WP_Post $post Triggering post.
     * @param string   $reason Reason key for diagnostics / downstream warmup trigger.
     * @param string   $correlationId Shared lifecycle correlation ID, when available.
     * @return void
     */
    private static function invalidateForPost(
        \WP_Post $post,
        string $reason,
        string $correlationId = ''
    ): void
    {
        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                'post invalidation skipped_terminal_global_already_run: reason=%s post_id=%d post_type=%s',
                $reason,
                (int) $post->ID,
                sanitize_key((string) $post->post_type)
            ));
            return;
        }

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

        $postId = (int) $post->ID;
        $resolver = LanguageResolverFactory::create();

        $triggerLanguage = null;
        if ($resolver !== null) {
            $triggerLanguage = $resolver->getTriggerLanguage($postId);
        }

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

        // Menu item posts are handled by PresentationInvalidationListener menu hooks.
        // Avoid noisy per-item save_post invalidations when editing menus.
        if ($postType === 'nav_menu_item') {
            Debug::log(sprintf(
                'nav_menu_item skipped in post invalidation: reason=%s post_id=%d',
                $reason,
                $postId
            ));
            return;
        }

        $ptObj = get_post_type_object($postType);
        $isPubliclyQueryable = (bool) ($ptObj && !empty($ptObj->publicly_queryable));

        $candidateConfiguredPostTypes = array_values(array_unique(array_merge(
            [$postType],
            RelationshipGraphResolver::resolveAffectedHosts($postType)
        )));
        $enabledConfiguredPostTypes = [];
        $selectedTargetPostIds = [];
        $targetPostIds = [];
        $archiveUrls = [];
        $untrustedUrlTargets = [];
        $baselineTargetPostIds = [];
        $baselineTargetUrls = [];
        $baselineTrustedUrls = [];
        $relationshipTrustedPostIds = [];
        $purgedUrlsDirectly = [];
        $purgeSkippedUrls = [];
        $asyncScheduled = false;
        $isExcludedPostTypeDirectTargeting = false;

        $baselineResult = WooCommerceIntegrationRegistry::current()->resolveProductBaseline($post);
        $baselineTargetPostIds = TrustDecider::normalizePostIds(
            is_array($baselineResult['post_ids'] ?? null) ? $baselineResult['post_ids'] : []
        );
        $baselineTargetUrls = TrustDecider::normalizeUrls(
            is_array($baselineResult['urls'] ?? null) ? $baselineResult['urls'] : []
        );
        $baselineTrustedUrls = TrustDecider::normalizeUrls(
            is_array($baselineResult['trusted_urls'] ?? null) ? $baselineResult['trusted_urls'] : []
        );

        foreach ($candidateConfiguredPostTypes as $configuredPostType) {
            $configuredPostType = sanitize_key((string) $configuredPostType);
            if ($configuredPostType === '') {
                continue;
            }

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

            $enabledConfiguredPostTypes[] = $configuredPostType;
            $targetMode = $settingsRepository->getPostTypeTargetMode($configuredPostType);

            if ($targetMode === 'selected') {
                $configuredSelectedIds = $settingsRepository->getPostTypeTargetPageIds($configuredPostType);
                $selectedTargetPostIds = array_merge($selectedTargetPostIds, $configuredSelectedIds);
                $targetPostIds = array_merge($targetPostIds, $configuredSelectedIds);
                continue;
            }

            if ($targetMode === 'all') {
                $targetPostIds = array_merge(
                    $targetPostIds,
                    $settingsRepository->getEffectivePostTypeTargetPageIds($configuredPostType)
                );
            }
        }

        $enabledConfiguredPostTypes = array_values(array_unique(array_filter($enabledConfiguredPostTypes)));
        $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)));

        if (!empty($enabledConfiguredPostTypes)) {
            Debug::info(sprintf(
                'post type config matched: post_type=%s post_id=%d reason=%s hosts=%d targets=%d',
                $postType,
                $postId,
                $reason,
                count($enabledConfiguredPostTypes),
                count($targetPostIds)
            ));
        }

        if (!empty($baselineTargetPostIds) || !empty($baselineTargetUrls)) {
            Debug::log(sprintf(
                'baseline resolver applied: resolver=woo post_id=%d post_type=%s configured=%s post_ids=%d urls=%d trusted_urls=%d',
                $postId,
                $postType,
                !empty($enabledConfiguredPostTypes) ? 'yes' : 'no',
                count($baselineTargetPostIds),
                count($baselineTargetUrls),
                count($baselineTrustedUrls)
            ));
        }

        if (!empty($enabledConfiguredPostTypes) && self::hasFullFlushPostType($enabledConfiguredPostTypes, $settingsRepository)) {
            self::$processedInRequest[$postId] = true;
            self::emitPostFullFlushInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                $postId,
                $postType,
                $reason,
                $enabledConfiguredPostTypes
            );
            return;
        }

        $builderProfile = (new BuilderDetector())->detectForPost($post);
        $mappingContext = [
            'trigger_post_id' => $postId,
            'trigger_post_type' => $postType,
            'reason' => $reason,
            'groups' => $enabledConfiguredPostTypes,
            'is_publicly_queryable' => $isPubliclyQueryable,
            'builder_key' => $builderProfile->builderKey(),
            'builder_confidence' => $builderProfile->confidence(),
        ];
        $mappingContext = array_merge(
            $mappingContext,
            ResolverContext::buildContext($resolver, $triggerLanguage, [
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
            ])
        );
        $mapping = PostTargetMappingResolver::resolve($mappingContext);
        $postSelectorHasSelectors = !empty($mapping['has_selectors']);
        $postSelectorIntent = (string) ($mapping['intent'] ?? SelectorResolver::INTENT_TARGETED);
        $postSelectorGlobal = $postSelectorHasSelectors
            && $postSelectorIntent === SelectorResolver::INTENT_GLOBAL;
        $postSelectorTargetedEmpty = $postSelectorHasSelectors
            && $postSelectorIntent === SelectorResolver::INTENT_TARGETED
            && !empty($mapping['targeted_empty']);
        $configuredTargetingPath = false;
        $mappingOnlyPath = false;

        // Handle excluded post types (page, attachment) that don't participate in PT relationship configuration.
        if (!$postSelectorHasSelectors && empty($enabledConfiguredPostTypes) && in_array($postType, Config::EXCLUDED_POST_TYPES, true)) {
            Debug::log('purging excluded post type directly: ' . $postType);

            $targetPostIds = [$postId];
            self::$processedInRequest[$postId] = true;
            $isExcludedPostTypeDirectTargeting = true;
        } elseif ($postSelectorHasSelectors) {
            self::$processedInRequest[$postId] = true;
            $targetPostIds = is_array($mapping['target_ids'] ?? null)
                ? array_values(array_unique(array_filter(array_map('absint', $mapping['target_ids']), static fn(int $id): bool => $id > 0)))
                : [];

            Debug::log(sprintf(
                'post selector outcome: has_selectors=yes intent=%s targets=%d',
                $postSelectorIntent,
                count($targetPostIds)
            ));
        } elseif (self::isMappingOnlyEligible($enabledConfiguredPostTypes, $isPubliclyQueryable, $mapping)) {
            Debug::log(sprintf(
                'post target mapping-only invalidation: post_type=%s post_id=%d',
                $postType,
                $postId
            ));

            $targetPostIds = self::applyPostTargetMapping([], $mapping);
            self::$processedInRequest[$postId] = true;
            $mappingOnlyPath = true;
        } elseif (empty($enabledConfiguredPostTypes)) {
            Debug::log('no enabled configured post types matched');
        } else {
            $configuredTargetingPath = true;
            self::$processedInRequest[$postId] = true;

            if ($isPubliclyQueryable) {
                $targetPostIds[] = $postId;
            }

            $targetPostIds = self::applyPostTargetMapping($targetPostIds, $mapping);
        }

        $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 && ($configuredTargetingPath || $postSelectorHasSelectors)) {
            $archiveSelectorOutcome = ArchiveResolver::resolveForPostOutcome(
                $post,
                !empty($triggerPostTypeConfig['taxonomy_archives_enabled'])
            );
            $archiveUrls = is_array($archiveSelectorOutcome['urls'] ?? null) ? $archiveSelectorOutcome['urls'] : [];
        }

        $targetPostIds = TrustDecider::normalizePostIds(array_merge($targetPostIds, $baselineTargetPostIds));
        $archiveUrls = TrustDecider::normalizeUrls(array_merge($archiveUrls, $baselineTargetUrls));

        $archiveSelectorHasSelectors = !empty($archiveSelectorOutcome['has_selectors']);
        $archiveSelectorIntent = (string) ($archiveSelectorOutcome['intent'] ?? SelectorResolver::INTENT_TARGETED);
        $archiveSelectorGlobal = $archiveSelectorHasSelectors
            && $archiveSelectorIntent === SelectorResolver::INTENT_GLOBAL;
        $archiveSelectorTargetedEmpty = $archiveSelectorHasSelectors
            && $archiveSelectorIntent === SelectorResolver::INTENT_TARGETED
            && !empty($archiveSelectorOutcome['targeted_empty']);

        $selectorRequestsGlobal = $postSelectorGlobal || $archiveSelectorGlobal;
        $skipEarlyGlobalFallback =
            $postType === 'product'
            && !empty(RelationshipGraphResolver::resolveRelationshipEdges($postType));

        if ($selectorRequestsGlobal && !$skipEarlyGlobalFallback) {
            Debug::log(sprintf(
                'selector execution decision: reason=selector_all post_global=%s archive_global=%s decision=global_only',
                $postSelectorGlobal ? 'yes' : 'no',
                $archiveSelectorGlobal ? 'yes' : 'no'
            ));
            self::executeGlobalInvalidationForPostTrigger(
                $post,
                $reason,
                'post',
                [
                    'intent' => SelectorResolver::INTENT_GLOBAL,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_all',
                ]
            );
            return;
        }

        // Phase 2 SYNC/ASYNC partition — capture the configured (pre-expansion) target page IDs.
        // This is the "selected targets" component of the SYNC subset and is lost after flat expansion.
        $configuredTargetPostIds = TrustDecider::normalizePostIds($targetPostIds);

        if (!empty($enabledConfiguredPostTypes)) {
            // Post type expansion is flat target expansion.
            // These targets MUST NOT re-enter resolution or trigger additional logic.
            $targetPostIds = self::applyFlatPostTypeExpansion(
                $targetPostIds,
                $enabledConfiguredPostTypes,
                $settingsRepository
            );
        }

        $postHasTargetedSelector =
            $postSelectorHasSelectors
            && $postSelectorIntent === SelectorResolver::INTENT_TARGETED;

        $archiveHasTargetedSelector =
            $archiveSelectorHasSelectors
            && $archiveSelectorIntent === SelectorResolver::INTENT_TARGETED;

        $selectorTargetedSurfacePresent =
            $postHasTargetedSelector
            || $archiveHasTargetedSelector;

        $shouldAttemptDeepInvalidation =
            !empty($enabledConfiguredPostTypes)
            && !$isExcludedPostTypeDirectTargeting
            && !$mappingOnlyPath;

        $finalCombinedTargetedEmpty =
            empty($targetPostIds)
            && empty($archiveUrls)
            && empty($untrustedUrlTargets);

        if (Debug::verboseEnabled()) {
            Debug::log(sprintf(
                'selector targeted presence: post=%s archive=%s final_empty=%s',
                $postHasTargetedSelector ? 'yes' : 'no',
                $archiveHasTargetedSelector ? 'yes' : 'no',
                $finalCombinedTargetedEmpty ? 'yes' : 'no'
            ));
            Debug::log('resolution_path', [
                'post_id' => $postId,
                'post_type' => $post->post_type ?? '',
                'reason' => $reason ?? '',
                'path' => $shouldAttemptDeepInvalidation
                    ? 'configured_deep'
                    : (($isExcludedPostTypeDirectTargeting || !empty($enabledConfiguredPostTypes)) ? 'direct' : 'unconfigured_fallback'),
                'configured_host_count' => count($enabledConfiguredPostTypes),
                'has_post_selectors' => $postSelectorHasSelectors,
                'has_archive_selectors' => $archiveSelectorHasSelectors,
            ]);
        }

        if ($selectorTargetedSurfacePresent && $finalCombinedTargetedEmpty && !$skipEarlyGlobalFallback) {
            Debug::log(sprintf(
                'selector execution decision: reason=selector_targeted_empty post_empty=%s archive_empty=%s final_empty=%s decision=global_only',
                $postSelectorTargetedEmpty ? 'yes' : 'no',
                $archiveSelectorTargetedEmpty ? 'yes' : 'no',
                $finalCombinedTargetedEmpty ? 'yes' : 'no'
            ));
            self::executeGlobalInvalidationForPostTrigger(
                $post,
                $reason,
                'post',
                [
                    'intent' => SelectorResolver::INTENT_TARGETED,
                    'resolution' => 'GLOBAL_FALLBACK',
                    'fallback' => true,
                    'fallback_reason' => 'selector_targeted_empty',
                ]
            );
            return;
        }

        if ($finalCombinedTargetedEmpty && !$shouldAttemptDeepInvalidation) {
            self::applyUnconfiguredSelfTargetFallback(
                $post,
                $reason,
                $enabledConfiguredPostTypes,
                $postSelectorHasSelectors,
                $archiveSelectorHasSelectors,
                $targetPostIds,
                $untrustedUrlTargets
            );
            $finalCombinedTargetedEmpty =
                empty($targetPostIds)
                && empty($archiveUrls)
                && empty($untrustedUrlTargets);
        }

        if ($finalCombinedTargetedEmpty && $shouldAttemptDeepInvalidation) {
            Debug::log('eci_empty_targets_continuing_to_deep_resolution', [
                'post_id' => $postId,
                'post_type' => $postType,
                'reason' => $reason,
                'configured_host_count' => count($enabledConfiguredPostTypes),
            ]);
        }

        if ($finalCombinedTargetedEmpty && !$shouldAttemptDeepInvalidation) {
            Debug::log('no invalidation targets resolved');
            return;
        }

        self::$processedInRequest[$postId] = true;

        Debug::log('selector execution decision: decision=targeted_only');

        if ($shouldAttemptDeepInvalidation) {
            // --- Deep invalidation: relationship propagation ---
            $deepScheduled = self::handleDeepInvalidation(
                $post,
                $reason,
                $enabledConfiguredPostTypes,
                $adapter,
                $resolver,
                $triggerLanguage,
                $targetPostIds,
                $archiveUrls,
                $relationshipTrustedPostIds,
                $baselineTrustedUrls
            );

            $asyncScheduled = $deepScheduled;
        }

        $finalCombinedTargetedEmpty =
            empty($targetPostIds)
            && empty($archiveUrls)
            && empty($untrustedUrlTargets);

        if ($finalCombinedTargetedEmpty && !$asyncScheduled) {
            Debug::log('no invalidation targets resolved');
            return;
        }

        $asyncDecision = $settingsRepository->getAsyncSchedulingDecision();
        $asyncMode = (string) ($asyncDecision['async_mode'] ?? 'off');
        if (Debug::verboseEnabled() || $asyncMode !== 'off') {
            Debug::log('invalidation execution decision', [
                'entry_point' => 'post_invalidation',
                'post_id' => $postId,
                'async_mode' => $asyncMode,
                'execution_mode' => !empty($asyncDecision['should_schedule']) ? 'async_partitioned' : 'sync',
                'discovery' => $asyncScheduled ? 'deep_or_fanout_contributed' : 'direct_targets_only',
                'reason' => (string) ($asyncDecision['reason'] ?? 'async_disabled'),
            ]);
        }

        // Synchronous path: purge and partition.
        $purgeContext = [
            'trigger_class' => 'post',
            'trigger_kind' => 'post',
            'trigger_post_id' => $postId,
            'trigger_post_type' => $postType,
            'trigger_event' => $reason,
            'reason' => $reason,
        ];
        if ($isExcludedPostTypeDirectTargeting) {
            $purgeContext['intent'] = 'content';
            $purgeContext['resolution'] = 'CONTENT_DIRECT';
            $purgeContext['fallback'] = false;
            $purgeContext['fallback_reason'] = '';
        }

        $urlTargets = TrustDecider::normalizeUrls(array_merge($archiveUrls, $untrustedUrlTargets));

        $eciPurgeStart = microtime(true);

        $result = PurgeCoordinator::purgeTargets(
            $targetPostIds,
            $urlTargets,
            $adapter,
            $resolver,
            $triggerLanguage,
            $purgeContext
        );

        $eciPurgeDurationMs = round((microtime(true) - $eciPurgeStart) * 1000);

        if (Debug::verboseEnabled()) {
            Debug::log('purge_timing', [
                'post_id'           => $postId,
                'post_type'         => $postType,
                'reason'            => $reason,
                'duration_ms'       => $eciPurgeDurationMs,
                'emitted_url_count' => is_array($result['emitted_urls'] ?? null)
                    ? count($result['emitted_urls'])
                    : 0,
                'post_ids_purged'   => is_array($result['post_ids'] ?? null)
                    ? count($result['post_ids'])
                    : 0,
                'url_targets_count' => is_array($result['url_targets'] ?? null)
                    ? count($result['url_targets'])
                    : 0,
            ]);
        }

        $purgedUrls = TrustDecider::normalizeUrls((array) ($result['emitted_urls'] ?? []));
        $purgedUrlsDirectly = $result['purged_urls_directly'] ?? [];
        $purgeSkippedUrls = $result['purge_skipped_urls'] ?? [];
        if (empty($purgedUrls)) {
            Debug::log('$purgedUrls is empty (no target pages selected?)');
            return;
        }
        $fanoutLanguages = TrustDecider::normalizeStringList((array) ($result['fanout_languages'] ?? []));
        $baselineTrustedUrls = TrustDecider::normalizeUrls(FanoutPolicy::expandUrls(
            $baselineTrustedUrls,
            $resolver,
            $fanoutLanguages
        ));
        $relationshipTrustedUrls = self::resolveTrustedRelationshipUrlsFromPostIds(
            $relationshipTrustedPostIds,
            $resolver,
            $fanoutLanguages
        );
        $trustedSelectedPostIds = $selectedTargetPostIds;

        if ($isExcludedPostTypeDirectTargeting) {
            $trustedSelectedPostIds = array_merge(
                $trustedSelectedPostIds,
                $targetPostIds
            );
        }

        $trustedUrls = TrustDecider::trustedUrlsFromOriginUrls([
            TrustDecider::ORIGIN_SELECTED => TrustDecider::resolveTrustedUrlsForSelectedPostIds(
                $trustedSelectedPostIds,
                $resolver,
                $fanoutLanguages
            ),
        ]);
        $trustedUrls = TrustDecider::normalizeUrls(array_merge(
            $trustedUrls,
            $baselineTrustedUrls,
            $relationshipTrustedUrls
        ));
        $trustedUrls = TrustDecider::intersectWithUrls($trustedUrls, $purgedUrls);
        Debug::verbose('[ECI] trust classification', [
            'trusted' => count($trustedUrls),
            'origin' => 'selected+relationship',
            'relationship_candidates' => count($relationshipTrustedUrls),
        ]);
        if (!empty($archiveUrls)) {
            Debug::verbose('[ECI] untrusted origin', [
                'origin' => 'archive',
                'count' => count($archiveUrls),
            ]);
        }
        if (!empty($untrustedUrlTargets)) {
            Debug::verbose('[ECI] untrusted origin', [
                'origin' => 'self_target',
                'count' => count($untrustedUrlTargets),
            ]);
        }

        $context = [
            'source' => $adapter->getKey(),
            'source_label' => $adapter->getLabel(),
            'reason' => $reason,
            'trigger_class' => 'post',
            'trigger_kind' => 'post',
            'trigger_event' => $reason,
            'trigger_post_id' => $postId,
            'trigger_post_type' => $postType,
            'groups' => $enabledConfiguredPostTypes,
            'post_ids' => $targetPostIds,
            'urls' => [],
            'archives' => $archiveUrls,
            'self_targets' => $untrustedUrlTargets,
            'purged_urls_directly' => $purgedUrlsDirectly,
            'purge_skipped_urls' => $purgeSkippedUrls,
            'fanout_mode' => $result['fanout_mode'] ?? FanoutPolicy::MODE_SAME_LANGUAGE,
            'fanout_languages' => $fanoutLanguages,
            'trusted_urls' => $trustedUrls,
        ];
        if ($isExcludedPostTypeDirectTargeting) {
            $context['intent'] = 'content';
            $context['resolution'] = 'CONTENT_DIRECT';
            $context['fallback'] = false;
            $context['fallback_reason'] = '';
        }

        $context = array_merge(
            $context,
            ResolverContext::buildContext($resolver, $triggerLanguage, [
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
            ])
        );
        if ($correlationId !== '') {
            $context['correlation_id'] = $correlationId;
        }
        // --- Phase 2 SYNC/ASYNC emission partition ---
        // Resolution & purge already ran against the full target set; the partition
        // only splits what ECW sees synchronously vs. on the async processor tick.
        // The sync subset (critical freshness set) is built WITHOUT language expansion:
        //   - configured selected target URLs (pre-expansion post IDs → precise URLs)
        //   - trigger self URL (with unpublish/trash fallback)
        //   - trigger archive URLs (already resolved into $archiveUrls for this post)
        // The sync subset is then intersected with $purgedUrls so we never emit a URL
        // that wasn't actually purged. The async remainder is enqueued for the processor.
        $syncSelectedUrls = [];
        foreach ($configuredTargetPostIds as $configuredPostId) {
            $configuredPost = get_post((int) $configuredPostId);
            if (!$configuredPost instanceof \WP_Post) {
                continue;
            }
            $configuredUrl = ContentVisibility::resolvePreciseUrlForPost($configuredPost);
            if ($configuredUrl !== '') {
                $syncSelectedUrls[] = $configuredUrl;
            }
        }

        $triggerSelfUrl = ContentVisibility::resolvePreciseUrlForPost($post);
        if ($triggerSelfUrl === '') {
            // Unpublish / trash / non-viewable transitions: the precise resolver rejects
            // the URL because the post is no longer publicly viewable. Fall back to the raw
            // permalink so the sync subset still carries the trigger's own URL for
            // downstream purge bookkeeping.
            $fallback = get_permalink($postId);
            if (is_string($fallback) && $fallback !== '') {
                $triggerSelfUrl = $fallback;
                Debug::log('selfurl_fallback', [
                    'post_id' => $postId,
                    'reason' => $reason,
                ]);
            }
        }

        $syncCandidates = TrustDecider::normalizeUrls(array_merge(
            $syncSelectedUrls,
            $relationshipTrustedUrls,
            $archiveUrls,
            $triggerSelfUrl !== '' ? [$triggerSelfUrl] : []
        ));

        // Constrain sync subset to URLs actually purged (keeps emission honest).
        $syncUrls = TrustDecider::intersectWithUrls($syncCandidates, $purgedUrls);

        $syncFallbackApplied = false;
        if (empty($syncUrls)) {
            // Invariant: SYNC MUST NEVER BE EMPTY. Safety net — emit full purged set
            // synchronously (preserves pre-partition behavior on degenerate cases).
            $syncUrls = $purgedUrls;
            $syncFallbackApplied = true;
        }

        // Sync subset is fully trusted by construction (selected + self + trigger archives).
        // When the fallback applied we inherit the existing sync-path trust computation.
        $syncTrustedUrls = $syncFallbackApplied
            ? TrustDecider::intersectWithUrls($trustedUrls, $syncUrls)
            : TrustDecider::intersectWithUrls($syncCandidates, $syncUrls);
        if (empty($syncTrustedUrls) && !empty($syncUrls)) {
            // Invariant: trusted_urls must be a non-empty subset when sync has content.
            $syncTrustedUrls = TrustDecider::intersectWithUrls($trustedUrls, $syncUrls);
        }

        $asyncUrls = array_values(array_diff($purgedUrls, $syncUrls));

        if (Debug::verboseEnabled()) {
            Debug::log('partition_context', [
                'sync_count'  => count($syncUrls),
                'async_count' => count($asyncUrls),
                'total'       => count($purgedUrls),
            ]);
        }

        $splitActive = !empty($asyncDecision['should_schedule']) && !empty($asyncUrls);

        if ($splitActive) {
            $syncContext = array_merge($context, [
                'trusted_urls' => $syncTrustedUrls,
                'split_mode' => 'sync',
            ]);

            $syncEmissionPayload = EmissionBuilder::build($syncTrustedUrls, $syncUrls);
            $syncContext['trusted_urls'] = $syncEmissionPayload['trusted_urls'];

            Debug::log('split', [
                'post_id' => $postId,
                'reason' => $reason,
                'sync_count' => count($syncEmissionPayload['urls']),
                'async_count' => count($asyncUrls),
                'selfurl_fallback' => $triggerSelfUrl !== '' && $syncSelectedUrls === [] && $archiveUrls === [] ? true : false,
                'sync_safety_fallback' => $syncFallbackApplied,
            ]);

            Debug::info(sprintf(
                'invalidation emitted (partitioned): reason=%s host_post_types=%d sync_urls=%d async_urls=%d direct_url_purges=%d skipped_url_purges=%d',
                $reason,
                count($enabledConfiguredPostTypes),
                count($syncEmissionPayload['urls']),
                count($asyncUrls),
                count($purgedUrlsDirectly),
                count($purgeSkippedUrls)
            ));

            /** Emit canonical SYNC subset via shared helper boundary. */
            EmissionHelper::emit($syncEmissionPayload['urls'], $syncContext);

            // Enqueue the async remainder. No trust (invariant: trusted_urls only on sync),
            // no language expansion, no re-resolution. The processor runs the resolver
            // path at cron time and drives its own emission.
            $asyncCorrelationId = $correlationId !== ''
                ? $correlationId
                : EmissionHelper::getOrCreateRequestCorrelationId();
            AsyncInvalidationQueue::enqueue([
                'trigger_kind' => 'post',
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
                'reason' => $reason,
                'trigger_language' => $triggerLanguage,
                'immediate_target_page_ids' => [],
                'immediate_target_urls' => $asyncUrls,
                'immediate_trusted_urls' => [],
                'correlation_id' => $asyncCorrelationId,
                'split_mode' => 'async_tail',
            ]);
            AsyncInvalidationQueue::schedule((int) ($asyncDecision['delay_seconds'] ?? Config::DEFAULT_ASYNC_DELAY_SECONDS));
            Debug::log('async_enqueue', [
                'trigger_kind' => 'post',
                'entry_point' => 'async_tail',
                'post_id' => $postId,
                'reason' => $reason,
                'target_page_ids' => 0,
                'target_urls' => count($asyncUrls),
                'trusted_urls' => 0,
                'correlation_id' => $asyncCorrelationId,
            ]);

            return;
        }

        // Async disabled OR no async remainder → preserve pre-partition single-emission path.
        $emissionPayload = EmissionBuilder::build((array) ($context['trusted_urls'] ?? []), $purgedUrls);
        $context['trusted_urls'] = $emissionPayload['trusted_urls'];

        Debug::info(sprintf(
            'invalidation emitted: reason=%s host_post_types=%d urls=%d direct_url_purges=%d skipped_url_purges=%d',
            $reason,
            count($enabledConfiguredPostTypes),
            count($emissionPayload['urls']),
            count($purgedUrlsDirectly),
            count($purgeSkippedUrls)
        ));

        /** Emit canonical invalidation payload via shared helper boundary. */
        EmissionHelper::emit($emissionPayload['urls'], $context);
    }

    /**
     * Determine whether this intent-scoped trigger event was already processed in this request.
     *
     * @param int    $postId Post ID.
     * @param string $eventKey Intent key in event:scope form.
     * @return bool
     */
    private static function shouldSkipDuplicateEvent(int $postId, string $eventKey): bool
    {
        $postId = absint($postId);
        $post = $postId > 0 ? get_post($postId) : null;
        if ($postId <= 0) {
            LifecycleProbe::log('dedupe_decision', null, [
                'dedupe_system' => 'generic_lifecycle',
                'dedupe_decisions' => [
                    'event_key' => $eventKey,
                    'outcome' => 'skip_invalid_post_id',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            return true;
        }

        $parts = explode(':', $eventKey, 2);
        $event = sanitize_key((string) ($parts[0] ?? ''));
        $scope = sanitize_key((string) ($parts[1] ?? ''));

        if ($event === '' || $scope === '') {
            LifecycleProbe::log('dedupe_decision', $post instanceof \WP_Post ? $post : null, [
                'dedupe_system' => 'generic_lifecycle',
                'dedupe_decisions' => [
                    'event_key' => $eventKey,
                    'outcome' => 'skip_invalid_event_key',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => false,
                'generic_lifecycle_must_stop' => false,
            ]);
            return true;
        }

        $transientKey = sprintf('ekesto_ca_dedupe_%d_%s_%s', $postId, $event, $scope);
        $dedupeSystem = $scope === 'structural' ? 'structural' : 'generic_lifecycle';

        if (get_transient($transientKey)) {
            LifecycleProbe::log('dedupe_decision', $post instanceof \WP_Post ? $post : null, [
                'dedupe_system' => $dedupeSystem,
                'dedupe_decisions' => [
                    'event_key' => $eventKey,
                    'transient_key' => $transientKey,
                    'outcome' => 'skip_existing_transient',
                ],
                'invalidation_emitted' => false,
                'structural_claimed' => $scope === 'structural',
                'generic_lifecycle_must_stop' => $scope === 'structural',
            ]);
            return true;
        }

        set_transient($transientKey, 1, self::CONTENT_LIFECYCLE_CORRELATION_TTL);
        LifecycleProbe::log('dedupe_decision', $post instanceof \WP_Post ? $post : null, [
            'dedupe_system' => $dedupeSystem,
            'dedupe_decisions' => [
                'event_key' => $eventKey,
                'transient_key' => $transientKey,
                'outcome' => 'claim',
            ],
            'invalidation_emitted' => false,
            'structural_claimed' => false,
            'generic_lifecycle_must_stop' => false,
        ]);

        return false;
    }

    /**
     * Resolve one short-lived correlation ID across translated content siblings.
     *
     * @param int    $postId Triggering post ID.
     * @param string $eventKey Content lifecycle event key.
     * @return string Shared content lifecycle correlation ID.
     */
    private static function resolveContentLifecycleCorrelationId(int $postId, string $eventKey): string
    {
        $postId = absint($postId);
        if ($postId <= 0) {
            return EmissionHelper::getOrCreateRequestCorrelationId();
        }

        $postType = get_post_type($postId);
        if (!is_string($postType) || $postType === '') {
            return EmissionHelper::getOrCreateRequestCorrelationId();
        }

        $siblingIds = ObjectSiblingResolver::expandObjectIds($postId, $postType);
        $canonicalPostId = $siblingIds !== [] ? min($siblingIds) : $postId;
        $optionKey = self::OPTION_CONTENT_LIFECYCLE_CORRELATION_PREFIX . md5(
            $canonicalPostId . '|' . sanitize_key($eventKey)
        );
        $now = time();

        self::forgetOptionRuntimeCache($optionKey);
        $existing = get_option($optionKey, false);
        if (self::isValidContentLifecycleCorrelationLease($existing, $now)) {
            return trim((string) $existing['correlation_id']);
        }

        if ($existing !== false) {
            delete_option($optionKey);
            self::forgetOptionRuntimeCache($optionKey);
        }

        $correlationId = EmissionHelper::getOrCreateRequestCorrelationId();
        $lease = [
            'correlation_id' => $correlationId,
            'expires_at' => $now + self::CONTENT_LIFECYCLE_CORRELATION_TTL,
        ];
        if (add_option($optionKey, $lease, '', false)) {
            return $correlationId;
        }

        self::forgetOptionRuntimeCache($optionKey);
        $winner = get_option($optionKey, false);
        if (self::isValidContentLifecycleCorrelationLease($winner, time())) {
            return trim((string) $winner['correlation_id']);
        }

        return $correlationId;
    }

    /**
     * Determine whether a stored lifecycle correlation lease is usable.
     *
     * @param mixed $lease Stored option value.
     * @param int   $now Current Unix timestamp.
     * @return bool
     */
    private static function isValidContentLifecycleCorrelationLease($lease, int $now): bool
    {
        if (!is_array($lease) || !isset($lease['correlation_id']) || !is_string($lease['correlation_id'])) {
            return false;
        }

        $correlationId = trim($lease['correlation_id']);
        return $correlationId !== ''
            && strlen($correlationId) <= 128
            && preg_match('/^[A-Za-z0-9._:-]+$/', $correlationId) === 1
            && (int) ($lease['expires_at'] ?? 0) >= $now;
    }

    /**
     * Forget request-local WordPress option cache entries before a concurrent read.
     *
     * @param string $optionKey Option name.
     * @return void
     */
    private static function forgetOptionRuntimeCache(string $optionKey): void
    {
        wp_cache_delete($optionKey, 'options');
        wp_cache_delete('notoptions', 'options');
    }

    /**
     * Whether any enabled configured post type is configured for full flush mode.
     *
     * @param string[]         $configuredPostTypes Enabled configured post types.
     * @param \Ekesto\CacheInvalidator\Settings\SettingsRepository $settingsRepository Settings repository.
     * @return bool
     */
    private static function hasFullFlushPostType(array $configuredPostTypes, \Ekesto\CacheInvalidator\Settings\SettingsRepository $settingsRepository): bool
    {
        foreach ($configuredPostTypes as $configuredPostType) {
            if ($settingsRepository->getPostTypeTargetMode((string) $configuredPostType) === 'full_flush') {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine whether mapping-only purging should run for this trigger.
     *
     * @param array<int,string>  $configuredPostTypes Enabled configured post types.
     * @param bool               $isPubliclyQueryable Trigger post type public queryability.
     * @param array<string,mixed> $mapping Normalized mapping payload.
     * @return bool
     */
    private static function isMappingOnlyEligible(
        array $configuredPostTypes,
        bool $isPubliclyQueryable,
        array $mapping
    ): bool {
        if ($isPubliclyQueryable) {
            return false;
        }

        if (!empty($configuredPostTypes)) {
            return false;
        }

        return !empty($mapping['matched']);
    }

    /**
     * Add untrusted self URL target for unconfigured content triggers when resolution is empty.
     *
     * @param \WP_Post      $post Trigger post.
     * @param string        $reason Trigger reason.
     * @param string[]      $enabledConfiguredPostTypes Enabled configured post types in this pass.
     * @param bool          $postSelectorHasSelectors Whether post selectors are active.
     * @param bool          $archiveSelectorHasSelectors Whether archive selectors are active.
     * @param int[]         $targetPostIds Current target post IDs.
     * @param string[]      $untrustedUrlTargets Current untrusted URL targets.
     * @return bool Whether fallback was applied.
     */
    private static function applyUnconfiguredSelfTargetFallback(
        \WP_Post $post,
        string $reason,
        array $enabledConfiguredPostTypes,
        bool $postSelectorHasSelectors,
        bool $archiveSelectorHasSelectors,
        array &$targetPostIds,
        array &$untrustedUrlTargets
    ): bool {
        if (!empty($enabledConfiguredPostTypes)) {
            return false;
        }

        if ($postSelectorHasSelectors || $archiveSelectorHasSelectors) {
            return false;
        }

        $targetPostIds = TrustDecider::normalizePostIds($targetPostIds);
        $untrustedUrlTargets = TrustDecider::normalizeUrls($untrustedUrlTargets);

        if (!empty($targetPostIds) || !empty($untrustedUrlTargets)) {
            return false;
        }

        $selfUrl = ContentVisibility::resolvePreciseUrlForPost($post);
        if ($selfUrl === '') {
            return false;
        }

        $untrustedUrlTargets = TrustDecider::normalizeUrls(array_merge($untrustedUrlTargets, [$selfUrl]));

        Debug::log('eci_self_target_applied', [
            'post_id' => (int) $post->ID,
            'post_type' => (string) $post->post_type,
            'reason' => $reason,
            'trusted' => false,
            'path' => 'unconfigured_self_target',
        ]);

        return true;
    }

    /**
     * Apply mapped targets to a base target ID list using mapping mode semantics.
     *
     * @param int[]              $baseTargetPostIds Base post IDs.
     * @param array<string,mixed> $mapping Normalized mapping payload.
     * @return int[]
     */
    private static function applyPostTargetMapping(array $baseTargetPostIds, array $mapping): array
    {
        if (empty($mapping['matched'])) {
            return array_values(array_unique(array_filter(array_map('absint', $baseTargetPostIds), static fn(int $id): bool => $id > 0)));
        }

        $mappedTargetIds = is_array($mapping['target_ids'] ?? null) ? $mapping['target_ids'] : [];
        if (($mapping['mode'] ?? 'append') === 'replace') {
            $baseTargetPostIds = $mappedTargetIds;
        } else {
            $baseTargetPostIds = array_merge($baseTargetPostIds, $mappedTargetIds);
        }

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

    /**
     * Apply flat post type expansion for this trigger pass.
     *
     * Expansion scope is the UNION of all enabled configured post types
     * participating in the current resolution pass.
     *
     * @param int[]                    $targetPostIds Base target post IDs.
     * @param string[]                 $enabledConfiguredPostTypes Enabled configs in this pass.
     * @param \Ekesto\CacheInvalidator\Settings\SettingsRepository $settingsRepository Settings repository.
     * @return int[]
     */
    private static function applyFlatPostTypeExpansion(
        array $targetPostIds,
        array $enabledConfiguredPostTypes,
        \Ekesto\CacheInvalidator\Settings\SettingsRepository $settingsRepository
    ): array {
        $targetPostIds = ContentVisibility::normalizePostIds($targetPostIds);

        // Full flush is request-terminal for this trigger path; expansion must not run.
        if (self::hasFullFlushPostType($enabledConfiguredPostTypes, $settingsRepository)) {
            return $targetPostIds;
        }

        $expandPostTypes = PostTypeExpansionResolver::collectConfiguredExpandPostTypes(
            $enabledConfiguredPostTypes,
            $settingsRepository
        );

        return PostTypeExpansionResolver::expandTargetPostIds($targetPostIds, $expandPostTypes);
    }

    /**
     * Handle deep invalidation (relationship propagation) for configured post types.
     *
     * @param \WP_Post                       $post
     * @param string                         $reason
     * @param string[]                       $configuredPostTypes Enabled configured post types.
     * @param CacheAdapterInterface          $adapter
     * @param LanguageResolverInterface|null $resolver
     * @param string|null                    $triggerLanguage
     * @param int[]                          &$targetPostIds Target post IDs (modified by reference).
     * @param string[]                       &$archiveUrls Archive URL targets (modified by reference).
     * @param int[]                          &$relationshipTrustedPostIds Relationship-derived host post IDs eligible for trusted URL emission.
     * @param string[]                       $trustedUrls Trusted immediate URL targets.
     * @return bool Whether any async jobs were scheduled.
     */
    private static function handleDeepInvalidation(
        \WP_Post $post,
        string $reason,
        array $configuredPostTypes,
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage,
        array &$targetPostIds,
        array &$archiveUrls,
        array &$relationshipTrustedPostIds,
        array $trustedUrls = []
    ): bool {
        $postType = sanitize_key((string) $post->post_type);
        $postId = (int) $post->ID;
        $settingsRepository = InvalidatorRuntime::settingsRepository();
        $edges = RelationshipGraphResolver::resolveRelationshipEdges($postType);

        if (empty($edges)) {
            return false;
        }

        $configuredLookup = array_fill_keys($configuredPostTypes, true);

        $postIdsByPostType = [
            $postType => [$postId],
        ];
        $discoveredTargetKeys = [];

        foreach ($edges as [$fromPostType, $toPostType]) {
            $fromIds = $postIdsByPostType[$fromPostType] ?? [];
            $fromIds = array_values(array_unique(array_filter(array_map('absint', $fromIds), static fn(int $id): bool => $id > 0)));
            if (empty($fromIds)) {
                continue;
            }

            $toIds = [];
            foreach ($fromIds as $fromId) {
                $foundIds = RelationshipResolver::findHostPostIds($toPostType, $fromPostType, $fromId);
                foreach ($foundIds as $foundId) {
                    $foundId = absint($foundId);
                    if ($foundId <= 0) {
                        continue;
                    }

                    $targetKey = $toPostType . ':' . $foundId;
                    if (isset($discoveredTargetKeys[$targetKey])) {
                        continue;
                    }

                    $discoveredTargetKeys[$targetKey] = true;
                    $toIds[] = $foundId;
                }
            }

            if (empty($toIds)) {
                continue;
            }

            $isConfiguredHost = isset($configuredLookup[$toPostType]);
            $canTraverseBeyondHost = true;

            if ($isConfiguredHost) {
                $hostConfig = $settingsRepository->getPostTypeConfig($toPostType);
                $hostEnabled = $hostConfig !== null && ($hostConfig['enabled'] ?? true);
                $hostPropagation = $hostConfig['propagation'] ?? [];
                $relationshipsEnabled = (bool) ($hostPropagation['relationships_enabled'] ?? true);
                $canTraverseBeyondHost = $hostEnabled && $relationshipsEnabled;

                if ($canTraverseBeyondHost) {
                    $ptObj = get_post_type_object($toPostType);
                    if ($ptObj && $ptObj->publicly_queryable) {
                        $targetPostIds = array_merge($targetPostIds, $toIds);
                        $relationshipTrustedPostIds = array_merge($relationshipTrustedPostIds, $toIds);
                    }
                }
            }

            if ($canTraverseBeyondHost) {
                $postIdsByPostType[$toPostType] = array_values(array_unique(array_merge(
                    $postIdsByPostType[$toPostType] ?? [],
                    $toIds
                )));
            }
        }

        $targetPostIds = array_values(array_unique(array_filter(array_map('absint', $targetPostIds), static fn(int $id): bool => $id > 0)));
        $relationshipTrustedPostIds = TrustDecider::normalizePostIds($relationshipTrustedPostIds);
        if (Debug::verboseEnabled()) {
            Debug::log('deep_invalidation_execution', [
                'post_id' => $postId,
                'hosts_found' => count($discoveredTargetKeys ?? []),
                'urls_generated' => count($archiveUrls ?? []),
                'trusted_relationship_post_ids' => count($relationshipTrustedPostIds),
            ]);
        }

        return !empty($discoveredTargetKeys);
    }

    /**
     * Handle synced pattern updates with best-effort precise targeting and global fallback.
     *
     * @param \WP_Post $post Synced pattern post.
     * @return void
     */
    private static function handleSyncedPatternUpdate(\WP_Post $post): void
    {
        if ($post->post_status !== 'publish') {
            return;
        }

        $global = InvalidatorRuntime::settingsRepository()->getGlobalInvalidation();
        if (empty($global['synced_patterns_enabled'])) {
            return;
        }

        $result = self::resolveStructuralContainerResult($post);
        self::handleResolverFirstEntityUpdate(
            $post,
            $result,
            (bool) ($global['pattern_global_fallback_enabled'] ?? true),
            'synced_pattern_change',
            'synced_pattern',
            'post',
            [
                'archives' => [],
                'groups' => self::resolveEnabledConfiguredPostTypes(),
                'pattern_references' => ContentVisibility::normalizePostIds((array) ($result['post_ids'] ?? [])),
            ]
        );
    }

    /**
     * Handle navigation updates with resolver-first targeting and fallback safety.
     *
     * @param \WP_Post $post Navigation entity.
     * @return void
     */
    private static function handleNavigationUpdate(\WP_Post $post): void
    {
        $global = InvalidatorRuntime::settingsRepository()->getGlobalInvalidation();
        if (empty($global['navigation_enabled']) || $post->post_status !== 'publish') {
            Debug::log(sprintf(
                'navigation_change skipped: enabled=%s status=%s post_id=%d',
                !empty($global['navigation_enabled']) ? 'yes' : 'no',
                (string) $post->post_status,
                (int) $post->ID
            ));
            return;
        }

        Debug::log(sprintf('navigation_change trigger: post_id=%d', (int) $post->ID));
        self::executeGlobalInvalidationForPostTrigger($post, 'navigation_change', 'post');
    }

    /**
     * Handle template-part updates with resolver-first targeting and fallback safety.
     *
     * @param \WP_Post $post Template part entity.
     * @return void
     */
    private static function handleTemplatePartUpdate(\WP_Post $post): void
    {
        if ($post->post_status !== 'publish') {
            Debug::log(sprintf(
                'template_part_change skipped: status=%s post_id=%d',
                (string) $post->post_status,
                (int) $post->ID
            ));
            return;
        }

        Debug::log(sprintf(
            'template/global style trigger: post_type=%s post_id=%d',
            $post->post_type,
            (int) $post->ID
        ));

        $result = self::resolveStructuralContainerResult($post);
        $resolvedPostIds = ContentVisibility::normalizePostIds((array) ($result['post_ids'] ?? []));
        $resolvedUrls = ContentVisibility::normalizeUrls((array) ($result['target_urls'] ?? []));
        $hasReferences = (bool) ($result['has_references'] ?? (!empty($resolvedPostIds) || !empty($resolvedUrls)));
        $uncertain = (bool) ($result['uncertain'] ?? false);
        $uncertainDueToScanGap = (bool) ($result['uncertain_due_to_scan_gap'] ?? false);
        $fallbackReasonHint = sanitize_key((string) ($result['fallback_reason_hint'] ?? ''));

        if (!$uncertain && (!empty($resolvedPostIds) || !empty($resolvedUrls))) {
            $triggerClass = !empty($resolvedUrls) ? 'presentation' : 'post';
            $triggerKind = !empty($resolvedUrls) ? 'template_archive' : 'post';
            self::logResolvedEntitySummary($post, $result, false, '');
            self::invalidateResolvedTargets($post, $resolvedPostIds, $resolvedUrls, 'template_change', $triggerClass, $triggerKind);
            return;
        }

        if (!$uncertain && $hasReferences) {
            self::logResolvedEntitySummary($post, $result, false, '');
            Debug::log('template_change resolved_empty_scope', [
                'post_id' => (int) $post->ID,
                'post_type' => sanitize_key((string) $post->post_type),
            ]);
            return;
        }

        $fallbackReason = $fallbackReasonHint !== ''
            ? $fallbackReasonHint
            : ((!empty($resolvedPostIds) || !empty($resolvedUrls) || $uncertainDueToScanGap)
            ? 'resolver_uncertain'
            : 'unresolved_template_usage');

        self::logResolvedEntitySummary($post, $result, true, $fallbackReason);
        self::executeGlobalInvalidationForPostTrigger(
            $post,
            'template_change',
            'post',
            [
                'fallback' => true,
                'fallback_reason' => $fallbackReason,
            ]
        );
    }

    /**
     * Handle Elementor structural entity updates with resolver-first safety.
     *
     * Called only by routeStructuralTrigger(); do not call directly from trigger
     * entrypoints or other handlers because handler-level dedupe is intentionally absent.
     *
     * @param \WP_Post $post Elementor structural entity.
     * @return void
     */
    private static function handleElementorStructuralUpdate(\WP_Post $post): void
    {
        $postId = (int) $post->ID;

        if ($post->post_status !== 'publish') {
            Debug::log(sprintf(
                'elementor_structural_change skipped: status=%s post_type=%s post_id=%d',
                (string) $post->post_status,
                sanitize_key((string) $post->post_type),
                $postId
            ));
            return;
        }

        $reason = $post->post_type === 'elementor_component'
            ? 'elementor_component_change'
            : 'elementor_template_change';

        $result = self::resolveStructuralContainerResult($post);

        self::handleResolverFirstEntityUpdate(
            $post,
            $result,
            true,
            $reason
        );
    }

    /**
     * Handle Elementor library updates using classifier-driven routing.
     *
     * Called only by routeStructuralTrigger(); do not call directly from trigger
     * entrypoints or other handlers because handler-level dedupe is intentionally absent.
     *
     * @param \WP_Post            $post Elementor library entity.
     * @param array<string,mixed> $context Additional handler context.
     * @return void
     */
    private static function handleElementorLibraryUpdate(\WP_Post $post, array $context = []): void
    {
        $postId = (int) $post->ID;
        $templateType = self::resolveElementorLibraryTemplateType($postId);
        $triggerSource = sanitize_key((string) ($context['trigger_source'] ?? ''));

        if ($post->post_status !== 'publish') {
            Debug::log(sprintf(
                'elementor_library_change skipped: status=%s post_id=%d',
                (string) $post->post_status,
                $postId
            ));
            return;
        }

        $classification = ContentVisibility::classifyElementorLibraryPost($post);

        if ($classification === ElementorDocumentClassification::NON_STRUCTURAL) {
            Debug::log(
                'non_structural_fallback',
                [
                    'event' => 'non_structural_fallback',
                    'post_id' => $postId,
                    'original_post_type' => sanitize_key((string) $post->post_type),
                ]
            );

            self::executeGlobalInvalidationForPostTrigger(
                $post,
                'non_structural_change',
                'post',
                [
                    'fallback' => true,
                    'fallback_reason' => 'non_structural_elementor_ignored',
                    'neutral_identity' => true,
                ]
            );
            return;
        }

        if ($classification === ElementorDocumentClassification::STRUCTURAL_CONTAINER) {
            $result = self::resolveStructuralContainerResult($post);
            self::handleResolverFirstEntityUpdate(
                $post,
                $result,
                true,
                self::resolveElementorLibraryChangeReason($templateType)
            );
            return;
        }

        if (
            $triggerSource === 'elementor_document_hook'
            && !array_key_exists('previous_conditions', $context)
        ) {
            $context['previous_conditions'] = self::getStoredConditions($postId);
        }

        if (self::attemptElementorConditionResolution($post, $context)) {
            self::maybeStoreConditionsFromDocumentSave($postId, $templateType, $triggerSource);
            return;
        }

        if (
            $triggerSource === 'elementor_document_hook'
            && self::attemptElementorConditionResolution($post, ['previous_conditions' => []])
        ) {
            self::maybeStoreConditionsFromDocumentSave($postId, $templateType, $triggerSource);
            return;
        }

        Debug::log(sprintf(
            'elementor_library_change fallback: classification=%s post_id=%d',
            sanitize_key($classification),
            $postId
        ));

        self::executeGlobalInvalidationForPostTrigger(
            $post,
            'elementor_template_change_fallback',
            'post',
            [
                'fallback' => true,
                'fallback_reason' => 'elementor_classification',
            ]
        );

        self::maybeStoreConditionsFromDocumentSave($postId, $templateType, $triggerSource);
    }

    /**
     * Attempt Elementor condition-based targeted resolution for a presentation template.
     *
     * Reads `_elementor_conditions` and resolves deterministic conditions into concrete
     * invalidation targets. When conditions are resolvable, executes GLOBAL_TARGETED
     * and returns true. When conditions are unresolvable or absent, returns false
     * so the caller can proceed with its own fallback.
     *
     * @param \WP_Post            $post Elementor library post.
     * @param array<string,mixed> $context Previous-condition context.
     * @return bool True when condition resolution handled the invalidation.
     */
    private static function attemptElementorConditionResolution(\WP_Post $post, array $context = []): bool
    {
        $postId = (int) $post->ID;

        $current = self::normalizeElementorConditions(get_post_meta($postId, '_elementor_conditions', true));
        $previous = self::normalizeElementorConditions($context['previous_conditions'] ?? []);
        $effective = array_values(array_unique(array_merge($previous, $current)));

        if (empty($effective)) {
            return true;
        }

        if ($effective === $current) {
            $result = ElementorIntegrationRegistry::current()->resolveElementorConditions($postId);
        } else {
            $result = ElementorIntegrationRegistry::current()->resolveEffectiveConditions($postId, $effective);
        }

        if (!$result['certain']) {
            Debug::verbose(sprintf(
                'elementor_condition_resolution: not resolvable post_id=%d reason=%s',
                $postId,
                sanitize_key($result['fallback_reason'] ?? 'unknown')
            ));
            return false;
        }

        if (empty($result['post_ids']) && empty($result['urls'])) {
            Debug::verbose(sprintf(
                'elementor_condition_resolution: resolved empty post_id=%d',
                $postId
            ));
            return false;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                'elementor_condition_resolution skipped_terminal_global_already_run: post_id=%d',
                $postId
            ));
            return true;
        }

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

        $languageResolver = LanguageResolverFactory::create();
        $triggerLanguage = $languageResolver !== null ? $languageResolver->getTriggerLanguage($postId) : null;

        $context = [
            'trigger_class' => 'post',
            'trigger_kind' => 'post',
            'trigger_event' => 'elementor_template_change',
            'reason' => 'elementor_template_change',
            'trigger_post_id' => $postId,
            'trigger_post_type' => sanitize_key((string) $post->post_type),
            'intent' => 'GLOBAL_TARGETED',
            'resolution' => 'ELEMENTOR_CONDITIONS',
            'fallback' => false,
            'fallback_reason' => '',
            'trusted_urls' => TrustDecider::trustedUrlsFromOriginUrls([
                TrustDecider::ORIGIN_STRUCTURAL_TARGETS => (array) ($result['urls'] ?? []),
            ]),
        ];
        Debug::verbose('[ECI] untrusted origin', [
            'origin' => 'derived',
            'reason' => 'elementor_conditions',
        ]);

        $context = array_merge(
            $context,
            ResolverContext::buildContext($languageResolver, $triggerLanguage, [
                'trigger_post_id' => $postId,
                'trigger_post_type' => sanitize_key((string) $post->post_type),
            ])
        );

        $resolverResult = [
            'post_ids' => $result['post_ids'],
            'urls' => $result['urls'],
            'certain' => true,
        ];

        Debug::log(sprintf(
            'elementor_condition_resolution: targeted post_id=%d post_ids=%d urls=%d',
            $postId,
            count($result['post_ids']),
            count($result['urls'])
        ));

        GlobalInvalidationExecutor::executeGlobalTargetedOrEscalate(
            $adapter,
            $languageResolver,
            $triggerLanguage,
            $resolverResult,
            'elementor_template_change',
            $context
        );

        return true;
    }

    /**
     * Read previously stored Elementor conditions from persistent post meta.
     *
     * @param int $postId Elementor template post ID.
     * @return array<int,string>
     */
    private static function getStoredConditions(int $postId): array
    {
        return self::normalizeElementorConditions(
            get_post_meta($postId, self::ELEMENTOR_CONDITIONS_BASELINE_META_KEY, true)
        );
    }

    /**
     * Persist Elementor conditions for deterministic previous-state tracking.
     *
     * @param int $postId Elementor template post ID.
     * @param array<int,string> $conditions Normalized condition strings.
     * @return void
     */
    private static function storeConditions(int $postId, array $conditions): void
    {
        if (empty($conditions)) {
            delete_post_meta($postId, self::ELEMENTOR_CONDITIONS_BASELINE_META_KEY);
            return;
        }

        update_post_meta($postId, self::ELEMENTOR_CONDITIONS_BASELINE_META_KEY, array_values($conditions));
    }

    /**
     * Normalize Elementor condition payloads to deterministic list format.
     *
     * @param mixed $raw Raw `_elementor_conditions` payload.
     * @return array<int,string>
     */
    private static function normalizeElementorConditions($raw): array
    {
        if (!is_array($raw)) {
            return [];
        }

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

            $condition = trim($condition);
            if ($condition === '') {
                continue;
            }

            $normalized[] = $condition;
        }

        return array_values(array_unique($normalized));
    }

    /**
     * Persist current conditions for document-save-driven presentation updates.
     *
     * @param int $postId Elementor template post ID.
     * @param string $templateType Elementor template type.
     * @param string $triggerSource Structural trigger source.
     * @return void
     */
    private static function maybeStoreConditionsFromDocumentSave(int $postId, string $templateType, string $triggerSource): void
    {
        if ($triggerSource !== 'elementor_document_hook') {
            return;
        }

        if (!in_array($templateType, self::ELEMENTOR_PRESENTATION_TEMPLATE_TYPES, true)) {
            return;
        }

        self::storeConditions(
            $postId,
            self::normalizeElementorConditions(get_post_meta($postId, '_elementor_conditions', true))
        );
    }

    /**
     * Resolve a structural container change through the shared orchestrator.
     *
     * @param \WP_Post $post Structural entity.
     * @return array{post_ids:int[],target_urls:string[],has_references:bool,uncertain:bool,uncertain_due_to_context:bool,uncertain_due_to_scan_gap:bool,direct_matches_count:int,direct_url_matches_count:int,structural_matches_count:int,fallback_reason_hint:string}
     */
    private static function resolveStructuralContainerResult(\WP_Post $post): array
    {
        return (new StructuralContainerResolver())->resolveReferencedPostIds($post);
    }

    /**
     * Handle resolver-first entity invalidation with centralized fallback logic.
     *
     * @param \WP_Post             $post Changed entity.
     * @param array<string,mixed>  $result Resolver result payload.
     * @param bool                 $fallbackDefault Default fallback preference.
     * @param string               $reason Invalidation reason.
     * @param string               $triggerClass Trigger class.
     * @param string               $triggerKind Trigger kind.
     * @param array<string,mixed>  $extraContext Extra context payload.
     * @return void
     */
    private static function handleResolverFirstEntityUpdate(
        \WP_Post $post,
        array $result,
        bool $fallbackDefault,
        string $reason,
        string $triggerClass = 'post',
        string $triggerKind = 'post',
        array $extraContext = []
    ): void {
        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                '%s skipped_terminal_global_already_run: post_id=%d post_type=%s',
                $reason,
                (int) $post->ID,
                sanitize_key((string) $post->post_type)
            ));
            return;
        }

        $resolvedPostIds = ContentVisibility::normalizePostIds((array) ($result['post_ids'] ?? []));
        $hasReferences = (bool) ($result['has_references'] ?? !empty($resolvedPostIds));
        $uncertain = (bool) ($result['uncertain'] ?? false);
        $useFallback = self::resolveFallbackPreference($fallbackDefault, $post, $result);

        if ($uncertain) {
            if ($useFallback) {
                self::logResolvedEntitySummary($post, $result, true, 'resolver_uncertain');
                self::executeGlobalInvalidationForPostTrigger(
                    $post,
                    $reason . '_fallback',
                    $triggerClass,
                    array_merge($extraContext, [
                        'fallback' => true,
                        'fallback_reason' => 'resolver_uncertain',
                    ])
                );
                return;
            }

            self::logResolvedEntitySummary($post, $result, false, 'fallback_disabled');
            return;
        }

        if (!empty($resolvedPostIds)) {
            self::logResolvedEntitySummary($post, $result, false, '');
            self::invalidateResolvedReferences(
                $post,
                $resolvedPostIds,
                $reason,
                $triggerClass,
                $triggerKind,
                $extraContext
            );
            return;
        }

        if ($hasReferences) {
            self::logResolvedEntitySummary($post, $result, false, '');
            return;
        }

        self::logResolvedEntitySummary($post, $result, false, '');
    }

    /**
     * Apply the centralized fallback filter.
     *
     * @param bool                $fallbackDefault Default fallback preference.
     * @param \WP_Post            $post Changed entity.
     * @param array<string,mixed> $result Resolver result.
     * @return bool
     */
    private static function resolveFallbackPreference(bool $fallbackDefault, \WP_Post $post, array $result): bool
    {
        /**
         * Filter whether resolver uncertainty should fallback to global invalidation.
         *
         * @param bool     $fallbackDefault Whether fallback is enabled.
         * @param \WP_Post $post Changed entity.
         * @param array<string,mixed> $result Resolver result payload.
         */
        return (bool) apply_filters(
            'ekesto_ci_should_fallback_global',
            $fallbackDefault,
            $post,
            $result
        );
    }

    /**
     * Log a compact resolver summary for support tracing.
     *
     * @param \WP_Post            $post Changed entity.
     * @param array<string,mixed> $result Resolver result payload.
     * @param bool                $fallback Whether fallback was used.
     * @param string              $fallbackReason Fallback reason.
     * @return void
     */
    private static function logResolvedEntitySummary(
        \WP_Post $post,
        array $result,
        bool $fallback,
        string $fallbackReason
    ): void {
        $directTargets = (int) ($result['direct_matches_count'] ?? count((array) ($result['post_ids'] ?? [])));
        $directUrlTargets = (int) ($result['direct_url_matches_count'] ?? count((array) ($result['target_urls'] ?? [])));
        $structuralRefs = (int) ($result['structural_matches_count'] ?? 0);

        Debug::log(sprintf(
            'entity resolution summary: entity_type=%s entity_id=%d direct_targets=%d direct_url_targets=%d structural_refs=%d uncertain=%s fallback=%s fallback_reason=%s',
            sanitize_key((string) $post->post_type),
            (int) $post->ID,
            $directTargets,
            $directUrlTargets,
            $structuralRefs,
            !empty($result['uncertain']) ? 'yes' : 'no',
            $fallback ? 'yes' : 'no',
            sanitize_key($fallbackReason)
        ));
    }

    /**
     * Invalidate precise resolver-confirmed references for one entity update.
     *
     * @param \WP_Post            $post Changed entity.
     * @param int[]               $resolvedPostIds Resolver-confirmed direct targets.
     * @param string[]            $resolvedUrls Resolver-confirmed direct URL targets.
     * @param string              $reason Invalidation reason.
     * @param string              $triggerClass Trigger class.
     * @param string              $triggerKind Trigger kind.
     * @param array<string,mixed> $extraContext Extra context payload.
     * @return void
     */
    private static function invalidateResolvedTargets(
        \WP_Post $post,
        array $resolvedPostIds,
        array $resolvedUrls,
        string $reason,
        string $triggerClass = 'post',
        string $triggerKind = 'post',
        array $extraContext = []
    ): void {
        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            return;
        }

        $resolvedPostIds = ContentVisibility::normalizePostIds($resolvedPostIds);
        $resolvedUrls = ContentVisibility::normalizeUrls($resolvedUrls);
        if (empty($resolvedPostIds) && empty($resolvedUrls)) {
            return;
        }

        $postId = (int) $post->ID;
        $postType = sanitize_key((string) $post->post_type);
        $resolver = LanguageResolverFactory::create();
        $triggerLanguage = $resolver ? $resolver->getTriggerLanguage($postId) : null;
        $trustedStructuralUrls = TrustDecider::trustedUrlsFromOriginUrls([
            TrustDecider::ORIGIN_STRUCTURAL_TARGETS => array_merge(
                $resolvedUrls,
                self::resolvePermalinksForPostIds($resolvedPostIds)
            ),
        ]);

        $asyncDecision = InvalidatorRuntime::settingsRepository()->getAsyncSchedulingDecision();
        if (!empty($asyncDecision['should_schedule'])) {
            $correlationId = EmissionHelper::getOrCreateRequestCorrelationId();
            $queuedTriggerKind = sanitize_key($triggerKind);
            if ($queuedTriggerKind === 'post' && ContentVisibility::isStructuralPostType($postType)) {
                $queuedTriggerKind = 'structural_targets';
            }

            AsyncInvalidationQueue::enqueue([
                'trigger_class' => sanitize_key($triggerClass),
                'trigger_kind' => $queuedTriggerKind,
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
                'template_slug' => sanitize_key((string) $post->post_name),
                'reason' => $reason,
                'trigger_language' => $triggerLanguage,
                'immediate_target_page_ids' => $resolvedPostIds,
                'immediate_target_urls' => $resolvedUrls,
                'immediate_trusted_urls' => $trustedStructuralUrls,
                'correlation_id' => $correlationId,
            ]);
            AsyncInvalidationQueue::schedule((int) ($asyncDecision['delay_seconds'] ?? Config::DEFAULT_ASYNC_DELAY_SECONDS));
            Debug::log('async_enqueue', [
                'trigger_kind' => $queuedTriggerKind,
                'entry_point' => 'standard_queue',
                'post_id' => $postId,
                'template_slug' => sanitize_key((string) $post->post_name),
                'reason' => $reason,
                'target_page_ids' => count($resolvedPostIds),
                'target_urls' => count($resolvedUrls),
                'trusted_urls' => count($trustedStructuralUrls),
                'correlation_id' => $correlationId,
            ]);
            return;
        }

        $result = PurgeCoordinator::purgeTargets(
            $resolvedPostIds,
            $resolvedUrls,
            $adapter,
            $resolver,
            $triggerLanguage,
            [
                'trigger_class' => sanitize_key($triggerClass),
                'trigger_kind' => sanitize_key($triggerKind),
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
                'trigger_event' => $reason,
                'reason' => $reason,
                'intent' => 'GLOBAL_TARGETED',
                'resolution' => 'GLOBAL_TARGETED',
                'fallback' => false,
                'fallback_reason' => '',
            ]
        );
        $purgedUrls = ContentVisibility::normalizeUrls((array) ($result['emitted_urls'] ?? []));

        if (empty($purgedUrls)) {
            Debug::log(sprintf(
                '%s #%d processed: emitted_urls=0',
                $postType,
                $postId
            ));
            return;
        }

        $fanoutLanguages = array_values(array_unique(array_filter(array_map(
            static fn($language): string => trim((string) $language),
            (array) ($result['fanout_languages'] ?? [])
        ))));
        sort($fanoutLanguages, SORT_STRING);

        $context = array_merge([
            'source' => $adapter->getKey(),
            'source_label' => $adapter->getLabel(),
            'reason' => $reason,
            'trigger_class' => sanitize_key($triggerClass),
            'trigger_kind' => sanitize_key($triggerKind),
            'trigger_event' => $reason,
            'trigger_post_id' => $postId,
            'trigger_post_type' => $postType,
            'intent' => 'GLOBAL_TARGETED',
            'resolution' => 'GLOBAL_TARGETED',
            'archives' => ContentVisibility::normalizeUrls((array) ($extraContext['archives'] ?? [])),
            'resolved_references' => $resolvedPostIds,
            'resolved_target_urls' => $resolvedUrls,
            'purged_urls_directly' => ContentVisibility::normalizeUrls((array) ($result['purged_urls_directly'] ?? [])),
            'purge_skipped_urls' => ContentVisibility::normalizeUrls((array) ($result['purge_skipped_urls'] ?? [])),
            'fanout_mode' => $result['fanout_mode'] ?? FanoutPolicy::MODE_SAME_LANGUAGE,
            'fanout_languages' => $fanoutLanguages,
            'fallback' => false,
            'fallback_reason' => '',
            'trusted_urls' => TrustDecider::trustedUrlsFromOriginUrls([
                TrustDecider::ORIGIN_STRUCTURAL_TARGETS => $purgedUrls,
            ]),
        ], $extraContext);
        Debug::verbose('[ECI] untrusted origin', [
            'origin' => !empty($resolvedUrls) ? 'structural_targets' : 'derived',
            'reason' => !empty($resolvedUrls) ? 'template_archive' : 'relationship_references',
        ]);
        $context['resolved_references'] = $resolvedPostIds;

        $context = array_merge(
            $context,
            ResolverContext::buildContext($resolver, $triggerLanguage, [
                'trigger_post_id' => $postId,
                'trigger_post_type' => $postType,
            ])
        );
        $emissionPayload = EmissionBuilder::build((array) ($context['trusted_urls'] ?? []), $purgedUrls);
        $context['trusted_urls'] = $emissionPayload['trusted_urls'];
        LifecycleProbe::log('invalidation_emit', $post, [
            'hook_name' => current_filter(),
            'reason' => $reason,
            'dedupe_decisions' => [],
            'invalidation_emitted' => true,
            'emitted_url_count' => count($emissionPayload['urls']),
            'structural_claimed' => ContentVisibility::isStructuralPostType($postType),
            'generic_lifecycle_must_stop' => ContentVisibility::isStructuralPostType($postType),
        ]);

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

    /**
     * Invalidate precise resolver-confirmed post references.
     *
     * @param \WP_Post            $post Changed entity.
     * @param int[]               $resolvedPostIds Resolver-confirmed direct targets.
     * @param string              $reason Invalidation reason.
     * @param string              $triggerClass Trigger class.
     * @param string              $triggerKind Trigger kind.
     * @param array<string,mixed> $extraContext Extra context payload.
     * @return void
     */
    private static function invalidateResolvedReferences(
        \WP_Post $post,
        array $resolvedPostIds,
        string $reason,
        string $triggerClass = 'post',
        string $triggerKind = 'post',
        array $extraContext = []
    ): void {
        self::invalidateResolvedTargets($post, $resolvedPostIds, [], $reason, $triggerClass, $triggerKind, $extraContext);
    }

    /**
     * Resolve enabled configured post types for compatibility metadata on precise pattern emissions.
     *
     * @return string[]
     */
    private static function resolveEnabledConfiguredPostTypes(): array
    {
        $settingsRepository = InvalidatorRuntime::settingsRepository();
        $enabledPostTypes = [];

        foreach ($settingsRepository->getConfiguredPostTypes() as $configuredPostType) {
            if (!$settingsRepository->isPostTypeEnabled((string) $configuredPostType)) {
                continue;
            }

            $enabledPostTypes[] = (string) $configuredPostType;
        }

        $enabledPostTypes = array_values(array_unique(array_filter(array_map('strval', $enabledPostTypes))));
        sort($enabledPostTypes, SORT_STRING);

        return $enabledPostTypes;
    }

    /**
     * Execute post-trigger full flush and emit broad invalidation URL coverage.
     *
     * @param CacheAdapterInterface          $adapter Active adapter.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string|null                    $triggerLanguage Trigger language.
     * @param int                            $postId Trigger post ID.
     * @param string                         $postType Trigger post type.
     * @param string                         $reason Trigger reason.
     * @param string[]                       $matchedPostTypes Matched configured post types.
     * @return void
     */
    private static function emitPostFullFlushInvalidation(
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage,
        int $postId,
        string $postType,
        string $reason,
        array $matchedPostTypes
    ): void {
        $context = [
            'trigger_class' => 'post',
            'trigger_kind' => 'post',
            'trigger_event' => $reason,
            'reason' => $reason,
            'trigger_post_id' => $postId,
            'trigger_post_type' => $postType,
            'groups' => array_values(array_unique($matchedPostTypes)),
            'full_flush' => true,
            'target_mode' => 'full_flush',
        ];

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

        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            $resolver,
            $triggerLanguage,
            $reason,
            $context
        );
    }

    /**
     * Execute global invalidation for a non-content post trigger.
     *
     * @param \WP_Post             $post Trigger post.
     * @param string               $reason Invalidation reason.
     * @param string               $triggerClass Trigger class.
     * @param array<string,mixed>  $extraContext Extra context payload.
     * @return void
     */
    private static function executeGlobalInvalidationForPostTrigger(
        \WP_Post $post,
        string $reason,
        string $triggerClass = 'post',
        array $extraContext = []
    ): void {
        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                '%s skipped_terminal_global_already_run: post_id=%d post_type=%s',
                $reason,
                (int) $post->ID,
                sanitize_key((string) $post->post_type)
            ));
            return;
        }

        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            Debug::log(sprintf(
                '%s skipped: no active adapter (post_id=%d post_type=%s)',
                $reason,
                (int) $post->ID,
                sanitize_key((string) $post->post_type)
            ));
            return;
        }

        $postId = (int) $post->ID;
        $postType = sanitize_key((string) $post->post_type);
        $resolver = LanguageResolverFactory::create();
        $triggerLanguage = $resolver !== null ? $resolver->getTriggerLanguage($postId) : null;
        $neutralIdentity = (bool) ($extraContext['neutral_identity'] ?? false);
        if (array_key_exists('neutral_identity', $extraContext)) {
            unset($extraContext['neutral_identity']);
        }
        $triggerPostType = $neutralIdentity ? '' : $postType;

        $context = array_merge([
            'trigger_class' => sanitize_key($triggerClass),
            'trigger_kind' => sanitize_key($triggerClass),
            'trigger_event' => $reason,
            'reason' => $reason,
            'trigger_post_id' => $postId,
            'trigger_post_type' => $triggerPostType,
        ], $extraContext);

        $context = array_merge(
            $context,
            ResolverContext::buildContext($resolver, $triggerLanguage, [
                'trigger_post_id' => $postId,
                'trigger_post_type' => $triggerPostType,
            ])
        );

        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            $resolver,
            $triggerLanguage,
            $reason,
            $context
        );
    }

    /**
     * Determine whether a wp_block post represents a synced pattern.
     *
     * @param \WP_Post $post Post object.
     * @return bool
     */
    private static function isSyncedPattern(\WP_Post $post): bool
    {
        return CoreStructuralEntityClassifier::isSyncedPattern($post);
    }

    /**
     * Resolve permalinks for the provided post IDs.
     *
     * @param int[] $postIds Post IDs.
     * @return string[]
     */
    private static function resolvePermalinksForPostIds(array $postIds): array
    {
        $urls = [];
        $postIds = array_values(array_unique(array_filter(array_map('absint', $postIds), static fn(int $id): bool => $id > 0)));

        foreach ($postIds as $resolvedPostId) {
            $url = self::resolvePreciseEmissionUrlForPostId($resolvedPostId);
            if ($url !== '') {
                $urls[] = $url;
            }
        }

        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * Resolve trusted URLs from relationship-derived host post IDs.
     *
     * Trust scope is intentionally narrow: deterministic post ID resolution only.
     *
     * @param int[]                           $postIds Relationship-derived host post IDs.
     * @param LanguageResolverInterface|null  $resolver Active multilingual resolver.
     * @param string[]                        $fanoutLanguages Effective fanout languages.
     * @return string[]
     */
    private static function resolveTrustedRelationshipUrlsFromPostIds(
        array $postIds,
        ?LanguageResolverInterface $resolver,
        array $fanoutLanguages
    ): array {
        $postIds = TrustDecider::normalizePostIds($postIds);
        if ($postIds === []) {
            return [];
        }

        $urls = [];
        foreach ($postIds as $postId) {
            $permalink = get_permalink($postId);
            if (!is_string($permalink) || trim($permalink) === '') {
                continue;
            }

            $urls[] = trim($permalink);
        }

        $urls = TrustDecider::normalizeUrls($urls);
        if ($urls === [] || $resolver === null) {
            return $urls;
        }

        $fanoutLanguages = TrustDecider::normalizeStringList($fanoutLanguages);
        if ($fanoutLanguages === []) {
            return $urls;
        }

        return TrustDecider::normalizeUrls(FanoutPolicy::expandUrls($urls, $resolver, $fanoutLanguages));
    }

    /**
     * Purge a single page ID, handling multilingual mapping.
     *
     * @param int                            $pageId
     * @param CacheAdapterInterface          $adapter
     * @param LanguageResolverInterface|null  $resolver
     * @param string[]                       $fanoutLanguages
     * @return string[] Purged URLs for this page.
     */
    private static function purgePageId(
        int $pageId,
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        array $fanoutLanguages
    ): array {
        $urls = [];

        if ($resolver === null) {
            if ($adapter->purgePostId($pageId)) {
                $url = self::resolvePreciseEmissionUrlForPostId($pageId);
                if ($url !== '') {
                    $urls[] = $url;
                }
            }
            return $urls;
        }

        $pagePostType = get_post_type($pageId);
        if (!is_string($pagePostType) || $pagePostType === '') {
            return $urls;
        }

        $languages = array_values(array_unique(array_filter(array_map('strval', $fanoutLanguages))));

        if (empty($languages)) {
            if ($adapter->purgePostId($pageId)) {
                $url = self::resolvePreciseEmissionUrlForPostId($pageId);
                if ($url !== '') {
                    $urls[] = $url;
                }
            }
            return array_values(array_unique($urls));
        }

        $resolverModel = ResolverContext::getResolverModel($resolver);
        $purgeByPostId = [];

        foreach ($languages as $lang) {
            $translatedId = $resolver->mapObjectId($pageId, $pagePostType, $lang);

            if ($translatedId === null && $resolverModel === ResolverMetadataInterface::MODEL_URL) {
                $translatedId = $pageId;
            }

            if ($translatedId === null) {
                continue;
            }

            if (!array_key_exists($translatedId, $purgeByPostId)) {
                $purgeByPostId[$translatedId] = $adapter->purgePostId($translatedId);
            }

            if (!$purgeByPostId[$translatedId]) {
                continue;
            }

            $url = self::resolvePreciseEmissionUrlForPostId((int) $translatedId);
            if ($url === '') {
                continue;
            }

            $url = $resolver->mapPermalink($url, $lang);
            $urls[] = $url;
        }

        return array_values(array_unique($urls));
    }

    /**
     * Resolve a precise front-end emission URL for one purged post ID.
     *
     * @param int $postId Purged post ID.
     * @return string
     */
    private static function resolvePreciseEmissionUrlForPostId(int $postId): string
    {
        $post = get_post($postId);
        if (!$post instanceof \WP_Post) {
            return '';
        }

        return ContentVisibility::resolvePreciseUrlForPost($post);
    }
}
