<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Listeners;

use Ekesto\CacheInvalidator\Integrations\Caching\AdapterManager;
use Ekesto\CacheInvalidator\Integrations\Caching\CacheAdapterInterface;
use Ekesto\CacheInvalidator\Core\GlobalInvalidationExecutor;
use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Core\TrustDecider;
use Ekesto\CacheInvalidator\LayoutTargetResolver;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverFactory;
use Ekesto\CacheInvalidator\Resolvers\PageTargetResolver;
use Ekesto\CacheInvalidator\Selectors\SelectorResolver;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Listens to non-post-content presentation changes and explicit option triggers.
 */
final class PresentationInvalidationListener
{
    /**
     * Whether option trigger registry was logged in this request.
     *
     * @var bool
     */
    private static bool $loggedOptionRegistry = false;

    /**
     * Whether global trigger registry was logged in this request.
     *
     * @var bool
     */
    private static bool $loggedGlobalRegistry = false;

    /**
     * Request-local dedupe map for direct global trigger execution.
     *
     * @var array<string,bool>
     */
    private static array $globalTriggerDedupe = [];

    /**
     * Request-local dedupe map for explicit option trigger execution.
     *
     * @var array<string,bool>
     */
    private static array $optionTriggerDedupe = [];

    /**
     * Request-local dedupe map for widget sidebar-set execution.
     *
     * @var array<string,bool>
     */
    private static array $widgetSidebarExecutionDedupe = [];

    /**
     * Whether direct global trigger hooks were already registered.
     *
     * @var bool
     */
    private static bool $globalTriggerHooksRegistered = false;

    /**
     * Registered direct global trigger hooks keyed by hook name.
     *
     * @var array<string,bool>
     */
    private static array $registeredGlobalTriggerHooks = [];

    /**
     * Whether explicit option trigger hooks were already registered.
     *
     * @var bool
     */
    private static bool $explicitOptionHooksRegistered = false;

    /**
     * Register presentation-related hooks.
     *
     * @return void
     */
    public static function registerHooks(): void
    {
        add_action('customize_save_after', [self::class, 'handleCustomizerSave']);
        add_action('wp_create_nav_menu', [self::class, 'handleMenuChange']);
        add_action('wp_update_nav_menu', [self::class, 'handleMenuChange']);
        add_action('wp_delete_nav_menu', [self::class, 'handleMenuChange']);
        add_action('switch_theme', [self::class, 'handleSwitchTheme'], 20, 3);
        add_action('update_option_sidebars_widgets', [self::class, 'handleWidgetsOptionUpdate'], 20, 3);
        add_action('updated_option', [self::class, 'handleWidgetContentOptionUpdate'], 20, 3);
        add_action('plugins_loaded', [self::class, 'registerGlobalTriggerHooks'], 999);
        add_action('plugins_loaded', [self::class, 'registerExplicitOptionHooks'], 999);
    }

    /**
     * Register explicit option trigger hooks from internal and extension maps.
     *
     * @return void
     */
    public static function registerExplicitOptionHooks(): void
    {
        if (self::$explicitOptionHooksRegistered) {
            return;
        }

        $internal = self::getInternalOptionTriggers();
        $external = apply_filters('ekesto_ci_option_triggers', []);
        if (!is_array($external)) {
            $external = [];
        }

        $map = self::normalizeOptionTriggerMap(array_merge($internal, $external));
        foreach ($map as $optionKey => $reason) {
            add_action(
                'update_option_' . $optionKey,
                static function ($oldValue, $newValue, string $updatedOption = '') use ($optionKey, $reason): void {
                    $resolvedOption = sanitize_key($updatedOption);
                    self::handleExplicitOptionTrigger(
                        $resolvedOption !== '' ? $resolvedOption : $optionKey,
                        $reason,
                        $oldValue,
                        $newValue
                    );
                },
                20,
                3
            );
        }

        self::$explicitOptionHooksRegistered = true;
        self::logOptionRegistryOnce($map);
    }

    /**
     * Register direct global trigger hooks from extension maps.
     *
     * @return void
     */
    public static function registerGlobalTriggerHooks(): void
    {
        if (self::$globalTriggerHooksRegistered) {
            return;
        }

        foreach (self::resolveGlobalTriggerMap() as $hookName => $reason) {
            if (isset(self::$registeredGlobalTriggerHooks[$hookName])) {
                continue;
            }

            add_action($hookName, static function () use ($reason, $hookName): void {
                self::handleExtensionGlobalTrigger($reason, $hookName);
            });
            self::$registeredGlobalTriggerHooks[$hookName] = true;
        }

        self::$globalTriggerHooksRegistered = true;
        self::logGlobalRegistryOnce(self::$registeredGlobalTriggerHooks);
    }

    /**
     * Handle one explicit option trigger using selector-driven execution only.
     *
     * @param string $optionKey Option key.
     * @param string $reason Reason key.
     * @param mixed  $oldValue Previous option value.
     * @param mixed  $newValue New option value.
     * @return void
     */
    private static function handleExplicitOptionTrigger(string $optionKey, string $reason, $oldValue, $newValue): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $optionKey = sanitize_key($optionKey);
        $reason = sanitize_key($reason);
        if ($optionKey === '' || $reason === '') {
            return;
        }

        if (!self::isOptionTriggerEnabled($reason)) {
            Debug::log('option_trigger_skipped_disabled', [
                'option_key' => $optionKey,
                'reason' => $reason,
            ]);
            return;
        }

        if ($reason === 'menu_location_change' && !self::hasNavMenuLocationChange($oldValue, $newValue)) {
            Debug::log('option_trigger_skipped_menu_locations_unchanged', [
                'option_key' => $optionKey,
                'reason' => $reason,
            ]);
            return;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log('option_trigger_skipped_terminal_global_already_run', [
                'option_key' => $optionKey,
                'reason' => $reason,
            ]);
            return;
        }

        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            Debug::log('option_trigger_skipped_no_adapter', [
                'option_key' => $optionKey,
                'reason' => $reason,
            ]);
            return;
        }

        $triggerEvent = $reason;
        $context = [
            'trigger_class' => 'option',
            'trigger_kind' => 'option',
            'trigger_option' => $optionKey,
            'trigger_event' => $triggerEvent,
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'reason' => $triggerEvent,
        ];
        $baseSelectors = self::getInternalOptionSelectors($optionKey);
        $selectors = apply_filters('ekesto_ci_option_selectors', $baseSelectors, $context);
        if (!is_array($selectors)) {
            $selectors = [];
        }

        $triggerLanguage = isset($context['language']) && is_scalar($context['language'])
            ? trim((string) $context['language'])
            : null;
        if ($triggerLanguage === '') {
            $triggerLanguage = null;
        }

        if (self::hasGlobalSelector($selectors)) {
            $dedupeKey = self::buildOptionTriggerDedupeKey(
                $optionKey,
                $reason,
                [
                    'intent' => SelectorResolver::INTENT_GLOBAL,
                    'selectors' => [
                        ['type' => SelectorResolver::TYPE_ALL],
                    ],
                ],
                $triggerLanguage
            );
            if (isset(self::$optionTriggerDedupe[$dedupeKey])) {
                Debug::log('option_trigger_dedupe_skipped', [
                    'option_key' => $optionKey,
                    'reason' => $reason,
                    'path' => 'global',
                ]);
                return;
            }
            self::$optionTriggerDedupe[$dedupeKey] = true;

            if (Debug::allowsLevel('normal')) {
                Debug::log('option_trigger_executed', [
                    'option_key' => $optionKey,
                    'reason' => $reason,
                    'path' => 'global',
                    'selector_count' => 1,
                ]);
            }

            $resolver = LanguageResolverFactory::create();
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                $resolver,
                null,
                $reason,
                $context
            );
            return;
        }

        $selectorResult = SelectorResolver::resolve(array_values($selectors));
        $intent = (string) ($selectorResult['intent'] ?? SelectorResolver::INTENT_TARGETED);
        $hasSelectors = !empty($selectorResult['has_selectors']);
        $selectorPostIds = is_array($selectorResult['post_ids'] ?? null) ? $selectorResult['post_ids'] : [];
        $selectorPostTypes = is_array($selectorResult['post_types'] ?? null) ? $selectorResult['post_types'] : [];
        $validSelectorCount = count($selectorPostIds) + count($selectorPostTypes);

        if (!$hasSelectors) {
            if (Debug::allowsLevel('normal')) {
                Debug::log('option_trigger_skipped', [
                    'option_key' => $optionKey,
                    'reason' => $reason,
                    'skip_reason' => 'no_selectors',
                    'selector_count' => 0,
                    'path' => 'skipped_empty',
                    'hint' => 'Define selectors via ekesto_ci_option_selectors',
                ]);
            }
            return;
        }

        $resolvedTargetIds = self::resolveSelectorTargetIds($selectorPostIds, $selectorPostTypes);

        if (Debug::allowsLevel('normal')) {
            Debug::log('option_trigger_resolved', [
                'option_key' => $optionKey,
                'reason' => $reason,
                'selector_count' => count($selectors),
                'has_selectors' => $hasSelectors,
                'intent' => $intent,
                'selector_post_ids' => count($selectorPostIds),
                'selector_post_types' => count($selectorPostTypes),
                'resolved_target_ids' => count($resolvedTargetIds),
            ]);
        }

        $dedupeKey = self::buildOptionTriggerDedupeKey(
            $optionKey,
            $reason,
            [
                'intent' => $intent,
                'selectors' => [
                    'post_ids' => $selectorPostIds,
                    'post_types' => $selectorPostTypes,
                ],
            ],
            $triggerLanguage
        );
        if (isset(self::$optionTriggerDedupe[$dedupeKey])) {
            Debug::log('option_trigger_dedupe_skipped', [
                'option_key' => $optionKey,
                'reason' => $reason,
                'path' => 'targeted',
            ]);
            return;
        }
        self::$optionTriggerDedupe[$dedupeKey] = true;

        if (empty($resolvedTargetIds)) {
            if (Debug::allowsLevel('normal')) {
                Debug::log('option_trigger_skipped', [
                    'option_key' => $optionKey,
                    'reason' => $reason,
                    'skip_reason' => 'no_targets',
                    'path' => 'skipped_empty',
                    'selector_count' => $validSelectorCount,
                    'hint' => 'Define selectors via ekesto_ci_option_selectors',
                ]);
            }
            return;
        }

        if (Debug::allowsLevel('normal')) {
            Debug::log('option_trigger_executed', [
                'option_key' => $optionKey,
                'reason' => $reason,
                'path' => 'targeted',
                'selector_count' => $validSelectorCount,
                'targets' => count($resolvedTargetIds),
            ]);
        }
        $resolver = LanguageResolverFactory::create();
        GlobalInvalidationExecutor::executeGlobalTargeted(
            $adapter,
            $resolver,
            null,
            $resolvedTargetIds,
            [],
            $reason,
            $context
        );
    }

    /**
     * Handle customizer saves.
     *
     * @param mixed $manager Customizer manager object.
     * @return void
     */
    public static function handleCustomizerSave($manager = null): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        if (!self::isTriggerEnabled('customizer_enabled')) {
            Debug::log('customizer_change skipped: trigger disabled');
            return;
        }

        Debug::log('customizer_change trigger received');
        self::invalidatePresentationTargets('customizer_change');
    }

    /**
     * Handle classic nav menu creation/update/deletion.
     *
     * @param int $menuId Menu ID.
     * @return void
     */
    public static function handleMenuChange(int $menuId = 0): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        if (!self::isTriggerEnabled('menus_enabled')) {
            Debug::log('menu_change skipped: trigger disabled');
            return;
        }

        Debug::log(sprintf('menu_change trigger received: menu_id=%d', $menuId));
        self::invalidatePresentationTargets('menu_change');
    }

    /**
     * Handle theme switches.
     *
     * @param string         $newName New theme name.
     * @param \WP_Theme|null $newTheme New theme object.
     * @param \WP_Theme|null $oldTheme Old theme object.
     * @return void
     */
    public static function handleSwitchTheme(string $newName = '', ?\WP_Theme $newTheme = null, ?\WP_Theme $oldTheme = null): void
    {
        if (file_exists(ABSPATH . '.maintenance') || (function_exists('wp_is_maintenance_mode') && wp_is_maintenance_mode())) {
            Debug::log('eci_maintenance_guard_skip', [
                'trigger' => 'switch_theme',
            ]);
            return;
        }

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

        if (!self::isTriggerEnabled('switch_theme_enabled')) {
            Debug::log('switch_theme skipped: trigger disabled');
            return;
        }

        Debug::log(sprintf(
            'switch_theme trigger received: new=%s old=%s',
            $newTheme instanceof \WP_Theme ? (string) $newTheme->get_stylesheet() : '',
            $oldTheme instanceof \WP_Theme ? (string) $oldTheme->get_stylesheet() : ''
        ));
        self::invalidatePresentationTargets('switch_theme');
    }

    /**
     * Handle extension-provided direct global invalidation triggers.
     *
     * @param string $reason Extension reason key.
     * @param string $hookName Triggering hook name.
     * @return void
     */
    public static function handleExtensionGlobalTrigger(string $reason, string $hookName = ''): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        $reason = sanitize_key($reason);
        $hook = $hookName !== '' ? sanitize_key($hookName) : sanitize_key((string) current_filter());
        if ($reason === '' || $hook === '') {
            return;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log('global_trigger_skipped_terminal_global_already_run', [
                'hook_name' => $hook,
                'reason' => $reason,
            ]);
            return;
        }

        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            Debug::log('global_trigger_skipped_no_adapter', [
                'hook_name' => $hook,
                'reason' => $reason,
            ]);
            return;
        }

        $dedupeKey = 'global:' . $hook . ':' . $reason;
        if (isset(self::$globalTriggerDedupe[$dedupeKey])) {
            Debug::log('global_trigger_dedupe_skipped', [
                'hook_name' => $hook,
                'reason' => $reason,
            ]);
            return;
        }
        self::$globalTriggerDedupe[$dedupeKey] = true;

        $context = self::buildPresentationContext($reason, [
            'trigger_kind' => 'presentation_global_trigger',
            'global_trigger' => true,
            'global_trigger_hook' => $hook,
        ]);
        $context['global_invalidation'] = true;

        Debug::log('global_trigger_execution_path', [
            'hook_name' => $hook,
            'reason' => $reason,
            'path' => 'global',
        ]);

        $resolver = LanguageResolverFactory::create();
        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            $resolver,
            null,
            $reason,
            $context
        );
    }

    /**
     * Handle widget layout updates.
     *
     * @param mixed  $oldValue Old sidebars_widgets value.
     * @param mixed  $value New sidebars_widgets value.
     * @param string $optionName Option key.
     * @return void
     */
    public static function handleWidgetsOptionUpdate($oldValue, $value, string $optionName): void
    {
        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            return;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                'widgets_change skipped_terminal_global_already_run: option=%s',
                $optionName
            ));
            return;
        }

        Debug::log(sprintf(
            'widgets_change hook hit: option=%s old_type=%s new_type=%s',
            $optionName,
            gettype($oldValue),
            gettype($value)
        ));

        if (!self::isTriggerEnabled('widgets_enabled')) {
            Debug::log('widgets_change skipped: trigger disabled');
            return;
        }

        $changedSidebars = self::resolveChangedWidgetSidebars($oldValue, $value);
        if (empty($changedSidebars)) {
            return;
        }

        self::handleChangedWidgetSidebars($changedSidebars, 'layout');
    }

    /**
     * Handle widget option content updates after persistence.
     *
     * @param string $optionName Updated option key.
     * @param mixed  $oldValue Previous widget option value.
     * @param mixed  $value New widget option value.
     * @return void
     */
    public static function handleWidgetContentOptionUpdate(string $optionName, $oldValue, $value): void
    {
        $rawOptionName = trim($optionName);
        $optionName = sanitize_key($optionName);
        if ($optionName === '' || $rawOptionName !== $optionName) {
            return;
        }

        if ($optionName === 'sidebars_widgets') {
            return;
        }

        if (!str_starts_with($optionName, 'widget_') || strlen($optionName) <= strlen('widget_')) {
            return;
        }

        Debug::log('widget_content_change detected', [
            'option' => $optionName,
            'old_type' => gettype($oldValue),
            'new_type' => gettype($value),
        ]);

        if (!InvalidatorRuntime::settingsRepository()->isInvalidationEnabled()) {
            Debug::log('widget_content_change skipped', [
                'option' => $optionName,
                'skip_reason' => 'invalidation_disabled',
            ]);
            return;
        }

        if (!self::isTriggerEnabled('widgets_enabled')) {
            Debug::log('widget_content_change skipped', [
                'option' => $optionName,
                'skip_reason' => 'trigger_disabled',
            ]);
            return;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log('widget_content_change skipped', [
                'option' => $optionName,
                'skip_reason' => 'terminal_global_already_run',
            ]);
            return;
        }

        $diff = self::detectWidgetOptionInstanceMutations($oldValue, $value);
        Debug::log('widget_content_change mutation_summary', [
            'option' => $optionName,
            'same_instance_mutations' => count($diff['mutated_instance_keys']),
            'creates_ignored' => $diff['create_count'],
            'deletes_ignored' => $diff['delete_count'],
        ]);

        if (empty($diff['mutated_instance_keys'])) {
            Debug::log('widget_content_change skipped', [
                'option' => $optionName,
                'skip_reason' => 'no_same_instance_mutations',
            ]);
            return;
        }

        $widgetIds = self::buildWidgetIdsFromOptionName($optionName, $diff['mutated_instance_keys']);
        $ownership = self::resolveWidgetSidebarOwnership($widgetIds);

        Debug::log('widget_content_change ownership_summary', [
            'option' => $optionName,
            'changed_widget_count' => count($widgetIds),
            'resolved_widget_count' => $ownership['resolved_count'],
            'unresolved_widget_count' => $ownership['unresolved_count'],
            'resolved_sidebars' => $ownership['sidebars'],
        ]);

        if (empty($ownership['sidebars'])) {
            Debug::log('widget_content_change skipped', [
                'option' => $optionName,
                'skip_reason' => 'no_resolved_sidebars',
            ]);
            return;
        }

        self::handleChangedWidgetSidebars($ownership['sidebars'], 'content');
    }

    /**
     * Resolve and execute invalidation for changed widget sidebars.
     *
     * @param string[] $changedSidebars Changed sidebar IDs.
     * @param string   $source Widget trigger source.
     * @return void
     */
    private static function handleChangedWidgetSidebars(array $changedSidebars, string $source): void
    {
        $changedSidebars = self::normalizeSidebarIds($changedSidebars);
        if (empty($changedSidebars)) {
            Debug::log('widgets_change skipped: changed_sidebars empty');
            return;
        }

        $dedupeKey = self::buildWidgetSidebarExecutionDedupeKey($changedSidebars);
        if (isset(self::$widgetSidebarExecutionDedupe[$dedupeKey])) {
            Debug::log('widgets_change dedupe skipped', [
                'source' => $source,
                'changed_sidebars' => $changedSidebars,
            ]);
            return;
        }
        self::$widgetSidebarExecutionDedupe[$dedupeKey] = true;

        $targets = self::resolveWidgetTargetsForChangedSidebars($changedSidebars);
        $targetPostIds = $targets['post_ids'];
        $widgetMappedCount = count($targetPostIds);
        $changedSidebarCount = count($changedSidebars);
        $selectorHasSelectors = (bool) ($targets['selector_has_selectors'] ?? false);
        $selectorIntent = (string) ($targets['selector_intent'] ?? SelectorResolver::INTENT_TARGETED);
        $selectorRequestsGlobal = $selectorHasSelectors && $selectorIntent === SelectorResolver::INTENT_GLOBAL;
        $widgetFallback = self::widgetGlobalFallbackEnabled();
        $layoutMappedCount = 0;

        if (!$selectorRequestsGlobal) {
            $layoutResult = LayoutTargetResolver::resolve($changedSidebars, [
                'trigger_class' => 'presentation',
                'trigger_event' => 'widgets_change',
            ]);
            $layoutMappedCount = count($layoutResult['post_ids']);
            if ($layoutMappedCount > 0) {
                $targetPostIds = array_merge($targetPostIds, $layoutResult['post_ids']);
            }
        } else {
            Debug::log('layout_target_resolver: skipped, selector intent is GLOBAL');
        }

        // If no sidebar mapping targets are returned and fallback is disabled, skip work.
        if (empty($targetPostIds) && !$widgetFallback && !$selectorRequestsGlobal) {
            Debug::log(sprintf(
                'widgets_change skipped: changed_sidebars=%d mapped_ids=0 fallback=off',
                $changedSidebarCount
            ));
            return;
        }

        Debug::log(sprintf(
            'widgets_change trigger: changed_sidebars=%d widget_mapped_ids=%d layout_template_ids=%d total_mapped_ids=%d has_selectors=%s selector_intent=%s fallback=%s',
            $changedSidebarCount,
            $widgetMappedCount,
            $layoutMappedCount,
            count($targetPostIds),
            $selectorHasSelectors ? 'yes' : 'no',
            $selectorIntent,
            $widgetFallback ? 'on' : 'off'
        ));

        self::invalidatePresentationTargets('widgets_change', [
            'extra_post_ids' => $targetPostIds,
            'widget_global_fallback' => $widgetFallback,
            'widget_selector_has_selectors' => $selectorHasSelectors,
            'widget_selector_intent' => $selectorIntent,
            'widget_selector_targeted_empty' => $selectorHasSelectors
                && $selectorIntent === SelectorResolver::INTENT_TARGETED
                && empty($targetPostIds),
            'widget_trigger_source' => $source,
            'changed_sidebars' => $changedSidebars,
        ]);
    }

    /**
     * Invalidate presentation targets and emit URL signal once.
     *
     * @param string               $reason Reason key.
     * @param array<string,mixed>  $extraContext Extra context payload.
     * @return void
     */
    private static function invalidatePresentationTargets(string $reason, array $extraContext = []): void
    {
        $adapter = AdapterManager::getActiveAdapter();
        if ($adapter === null) {
            Debug::log($reason . ' skipped: no active adapter');
            return;
        }

        if (GlobalInvalidationExecutor::hasGlobalInvalidationRun()) {
            Debug::log(sprintf(
                '%s skipped_terminal_global_already_run',
                $reason
            ));
            return;
        }

        if ($reason === 'widgets_change') {
            self::executeWidgetsInvalidation($adapter, $reason, $extraContext);
            return;
        }

        self::executePresentationGlobalInvalidation($adapter, $reason, $extraContext);
    }

    /**
     * Execute global invalidation for a presentation trigger.
     *
     * @param CacheAdapterInterface $adapter Active cache adapter.
     * @param string                $reason Trigger reason.
     * @param array<string,mixed>   $extraContext Extra context payload.
     * @return void
     */
    private static function executePresentationGlobalInvalidation(
        CacheAdapterInterface $adapter,
        string $reason,
        array $extraContext
    ): void {
        $context = self::buildPresentationContext($reason, $extraContext);
        $fullFlushReasons = ['menu_change', 'menu_location_change', 'switch_theme'];

        if (in_array($reason, $fullFlushReasons, true) || (($extraContext['extension_trigger'] ?? '') === 'full_flush')) {
            $context['full_flush'] = true;
        }

        $resolver = LanguageResolverFactory::create();
        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            $resolver,
            null,
            $reason,
            $context
        );
    }

    /**
     * Execute widgets invalidation with targeted-or-escalate behavior.
     *
     * @param CacheAdapterInterface $adapter Active cache adapter.
     * @param string                $reason Trigger reason.
     * @param array<string,mixed>   $extraContext Extra context payload.
     * @return void
     */
    private static function executeWidgetsInvalidation(
        CacheAdapterInterface $adapter,
        string $reason,
        array $extraContext
    ): void {
        $targetPostIds = is_array($extraContext['extra_post_ids'] ?? null)
            ? array_values(array_unique(array_filter(array_map('absint', $extraContext['extra_post_ids']), static fn(int $id): bool => $id > 0)))
            : [];
        $widgetFallback = (bool) ($extraContext['widget_global_fallback'] ?? false);
        $selectorHasSelectors = (bool) ($extraContext['widget_selector_has_selectors'] ?? false);
        $selectorIntent = (string) ($extraContext['widget_selector_intent'] ?? SelectorResolver::INTENT_TARGETED);
        $selectorTargetedEmpty = (bool) ($extraContext['widget_selector_targeted_empty'] ?? false);

        if ($selectorHasSelectors && $selectorIntent === SelectorResolver::INTENT_GLOBAL) {
            Debug::log('widgets_change selector escalation: reason=selector_all decision=global_only');
            $context = self::buildPresentationContext($reason, $extraContext);
            $context['full_flush'] = true;
            $context['fallback'] = true;
            $context['fallback_reason'] = 'selector_all';

            $resolver = LanguageResolverFactory::create();
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                $resolver,
                null,
                $reason,
                $context
            );
            return;
        }

        if ($selectorHasSelectors && $selectorTargetedEmpty) {
            if (!$widgetFallback) {
                Debug::log('widgets_change skipped: selector_targeted_empty fallback=off');
                return;
            }

            Debug::log('widgets_change selector escalation: reason=selector_targeted_empty decision=global_only');
            $context = self::buildPresentationContext($reason, $extraContext);
            $context['fallback'] = true;
            $context['fallback_reason'] = 'selector_targeted_empty';

            $resolver = LanguageResolverFactory::create();
            GlobalInvalidationExecutor::executeGlobalInvalidation(
                $adapter,
                $resolver,
                null,
                $reason,
                $context
            );
            return;
        }

        if (empty($targetPostIds) && !$widgetFallback) {
            Debug::log(sprintf(
                'widgets_change skipped: mapped_ids=0 fallback=off'
            ));
            return;
        }

        $resolver = LanguageResolverFactory::create();
        $context = self::buildPresentationContext($reason, $extraContext);

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

        GlobalInvalidationExecutor::executeGlobalTargetedOrEscalate(
            $adapter,
            $resolver,
            null,
            $resolverResult,
            $reason,
            $context
        );
    }

    /**
     * Build base presentation context with compatibility fields preserved.
     *
     * @param string              $reason Trigger reason.
     * @param array<string,mixed> $extraContext Extra context payload.
     * @return array<string,mixed>
     */
    private static function buildPresentationContext(string $reason, array $extraContext): array
    {
        $context = [
            'trigger_class' => 'presentation',
            'trigger_kind' => 'presentation',
            'trigger_event' => $reason,
            'reason' => $reason,
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
        ];

        foreach ($extraContext as $key => $value) {
            if (in_array($key, [
                'extra_post_ids',
                'widget_global_fallback',
                'widget_selector_has_selectors',
                'widget_selector_intent',
                'widget_selector_targeted_empty',
                'changed_sidebars',
            ], true)) {
                continue;
            }
            $context[(string) $key] = $value;
        }

        return $context;
    }

    /**
     * Resolve widget sidebar change targets via selector entries.
     *
     * @param mixed $oldValue Old sidebars widgets option.
     * @param mixed $value New sidebars widgets option.
     * @return string[] Changed sidebar IDs.
     */
    private static function resolveChangedWidgetSidebars($oldValue, $value): array
    {
        $old = is_array($oldValue) ? $oldValue : [];
        $new = is_array($value) ? $value : [];

        if ($old === $new) {
            Debug::log('widgets target resolver: old/new sidebars_widgets identical');
            return [];
        }

        $sidebarIds = array_values(array_unique(array_filter(array_map(
            static fn($k): string => (string) $k,
            array_merge(array_keys($old), array_keys($new))
        ))));

        $changedSidebars = [];
        foreach ($sidebarIds as $sidebarId) {
            if ($sidebarId === 'array_version') {
                continue;
            }

            $oldWidgets = $old[$sidebarId] ?? [];
            $newWidgets = $new[$sidebarId] ?? [];

            $oldWidgets = is_array($oldWidgets) ? array_values($oldWidgets) : [];
            $newWidgets = is_array($newWidgets) ? array_values($newWidgets) : [];

            if ($oldWidgets !== $newWidgets) {
                $changedSidebars[] = $sidebarId;
            }
        }

        if (empty($changedSidebars)) {
            Debug::log('widgets target resolver: no changed sidebars detected');
            return [];
        }

        return self::normalizeSidebarIds($changedSidebars);
    }

    /**
     * Resolve widget targets for already-detected changed sidebars.
     *
     * @param string[] $changedSidebars Changed sidebar IDs.
     * @return array{
     *     post_ids:int[],
     *     changed_sidebars:string[],
     *     selector_has_selectors:bool,
     *     selector_intent:string
     * }
     */
    private static function resolveWidgetTargetsForChangedSidebars(array $changedSidebars): array
    {
        $changedSidebars = self::normalizeSidebarIds($changedSidebars);
        if (empty($changedSidebars)) {
            Debug::log('widgets target resolver: changed_sidebars empty (mapping skipped)');
            return [
                'post_ids' => [],
                'changed_sidebars' => [],
                'selector_has_selectors' => false,
                'selector_intent' => SelectorResolver::INTENT_TARGETED,
            ];
        }

        /**
         * Filter widget invalidation selectors.
         *
         * @param array<int,array<string,mixed>> $selectors Selector entries.
         * @param string[]                        $changedSidebars Changed sidebar IDs.
         */
        $selectors = apply_filters(
            'ekesto_ci_widget_selectors',
            [],
            $changedSidebars
        );
        if (!is_array($selectors)) {
            $selectors = [];
        }

        $selectorResult = SelectorResolver::resolve(array_values($selectors));
        $hasSelectors = !empty($selectorResult['has_selectors']);
        $intent = (string) ($selectorResult['intent'] ?? SelectorResolver::INTENT_TARGETED);

        if (!$hasSelectors) {
            Debug::log(sprintf(
                'widgets selector resolve: changed_sidebars=%d has_selectors=no',
                count($changedSidebars)
            ));
            return [
                'post_ids' => [],
                'changed_sidebars' => $changedSidebars,
                'selector_has_selectors' => false,
                'selector_intent' => SelectorResolver::INTENT_TARGETED,
            ];
        }

        if ($intent === SelectorResolver::INTENT_GLOBAL) {
            Debug::log(sprintf(
                'widgets selector resolve: changed_sidebars=%d has_selectors=yes intent=GLOBAL',
                count($changedSidebars)
            ));
            return [
                'post_ids' => [],
                'changed_sidebars' => $changedSidebars,
                'selector_has_selectors' => true,
                'selector_intent' => SelectorResolver::INTENT_GLOBAL,
            ];
        }

        $selectorPostIds = is_array($selectorResult['post_ids'] ?? null) ? $selectorResult['post_ids'] : [];
        $selectorPostTypes = is_array($selectorResult['post_types'] ?? null) ? $selectorResult['post_types'] : [];
        $resolvedIds = array_values(array_unique(array_filter(array_map(
            'absint',
            array_merge($selectorPostIds, PageTargetResolver::getPublishedPostIdsByPostTypes($selectorPostTypes))
        ), static fn(int $id): bool => $id > 0)));

        Debug::log(sprintf(
            'widgets selector resolve: changed_sidebars=%d has_selectors=yes intent=TARGETED selector_post_ids=%d selector_post_types=%d resolved_ids=%d',
            count($changedSidebars),
            count($selectorPostIds),
            count($selectorPostTypes),
            count($resolvedIds)
        ));

        return [
            'post_ids' => $resolvedIds,
            'changed_sidebars' => $changedSidebars,
            'selector_has_selectors' => true,
            'selector_intent' => SelectorResolver::INTENT_TARGETED,
        ];
    }

    /**
     * Detect same-instance mutations in a widget option payload.
     *
     * @param mixed $oldValue Previous widget option value.
     * @param mixed $value New widget option value.
     * @return array{
     *     mutated_instance_keys:string[],
     *     create_count:int,
     *     delete_count:int
     * } Mutation summary.
     */
    private static function detectWidgetOptionInstanceMutations($oldValue, $value): array
    {
        $oldInstances = self::normalizeWidgetOptionInstances($oldValue);
        $newInstances = self::normalizeWidgetOptionInstances($value);

        $oldKeys = array_keys($oldInstances);
        $newKeys = array_keys($newInstances);
        $sharedKeys = array_values(array_intersect($oldKeys, $newKeys));

        $mutatedKeys = [];
        foreach ($sharedKeys as $instanceKey) {
            if ($oldInstances[$instanceKey] !== $newInstances[$instanceKey]) {
                $mutatedKeys[] = $instanceKey;
            }
        }

        sort($mutatedKeys, SORT_NATURAL);

        return [
            'mutated_instance_keys' => $mutatedKeys,
            'create_count' => count(array_diff($newKeys, $oldKeys)),
            'delete_count' => count(array_diff($oldKeys, $newKeys)),
        ];
    }

    /**
     * Normalize widget option payload to numeric instance entries.
     *
     * @param mixed $value Raw widget option value.
     * @return array<string,mixed> Normalized instance payloads keyed by numeric instance ID.
     */
    private static function normalizeWidgetOptionInstances($value): array
    {
        if (!is_array($value)) {
            return [];
        }

        $instances = [];
        foreach ($value as $key => $payload) {
            if ((string) $key === '_multiwidget') {
                continue;
            }

            $keyString = is_int($key) ? (string) $key : trim((string) $key);
            if ($keyString === '' || !ctype_digit($keyString)) {
                continue;
            }

            $instances[(string) absint($keyString)] = self::normalizeWidgetPayloadForComparison($payload);
        }

        ksort($instances, SORT_NATURAL);

        return $instances;
    }

    /**
     * Normalize widget instance payload values for deterministic comparison.
     *
     * @param mixed $value Raw payload value.
     * @return mixed Normalized payload value.
     */
    private static function normalizeWidgetPayloadForComparison($value)
    {
        if (!is_array($value)) {
            return $value;
        }

        $normalized = [];
        foreach ($value as $key => $item) {
            $normalized[$key] = self::normalizeWidgetPayloadForComparison($item);
        }

        if (array_keys($normalized) !== range(0, count($normalized) - 1)) {
            ksort($normalized);
        }

        return $normalized;
    }

    /**
     * Build concrete widget IDs from an option name and instance keys.
     *
     * @param string   $optionName Widget option name.
     * @param string[] $instanceKeys Numeric widget instance keys.
     * @return string[] Widget IDs as stored in sidebars_widgets.
     */
    private static function buildWidgetIdsFromOptionName(string $optionName, array $instanceKeys): array
    {
        $idBase = substr($optionName, strlen('widget_'));
        $idBase = sanitize_key((string) $idBase);
        if ($idBase === '') {
            return [];
        }

        $widgetIds = [];
        foreach ($instanceKeys as $instanceKey) {
            $instanceKey = trim((string) $instanceKey);
            if ($instanceKey === '' || !ctype_digit($instanceKey)) {
                continue;
            }
            $widgetIds[] = $idBase . '-' . absint($instanceKey);
        }

        return array_values(array_unique($widgetIds));
    }

    /**
     * Resolve current sidebar ownership for widget IDs in one sidebar scan.
     *
     * @param string[] $widgetIds Widget IDs to resolve.
     * @return array{
     *     sidebars:string[],
     *     resolved_count:int,
     *     unresolved_count:int
     * } Ownership summary.
     */
    private static function resolveWidgetSidebarOwnership(array $widgetIds): array
    {
        $widgetIds = array_values(array_unique(array_filter(array_map(
            static fn($widgetId): string => sanitize_key((string) $widgetId),
            $widgetIds
        ))));
        if (empty($widgetIds)) {
            return [
                'sidebars' => [],
                'resolved_count' => 0,
                'unresolved_count' => 0,
            ];
        }

        $pending = array_fill_keys($widgetIds, true);
        $resolvedSidebars = [];
        $sidebars = get_option('sidebars_widgets', []);
        $sidebars = is_array($sidebars) ? $sidebars : [];

        foreach ($sidebars as $sidebarId => $widgets) {
            if ($sidebarId === 'array_version' || !is_array($widgets)) {
                continue;
            }

            $sidebarId = sanitize_key((string) $sidebarId);
            if ($sidebarId === '') {
                continue;
            }

            foreach ($widgets as $widgetId) {
                $widgetId = sanitize_key((string) $widgetId);
                if ($widgetId === '' || !isset($pending[$widgetId])) {
                    continue;
                }

                $resolvedSidebars[] = $sidebarId;
                unset($pending[$widgetId]);
            }
        }

        $resolvedSidebars = self::normalizeSidebarIds($resolvedSidebars);

        if (!empty($pending)) {
            Debug::log('widget_content_change ownership_unresolved', [
                'widget_ids' => array_keys($pending),
            ]);
        }

        return [
            'sidebars' => $resolvedSidebars,
            'resolved_count' => count($widgetIds) - count($pending),
            'unresolved_count' => count($pending),
        ];
    }

    /**
     * Normalize sidebar IDs for deterministic comparisons and dedupe keys.
     *
     * @param string[] $sidebarIds Raw sidebar IDs.
     * @return string[] Normalized sidebar IDs.
     */
    private static function normalizeSidebarIds(array $sidebarIds): array
    {
        $normalized = array_values(array_unique(array_filter(array_map(
            static fn($sidebarId): string => sanitize_key((string) $sidebarId),
            $sidebarIds
        ))));
        sort($normalized, SORT_NATURAL);

        return $normalized;
    }

    /**
     * Build a request-local dedupe key for widget sidebar-set execution.
     *
     * @param string[] $changedSidebars Changed sidebar IDs.
     * @return string
     */
    private static function buildWidgetSidebarExecutionDedupeKey(array $changedSidebars): string
    {
        $changedSidebars = self::normalizeSidebarIds($changedSidebars);

        return 'widget_sidebar_execution:' . implode('|', $changedSidebars);
    }

    /**
     * Execute full adapter cache flush and emit broad URL set for warmup.
     *
     * @param CacheAdapterInterface $adapter Active adapter.
     * @param string                $reason Invalidation reason.
     * @param string[]              $matchedPostTypes Matched configured post types.
     * @param array<string,mixed>   $extraContext Extra context payload.
     * @return void
     */
    private static function emitFullFlushInvalidation(
        CacheAdapterInterface $adapter,
        string $reason,
        array $matchedPostTypes,
        array $extraContext
    ): void {
        $context = [
            'trigger_class' => 'presentation',
            'trigger_kind' => 'presentation_full_flush',
            'trigger_event' => $reason,
            'reason' => $reason,
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'groups' => array_values(array_unique($matchedPostTypes)),
            'full_flush' => true,
        ];

        foreach ($extraContext as $key => $value) {
            if (in_array($key, [
                'extra_post_ids',
                'widget_global_fallback',
                'widget_selector_has_selectors',
                'widget_selector_intent',
                'widget_selector_targeted_empty',
                'changed_sidebars',
            ], true)) {
                continue;
            }
            $context[$key] = $value;
        }

        $resolver = LanguageResolverFactory::create();
        GlobalInvalidationExecutor::executeGlobalInvalidation(
            $adapter,
            $resolver,
            null,
            $reason,
            $context
        );
    }

    /**
     * Resolve permalinks for a list of post IDs.
     *
     * @param int[] $postIds Post IDs.
     * @return string[] Absolute URLs.
     */
    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 $postId) {
            $url = (string) get_permalink($postId);
            if ($url !== '') {
                $urls[] = $url;
            }
        }

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

    /**
     * Detect whether nav menu locations changed in theme_mods.
     *
     * @param mixed $oldValue Previous theme_mods option value.
     * @param mixed $value New theme_mods option value.
     * @return bool
     */
    private static function hasNavMenuLocationChange($oldValue, $value): bool
    {
        $oldMods = is_array($oldValue) ? $oldValue : [];
        $newMods = is_array($value) ? $value : [];

        $oldLocations = $oldMods['nav_menu_locations'] ?? [];
        $newLocations = $newMods['nav_menu_locations'] ?? [];

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

        return $oldLocations !== $newLocations;
    }

    /**
     * Check if a presentation trigger is enabled.
     *
     * @param string $key Setting key.
     * @return bool
     */
    private static function isTriggerEnabled(string $key): bool
    {
        $global = InvalidatorRuntime::settingsRepository()->getGlobalInvalidation();
        $enabled = (bool) ($global[$key] ?? true);

        /**
         * Filter whether a presentation trigger is enabled at runtime.
         *
         * @param bool   $enabled Default enabled state.
         * @param string $key Trigger setting key.
         */
        return (bool) apply_filters('ekesto_ci_presentation_trigger_enabled', $enabled, $key);
    }

    /**
     * Check whether widgets should fall back to global invalidation.
     *
     * @return bool
     */
    private static function widgetGlobalFallbackEnabled(): bool
    {
        $global = InvalidatorRuntime::settingsRepository()->getGlobalInvalidation();
        $fallback = (bool) ($global['widget_global_fallback_enabled'] ?? true);

        /**
         * Filter widget global fallback behavior.
         *
         * @param bool $fallback Whether fallback is enabled.
         */
        return (bool) apply_filters('ekesto_ci_widget_global_fallback', $fallback);
    }

    /**
     * Resolve extension-provided direct global trigger map.
     *
     * @return array<string,string> [hook_name => reason]
     */
    private static function resolveGlobalTriggerMap(): array
    {
        $map = apply_filters('ekesto_ci_global_triggers', []);
        if (!is_array($map)) {
            $map = [];
        }

        return self::normalizeGlobalTriggerMap($map);
    }

    /**
     * Normalize direct global trigger map entries.
     *
     * @param array<string,mixed> $map Raw global trigger map.
     * @return array<string,string> [hook_name => reason_key]
     */
    private static function normalizeGlobalTriggerMap(array $map): array
    {
        $out = [];

        foreach ($map as $hookName => $reason) {
            $hook = sanitize_key((string) $hookName);
            $key = sanitize_key((string) $reason);
            if ($hook === '' || $key === '') {
                Debug::log('global_trigger_invalid_mapping', [
                    'original_hook' => is_scalar($hookName) ? (string) $hookName : gettype($hookName),
                    'original_reason' => is_scalar($reason) ? (string) $reason : gettype($reason),
                ]);
                continue;
            }
            $out[$hook] = $key;
        }

        return $out;
    }

    /**
     * Log option trigger registry once per request when debug is enabled.
     *
     * @param array<string,string> $map Option trigger map.
     * @return void
     */
    private static function logOptionRegistryOnce(array $map): void
    {
        if (!self::shouldLogRegistryForCurrentRequest() || self::$loggedOptionRegistry) {
            return;
        }

        static $loggedUris = [];
        $uri = self::getServerText('REQUEST_URI');
        if (isset($loggedUris[$uri])) {
            return;
        }
        $loggedUris[$uri] = true;

        self::$loggedOptionRegistry = true;

        Debug::verbose('option_triggers_registered', [
            'count' => count($map),
            'option_keys' => array_keys($map),
        ]);
    }

    /**
     * Log global trigger registry once per request when debug is enabled.
     *
     * @param array<string,bool> $map Global trigger hook map.
     * @return void
     */
    private static function logGlobalRegistryOnce(array $map): void
    {
        if (!self::shouldLogRegistryForCurrentRequest() || self::$loggedGlobalRegistry) {
            return;
        }

        static $loggedUris = [];
        $uri = self::getServerText('REQUEST_URI');
        if (isset($loggedUris[$uri])) {
            return;
        }
        $loggedUris[$uri] = true;

        self::$loggedGlobalRegistry = true;

        Debug::verbose('global_triggers_registered', [
            'count' => count($map),
            'hook_names' => array_keys($map),
        ]);
    }

    /**
     * Determine whether trigger registry logs should emit in this request.
     *
     * Registry logs are suppressed on internal REST and AJAX requests so
     * support/debug UI calls do not duplicate registry entries.
     *
     * @return bool
     */
    private static function shouldLogRegistryForCurrentRequest(): bool
    {
        if (!Debug::enabled()) {
            return false;
        }

        // Only log in admin UI requests
        if (!is_admin()) {
            return false;
        }

        // Skip cron
        if (defined('DOING_CRON') && DOING_CRON) {
            return false;
        }

        // Skip AJAX
        if (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
            return false;
        }

        // Skip REST
        $uri = self::getServerText('REQUEST_URI');
        if (str_starts_with($uri, '/wp-json/')) {
            return false;
        }

        // Skip obvious browser noise
        if (basename(self::getServerText('SCRIPT_NAME')) === 'favicon.ico') {
            return false;
        }

        return true;
    }

    /**
     * Read and sanitize one scalar server value for request classification.
     *
     * @param string $key Server array key.
     * @return string Sanitized server value, or empty string when unavailable.
     */
    private static function getServerText(string $key): string
    {
        if (!isset($_SERVER[$key]) || !is_scalar($_SERVER[$key])) {
            return '';
        }

        return sanitize_text_field(wp_unslash((string) $_SERVER[$key]));
    }

    /**
     * Return the internal ECI option trigger map.
     *
     * @return array<string,string> [option_key => reason_key]
     */
    private static function getInternalOptionTriggers(): array
    {
        $map = [
            'page_on_front' => 'reading_settings_change',
            'page_for_posts' => 'reading_settings_change',
            'show_on_front' => 'reading_settings_change',
            'permalink_structure' => 'permalink_settings_change',
            'category_base' => 'permalink_settings_change',
            'tag_base' => 'permalink_settings_change',
        ];

        $stylesheet = sanitize_key((string) get_option('stylesheet'));
        if ($stylesheet !== '') {
            $map['theme_mods_' . $stylesheet] = 'menu_location_change';
        }

        return $map;
    }

    /**
     * Normalize option trigger map values.
     *
     * @param array<string,mixed> $map Raw option trigger map.
     * @return array<string,string> [option_key => reason_key]
     */
    private static function normalizeOptionTriggerMap(array $map): array
    {
        $normalized = [];

        foreach ($map as $optionKey => $reason) {
            $normalizedOption = sanitize_key((string) $optionKey);
            $normalizedReason = sanitize_key((string) $reason);
            if ($normalizedOption === '' || $normalizedReason === '') {
                Debug::log('option_trigger_invalid_mapping', [
                    'original_key' => is_scalar($optionKey) ? (string) $optionKey : gettype($optionKey),
                    'original_reason' => is_scalar($reason) ? (string) $reason : gettype($reason),
                ]);
                continue;
            }

            $normalized[$normalizedOption] = $normalizedReason;
        }

        return $normalized;
    }

    /**
     * Resolve default selectors for built-in option triggers.
     *
     * @param string $optionKey Option key.
     * @return array<int,array<string,mixed>>
     */
    private static function getInternalOptionSelectors(string $optionKey): array
    {
        $internal = self::getInternalOptionTriggers();
        if (!array_key_exists($optionKey, $internal)) {
            return [];
        }

        return [
            ['type' => SelectorResolver::TYPE_ALL],
        ];
    }

    /**
     * Determine whether one explicit option-trigger reason is enabled by settings.
     *
     * @param string $reason Reason key.
     * @return bool
     */
    private static function isOptionTriggerEnabled(string $reason): bool
    {
        $reason = sanitize_key($reason);

        if ($reason === 'reading_settings_change') {
            return self::isTriggerEnabled('reading_settings_enabled');
        }

        if ($reason === 'permalink_settings_change') {
            return self::isTriggerEnabled('permalink_settings_enabled');
        }

        if ($reason === 'menu_location_change') {
            return self::isTriggerEnabled('menus_enabled');
        }

        return true;
    }

    /**
     * Resolve targeted post IDs from normalized selector output.
     *
     * @param int[]    $selectorPostIds Post IDs from selector normalization.
     * @param string[] $selectorPostTypes Post types from selector normalization.
     * @return int[]
     */
    private static function resolveSelectorTargetIds(array $selectorPostIds, array $selectorPostTypes): array
    {
        return array_values(array_unique(array_filter(array_map(
            'absint',
            array_merge(
                $selectorPostIds,
                PageTargetResolver::getPublishedPostIdsByPostTypes($selectorPostTypes)
            )
        ), static fn(int $id): bool => $id > 0)));
    }

    /**
     * Detect global selector intent from raw selector payload.
     *
     * @param array<int,mixed> $selectors Raw selector entries.
     * @return bool
     */
    private static function hasGlobalSelector(array $selectors): bool
    {
        foreach ($selectors as $selector) {
            if (!is_array($selector)) {
                continue;
            }

            $type = sanitize_key((string) ($selector['type'] ?? ''));
            if ($type === SelectorResolver::TYPE_ALL) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build a request-local dedupe key for explicit option trigger execution.
     *
     * @param string              $optionKey Option key.
     * @param string              $reason Reason key.
     * @param array<string,mixed> $selectorsSignature Normalized selector signature.
     * @param string|null         $triggerLanguage Trigger language code when available.
     * @return string
     */
    private static function buildOptionTriggerDedupeKey(
        string $optionKey,
        string $reason,
        array $selectorsSignature,
        ?string $triggerLanguage
    ): string {
        $signature = [
            'option' => $optionKey,
            'reason' => $reason,
            'selectors' => $selectorsSignature,
            'language' => $triggerLanguage,
        ];

        $encoded = wp_json_encode($signature);
        if (!is_string($encoded) || $encoded === '') {
            $encoded = '[]';
        }

        return md5($encoded);
    }

}
