<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Core;

use Ekesto\CacheInvalidator\Integrations\Caching\CacheAdapterInterface;
use Ekesto\CacheInvalidator\ContentVisibility;
use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Integrations\Multilingual\FanoutPolicy;
use Ekesto\CacheInvalidator\Integrations\Multilingual\LanguageResolverInterface;
use Ekesto\CacheInvalidator\Resolvers\ArchiveResolver;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Canonical execution boundary for global invalidation flows.
 */
final class GlobalInvalidationExecutor
{
    /**
     * Request-local marker: true after GLOBAL_INVALIDATION has executed.
     *
     * @var bool
     */
    private static bool $globalInvalidationRun = false;

    /**
     * Check whether GLOBAL_INVALIDATION has already executed in this request.
     *
     * @return bool
     */
    public static function hasGlobalInvalidationRun(): bool
    {
        return self::$globalInvalidationRun;
    }

    /**
     * Mark the current request as having executed GLOBAL_INVALIDATION.
     *
     * @return void
     */
    public static function markGlobalInvalidationRun(): void
    {
        self::$globalInvalidationRun = true;
    }

    /**
     * Reset request-local state. Intended for deterministic runtime tests only.
     *
     * @return void
     */
    public static function resetRequestStateForTests(): void
    {
        self::$globalInvalidationRun = false;
    }

    /**
     * Execute GLOBAL_INVALIDATION.
     *
     * @param CacheAdapterInterface          $adapter Active cache adapter.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string|null                    $triggerLanguage Trigger language code.
     * @param string                         $reason Invalidation reason.
     * @param array<string,mixed>            $context Trigger context payload.
     * @return array<string,mixed>
     */
    public static function executeGlobalInvalidation(
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage,
        string $reason,
        array $context = []
    ): array {
        $reason = sanitize_key($reason);
        if ($reason === '') {
            $reason = 'global_invalidation';
        }

        $intent = (string) ($context['intent'] ?? 'GLOBAL_INVALIDATION');
        $resolution = (string) ($context['resolution'] ?? 'GLOBAL_INVALIDATION');
        $fallback = (bool) ($context['fallback'] ?? false);
        $fallbackReason = sanitize_key((string) ($context['fallback_reason'] ?? ''));

        $baseContext = self::normalizeContext(
            $reason,
            $context,
            $intent,
            $resolution,
            $fallback,
            $fallbackReason
        );

        if (self::hasGlobalInvalidationRun()) {
            $skipOutcome = self::buildOutcomePayload(
                'skipped_terminal_global_already_run',
                $adapter,
                $baseContext,
                [],
                [],
                [],
                false
            );
            self::logOutcome($skipOutcome);
            return $skipOutcome;
        }

        $fullPurgeExecuted = $adapter->purgeAll();
        self::markGlobalInvalidationRun();

        $allIds = self::getAllPublishedPublicContentIds();
        $archiveUrls = self::resolveGlobalArchiveUrls();
        $emittedUrls = self::buildFullSiteEmissionUrls($allIds, $archiveUrls);

        $normalizedTriggerLanguage = is_string($triggerLanguage) ? trim($triggerLanguage) : '';
        $effectiveTriggerLanguage = $normalizedTriggerLanguage !== '' ? $normalizedTriggerLanguage : null;
        $fanoutContext = self::buildFanoutContext($baseContext, $resolver, $effectiveTriggerLanguage);
        $fanoutMode = FanoutPolicy::resolveMode($fanoutContext);
        $fanoutLanguages = FanoutPolicy::resolveTargetLanguages($resolver, $effectiveTriggerLanguage, $fanoutContext);
        $emittedUrls = FanoutPolicy::expandUrls($emittedUrls, $resolver, $fanoutLanguages);

        $emissionContext = self::buildEmissionContext(
            $adapter,
            $baseContext,
            $archiveUrls,
            [],
            [],
            $fanoutMode,
            $fanoutLanguages,
            $emittedUrls,
            $fullPurgeExecuted
        );

        if (!empty($emittedUrls)) {
            $trustedUrls = array_key_exists('trusted_urls', $emissionContext) && is_array($emissionContext['trusted_urls'])
                ? $emissionContext['trusted_urls']
                : [];
            $emissionPayload = EmissionBuilder::build($trustedUrls, $emittedUrls);
            if (array_key_exists('trusted_urls', $emissionContext)) {
                $emissionContext['trusted_urls'] = $emissionPayload['trusted_urls'];
            }
            EmissionHelper::emit($emissionPayload['urls'], $emissionContext);
        }

        $outcome = self::buildOutcomePayload(
            'global_invalidated',
            $adapter,
            $emissionContext,
            $emittedUrls,
            [],
            [],
            $fullPurgeExecuted
        );
        self::logOutcome($outcome);

        return $outcome;
    }

    /**
     * Execute GLOBAL_TARGETED.
     *
     * @param CacheAdapterInterface          $adapter Active cache adapter.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string|null                    $triggerLanguage Trigger language code.
     * @param array<int,mixed>               $postIds Target post IDs.
     * @param array<int,mixed>               $urls Target URLs.
     * @param string                         $reason Invalidation reason.
     * @param array<string,mixed>            $context Trigger context payload.
     * @return array<string,mixed>
     */
    public static function executeGlobalTargeted(
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage,
        array $postIds,
        array $urls,
        string $reason,
        array $context = []
    ): array {
        $reason = sanitize_key($reason);
        if ($reason === '') {
            $reason = 'global_targeted';
        }

        $intent = (string) ($context['intent'] ?? 'GLOBAL_TARGETED');
        $resolution = (string) ($context['resolution'] ?? 'GLOBAL_TARGETED');
        $fallback = (bool) ($context['fallback'] ?? false);
        $fallbackReason = sanitize_key((string) ($context['fallback_reason'] ?? ''));

        $baseContext = self::normalizeContext(
            $reason,
            $context,
            $intent,
            $resolution,
            $fallback,
            $fallbackReason
        );

        if (self::hasGlobalInvalidationRun()) {
            $skipOutcome = self::buildOutcomePayload(
                'skipped_terminal_global_already_run',
                $adapter,
                $baseContext,
                [],
                [],
                [],
                false
            );
            self::logOutcome($skipOutcome);
            return $skipOutcome;
        }

        $postIds = self::normalizePostIds($postIds);
        $urls = self::normalizeUrls($urls);
        $baseContext['post_ids'] = $postIds;
        $baseContext['urls'] = $urls;

        $normalizedTriggerLanguage = is_string($triggerLanguage) ? trim($triggerLanguage) : '';
        $effectiveTriggerLanguage = $normalizedTriggerLanguage !== '' ? $normalizedTriggerLanguage : null;

        $result = PurgeCoordinator::purgeTargets(
            $postIds,
            $urls,
            $adapter,
            $resolver,
            $effectiveTriggerLanguage,
            $baseContext
        );

        $emittedUrls = self::normalizeUrls((array) ($result['emitted_urls'] ?? []));
        $purgedUrlsDirectly = self::normalizeUrls((array) ($result['purged_urls_directly'] ?? []));
        $purgeSkippedUrls = self::normalizeUrls((array) ($result['purge_skipped_urls'] ?? []));
        $fanoutMode = (string) ($result['fanout_mode'] ?? FanoutPolicy::MODE_SAME_LANGUAGE);
        $fanoutLanguages = array_values(array_unique(array_filter(array_map(
            static fn($lang): string => trim((string) $lang),
            (array) ($result['fanout_languages'] ?? [])
        ))));

        $emissionContext = self::buildEmissionContext(
            $adapter,
            $baseContext,
            self::normalizeUrls((array) ($context['archives'] ?? [])),
            $purgedUrlsDirectly,
            $purgeSkippedUrls,
            $fanoutMode,
            $fanoutLanguages,
            $emittedUrls,
            false
        );

        if (!empty($emittedUrls)) {
            $trustedUrls = array_key_exists('trusted_urls', $emissionContext) && is_array($emissionContext['trusted_urls'])
                ? $emissionContext['trusted_urls']
                : [];
            $emissionPayload = EmissionBuilder::build($trustedUrls, $emittedUrls);
            if (array_key_exists('trusted_urls', $emissionContext)) {
                $emissionContext['trusted_urls'] = $emissionPayload['trusted_urls'];
            }
            EmissionHelper::emit($emissionPayload['urls'], $emissionContext);
        }

        $outcome = self::buildOutcomePayload(
            'global_targeted',
            $adapter,
            $emissionContext,
            $emittedUrls,
            $purgedUrlsDirectly,
            $purgeSkippedUrls,
            false
        );
        self::logOutcome($outcome);

        return $outcome;
    }

    /**
     * Execute GLOBAL_TARGETED from resolver output and escalate to GLOBAL_INVALIDATION when needed.
     *
     * @param CacheAdapterInterface          $adapter Active cache adapter.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string|null                    $triggerLanguage Trigger language code.
     * @param array<string,mixed>            $resolverResult Resolver output.
     * @param string                         $reason Invalidation reason.
     * @param array<string,mixed>            $context Trigger context payload.
     * @return array<string,mixed>
     */
    public static function executeGlobalTargetedOrEscalate(
        CacheAdapterInterface $adapter,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage,
        array $resolverResult,
        string $reason,
        array $context = []
    ): array {
        $reason = sanitize_key($reason);
        if ($reason === '') {
            $reason = 'global_targeted';
        }

        $baseContext = self::normalizeContext(
            $reason,
            $context,
            'GLOBAL_TARGETED',
            'GLOBAL_TARGETED',
            false,
            ''
        );

        if (self::hasGlobalInvalidationRun()) {
            $skipOutcome = self::buildOutcomePayload(
                'skipped_terminal_global_already_run',
                $adapter,
                $baseContext,
                [],
                [],
                [],
                false
            );
            self::logOutcome($skipOutcome);
            return $skipOutcome;
        }

        try {
            $normalizedResolverResult = self::normalizeResolverResult($resolverResult);
        } catch (\Throwable $throwable) {
            Debug::warn('global targeted resolver normalization failed', [
                'reason' => $reason,
                'exception' => $throwable->getMessage(),
            ]);

            $failureOutcome = self::buildOutcomePayload(
                'resolver_failure',
                $adapter,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_TARGETED',
                    true,
                    'resolver_failure'
                ),
                [],
                [],
                [],
                false
            );
            self::logOutcome($failureOutcome);

            return self::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                $reason,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_INVALIDATION',
                    true,
                    'resolver_failure'
                )
            );
        }

        if (!$normalizedResolverResult['certain']) {
            $uncertainOutcome = self::buildOutcomePayload(
                'resolver_uncertain',
                $adapter,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_TARGETED',
                    true,
                    'resolver_uncertain'
                ),
                [],
                [],
                [],
                false
            );
            self::logOutcome($uncertainOutcome);

            $escalated = self::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                $reason,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_INVALIDATION',
                    true,
                    'resolver_uncertain'
                )
            );
            $escalated['outcome'] = 'global_escalated';
            self::logOutcome($escalated);

            return $escalated;
        }

        if (empty($normalizedResolverResult['post_ids']) && empty($normalizedResolverResult['urls'])) {
            $emptyOutcome = self::buildOutcomePayload(
                'resolver_empty',
                $adapter,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_TARGETED',
                    true,
                    'resolver_empty'
                ),
                [],
                [],
                [],
                false
            );
            self::logOutcome($emptyOutcome);

            $postId = (int) ($context['trigger_post_id'] ?? 0);
            $targets = $normalizedResolverResult;

            Debug::log('eci_elementor_fallback_probe', [
                'post_id' => $postId,
                'trigger_event' => $context['trigger_event'] ?? '',
                'trigger_source' => $context['source'] ?? '',
                'post_status' => get_post_status($postId),
                'template_type' => get_post_meta($postId, '_elementor_template_type', true),
                'conditions_present' => !empty(get_post_meta($postId, '_elementor_conditions', true)),
                'resolved_post_ids' => $targets['post_ids'] ?? [],
                'resolved_urls' => $targets['urls'] ?? [],
            ]);

            $escalated = self::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                $reason,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_INVALIDATION',
                    true,
                    'resolver_empty'
                )
            );
            $escalated['outcome'] = 'global_escalated';
            self::logOutcome($escalated);

            return $escalated;
        }

        try {
            return self::executeGlobalTargeted(
                $adapter,
                $resolver,
                $triggerLanguage,
                $normalizedResolverResult['post_ids'],
                $normalizedResolverResult['urls'],
                $reason,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_TARGETED',
                    false,
                    ''
                )
            );
        } catch (\Throwable $throwable) {
            Debug::warn('global targeted execution failed, escalating', [
                'reason' => $reason,
                'exception' => $throwable->getMessage(),
            ]);

            $failureOutcome = self::buildOutcomePayload(
                'resolver_failure',
                $adapter,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_TARGETED',
                    true,
                    'resolver_failure'
                ),
                [],
                [],
                [],
                false
            );
            self::logOutcome($failureOutcome);

            $escalated = self::executeGlobalInvalidation(
                $adapter,
                $resolver,
                $triggerLanguage,
                $reason,
                self::normalizeContext(
                    $reason,
                    $context,
                    'GLOBAL_TARGETED',
                    'GLOBAL_INVALIDATION',
                    true,
                    'resolver_failure'
                )
            );
            $escalated['outcome'] = 'global_escalated';
            self::logOutcome($escalated);

            return $escalated;
        }
    }

    /**
     * Resolve all published publicly viewable content IDs.
     *
     * Includes built-in page/post objects and public custom post types.
     *
     * @return int[]
     */
    private static function getAllPublishedPublicContentIds(): array
    {
        $postTypes = self::getViewablePublicPostTypes();
        if (empty($postTypes)) {
            return [];
        }

        $posts = get_posts([
            'post_type' => $postTypes,
            'post_status' => ['publish'],
            'posts_per_page' => -1,
            'fields' => 'ids',
            'no_found_rows' => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        if (!is_array($posts)) {
            return [];
        }

        return self::normalizePostIds($posts);
    }

    /**
     * Resolve full-site archive URLs for global invalidation coverage.
     *
     * @return string[]
     */
    private static function resolveGlobalArchiveUrls(): array
    {
        $archiveUrls = ArchiveResolver::resolvePostTypeArchiveUrls(
            self::getViewablePublicPostTypes()
        );
        $archiveUrls[] = home_url('/');

        return self::normalizeUrls($archiveUrls);
    }

    /**
     * Resolve all publicly viewable public post types for global invalidation.
     *
     * @return string[]
     */
    private static function getViewablePublicPostTypes(): array
    {
        return ContentVisibility::getViewablePublicPostTypes();
    }

    /**
     * Build emission URL set for full-site invalidation.
     *
     * @param int[]    $postIds Post IDs.
     * @param string[] $archiveUrls Archive URLs.
     * @return string[]
     */
    private static function buildFullSiteEmissionUrls(array $postIds, array $archiveUrls): array
    {
        return self::normalizeUrls(array_merge(
            self::resolvePermalinksForPostIds($postIds),
            $archiveUrls
        ));
    }

    /**
     * Resolve permalinks for post IDs.
     *
     * @param int[] $postIds Post IDs.
     * @return string[]
     */
    private static function resolvePermalinksForPostIds(array $postIds): array
    {
        $urls = [];
        foreach (self::normalizePostIds($postIds) as $postId) {
            $url = (string) get_permalink($postId);
            if ($url !== '') {
                $urls[] = $url;
            }
        }

        return self::normalizeUrls($urls);
    }

    /**
     * Normalize post IDs.
     *
     * @param array<int,mixed> $postIds Raw post IDs.
     * @return int[]
     */
    private static function normalizePostIds(array $postIds): array
    {
        return array_values(array_unique(array_filter(array_map('absint', $postIds), static fn(int $id): bool => $id > 0)));
    }

    /**
     * Normalize URLs.
     *
     * @param array<int,mixed> $urls Raw URLs.
     * @return string[]
     */
    private static function normalizeUrls(array $urls): array
    {
        return array_values(array_unique(array_filter(array_map(
            static fn($url): string => trim((string) $url),
            $urls
        ))));
    }

    /**
     * Normalize resolver output.
     *
     * @param array<string,mixed> $result Raw resolver output.
     * @return array{post_ids:int[],urls:string[],certain:bool}
     */
    private static function normalizeResolverResult(array $result): array
    {
        $postIdsSource = [];
        if (isset($result['post_ids']) && is_array($result['post_ids'])) {
            $postIdsSource = $result['post_ids'];
        } elseif (isset($result['target_ids']) && is_array($result['target_ids'])) {
            $postIdsSource = $result['target_ids'];
        } elseif (isset($result['target_post_ids']) && is_array($result['target_post_ids'])) {
            $postIdsSource = $result['target_post_ids'];
        }

        $urlSource = [];
        if (isset($result['urls']) && is_array($result['urls'])) {
            $urlSource = $result['urls'];
        } elseif (isset($result['target_urls']) && is_array($result['target_urls'])) {
            $urlSource = $result['target_urls'];
        }

        $certain = array_key_exists('certain', $result)
            ? (bool) $result['certain']
            : (!empty($postIdsSource) || !empty($urlSource));

        return [
            'post_ids' => self::normalizePostIds($postIdsSource),
            'urls' => self::normalizeUrls($urlSource),
            'certain' => $certain,
        ];
    }

    /**
     * Build normalized trigger context with compatibility fields retained.
     *
     * @param string               $reason Invalidation reason.
     * @param array<string,mixed>  $context Raw context.
     * @param string               $intent Intent label.
     * @param string               $resolution Resolution label.
     * @param bool                 $fallback Fallback marker.
     * @param string               $fallbackReason Fallback reason.
     * @return array<string,mixed>
     */
    private static function normalizeContext(
        string $reason,
        array $context,
        string $intent,
        string $resolution,
        bool $fallback,
        string $fallbackReason
    ): array {
        $triggerClass = sanitize_key((string) ($context['trigger_class'] ?? 'presentation'));
        if ($triggerClass === '') {
            $triggerClass = 'presentation';
        }

        $triggerKind = sanitize_key((string) ($context['trigger_kind'] ?? $triggerClass));
        if ($triggerKind === '') {
            $triggerKind = $triggerClass;
        }

        $normalized = $context;
        $normalized['reason'] = sanitize_key($reason);
        $normalized['trigger_class'] = $triggerClass;
        $normalized['trigger_kind'] = $triggerKind;
        $normalized['trigger_event'] = sanitize_key((string) ($context['trigger_event'] ?? $reason));
        $normalized['trigger_post_id'] = absint($context['trigger_post_id'] ?? 0);
        $normalized['trigger_post_type'] = sanitize_key((string) ($context['trigger_post_type'] ?? ''));
        $normalized['intent'] = strtoupper(sanitize_key($intent));
        $normalized['resolution'] = strtoupper(sanitize_key($resolution));
        $normalized['fallback'] = $fallback;
        $normalized['fallback_reason'] = sanitize_key($fallbackReason);

        return $normalized;
    }

    /**
     * Build fanout context for multilingual policy resolution.
     *
     * @param array<string,mixed>           $context Trigger context.
     * @param LanguageResolverInterface|null $resolver Language resolver.
     * @param string|null                    $triggerLanguage Trigger language.
     * @return array<string,mixed>
     */
    private static function buildFanoutContext(
        array $context,
        ?LanguageResolverInterface $resolver,
        ?string $triggerLanguage
    ): array {
        $fanoutContext = $context;
        $global = InvalidatorRuntime::settingsRepository()->getGlobalInvalidation();
        $fanoutContext['resolver_available'] = $resolver !== null;
        $fanoutContext['trigger_language_available'] = $triggerLanguage !== null && $triggerLanguage !== '';
        $fanoutContext['multilingual_fanout_post_triggers_enabled'] = (bool) (
            $context['multilingual_fanout_post_triggers_enabled']
            ?? $global['multilingual_fanout_post_triggers_enabled']
            ?? false
        );
        $fanoutContext['multilingual_fanout_non_post_triggers_enabled'] = (bool) (
            $context['multilingual_fanout_non_post_triggers_enabled']
            ?? $global['multilingual_fanout_non_post_triggers_enabled']
            ?? true
        );

        if ($triggerLanguage !== null && $triggerLanguage !== '') {
            $fanoutContext['trigger_language'] = $triggerLanguage;
        }

        return $fanoutContext;
    }

    /**
     * Build emission context payload.
     *
     * @param CacheAdapterInterface $adapter Active adapter.
     * @param array<string,mixed>   $baseContext Base trigger context.
     * @param string[]              $archiveUrls Archive URLs.
     * @param string[]              $purgedUrlsDirectly Direct URL purge list.
     * @param string[]              $purgeSkippedUrls Skipped URL purge list.
     * @param string                $fanoutMode Fanout mode.
     * @param string[]              $fanoutLanguages Fanout languages.
     * @param string[]              $emissionUrls Final emitted URL list.
     * @param bool                  $fullPurgeExecuted Whether purgeAll() returned true.
     * @return array<string,mixed>
     */
    private static function buildEmissionContext(
        CacheAdapterInterface $adapter,
        array $baseContext,
        array $archiveUrls,
        array $purgedUrlsDirectly,
        array $purgeSkippedUrls,
        string $fanoutMode,
        array $fanoutLanguages,
        array $emissionUrls,
        bool $fullPurgeExecuted
    ): array {
        $context = $baseContext;
        $context['source'] = $adapter->getKey();
        $context['source_label'] = $adapter->getLabel();
        $context['archives'] = $archiveUrls;
        $context['purged_urls_directly'] = $purgedUrlsDirectly;
        $context['purge_skipped_urls'] = $purgeSkippedUrls;
        $context['fanout_mode'] = $fanoutMode;
        $context['fanout_languages'] = $fanoutLanguages;
        $context['full_purge'] = $fullPurgeExecuted;
        $emissionUrls = self::normalizeUrls($emissionUrls);

        // Explicit trust contract has priority, including trusted_urls=[].
        if (array_key_exists('trusted_urls', $context)) {
            return $context;
        }

        if (self::shouldMarkTrusted($context) && !empty($emissionUrls)) {
            $context['trusted_urls'] = $emissionUrls;
        }

        return $context;
    }

    /**
     * Determine whether one emission context is deterministic and should be trusted.
     *
     * @param array<string,mixed> $context Emission context payload.
     * @return bool
     */
    private static function shouldMarkTrusted(array $context): bool
    {
        if (!empty($context['fallback']) || !empty($context['full_purge'])) {
            return false;
        }

        $intent = strtoupper(sanitize_key((string) ($context['intent'] ?? '')));
        $resolution = strtoupper(sanitize_key((string) ($context['resolution'] ?? '')));
        if ($intent === 'GLOBAL_INVALIDATION' || $resolution === 'GLOBAL_INVALIDATION') {
            return false;
        }

        // Resolver must have produced concrete targets to confirm deterministic resolution.
        $postIds = isset($context['post_ids']) && is_array($context['post_ids'])
            ? self::normalizePostIds($context['post_ids'])
            : [];
        $targetUrls = isset($context['urls']) && is_array($context['urls'])
            ? self::normalizeUrls($context['urls'])
            : [];

        return !empty($postIds) || !empty($targetUrls);
    }

    /**
     * Build a standard outcome payload.
     *
     * @param string                $outcome Outcome key.
     * @param CacheAdapterInterface $adapter Active adapter.
     * @param array<string,mixed>   $context Context payload.
     * @param string[]              $emittedUrls Emitted URLs.
     * @param string[]              $purgedUrlsDirectly Direct URL purge list.
     * @param string[]              $purgeSkippedUrls Skipped URL purge list.
     * @param bool                  $fullPurgeExecuted Whether full purge was executed.
     * @return array<string,mixed>
     */
    private static function buildOutcomePayload(
        string $outcome,
        CacheAdapterInterface $adapter,
        array $context,
        array $emittedUrls,
        array $purgedUrlsDirectly,
        array $purgeSkippedUrls,
        bool $fullPurgeExecuted
    ): array {
        return [
            'outcome' => sanitize_key($outcome),
            'source' => $adapter->getKey(),
            'reason' => (string) ($context['reason'] ?? ''),
            'intent' => (string) ($context['intent'] ?? ''),
            'resolution' => (string) ($context['resolution'] ?? ''),
            'fallback' => (bool) ($context['fallback'] ?? false),
            'fallback_reason' => sanitize_key((string) ($context['fallback_reason'] ?? '')),
            'trigger_class' => (string) ($context['trigger_class'] ?? ''),
            'trigger_kind' => (string) ($context['trigger_kind'] ?? ''),
            'trigger_event' => (string) ($context['trigger_event'] ?? ''),
            'trigger_post_id' => (int) ($context['trigger_post_id'] ?? 0),
            'trigger_post_type' => (string) ($context['trigger_post_type'] ?? ''),
            'targets' => [
                'post_ids' => isset($context['post_ids']) && is_array($context['post_ids'])
                    ? self::normalizePostIds($context['post_ids'])
                    : [],
                'urls' => isset($context['urls']) && is_array($context['urls'])
                    ? self::normalizeUrls($context['urls'])
                    : [],
                'archives' => isset($context['archives']) && is_array($context['archives'])
                    ? self::normalizeUrls($context['archives'])
                    : [],
            ],
            'emitted_urls' => self::normalizeUrls($emittedUrls),
            'purged_urls_directly' => self::normalizeUrls($purgedUrlsDirectly),
            'purge_skipped_urls' => self::normalizeUrls($purgeSkippedUrls),
            'full_purge' => $fullPurgeExecuted,
            'context' => $context,
        ];
    }

    /**
     * Write standardized executor outcome logs.
     *
     * @param array<string,mixed> $outcome Outcome payload.
     * @return void
     */
    private static function logOutcome(array $outcome): void
    {
        $resolution = (string) ($outcome['resolution'] ?? '');
        $resolutionLabel = self::formatResolutionLabel(
            $resolution,
            !empty($outcome['fallback']),
            (string) ($outcome['fallback_reason'] ?? '')
        );
        $finalOutcome = self::deriveFinalOutcome($outcome, $resolution);

        Debug::log(sprintf(
            'global invalidation outcome: outcome=%s reason=%s intent=%s resolution=%s final_outcome=%s fallback=%s',
            (string) ($outcome['outcome'] ?? ''),
            (string) ($outcome['reason'] ?? ''),
            (string) ($outcome['intent'] ?? ''),
            $resolutionLabel,
            $finalOutcome,
            !empty($outcome['fallback']) ? 'yes' : 'no'
        ), [
            'fallback_reason' => (string) ($outcome['fallback_reason'] ?? ''),
            'trigger_class' => (string) ($outcome['trigger_class'] ?? ''),
            'trigger_event' => (string) ($outcome['trigger_event'] ?? ''),
            'trigger_post_id' => (int) ($outcome['trigger_post_id'] ?? 0),
            'trigger_post_type' => (string) ($outcome['trigger_post_type'] ?? ''),
            'targets' => $outcome['targets'] ?? [],
            'emitted_urls' => count((array) ($outcome['emitted_urls'] ?? [])),
            'purged_urls_directly' => count((array) ($outcome['purged_urls_directly'] ?? [])),
            'purge_skipped_urls' => count((array) ($outcome['purge_skipped_urls'] ?? [])),
        ]);
    }

    /**
     * Build a display label for resolution in debug logs.
     *
     * @param string $resolution Resolution key.
     * @param bool   $fallback Whether fallback escalation was used.
     * @param string $fallbackReason Fallback reason key.
     * @return string
     */
    private static function formatResolutionLabel(string $resolution, bool $fallback, string $fallbackReason): string
    {
        $normalized = trim($resolution);
        if ($normalized === '') {
            return '';
        }

        // Show fallback reason only when meaningful for log clarity; structured context remains source of truth.
        if ($fallback && $normalized === 'GLOBAL_INVALIDATION') {
            $normalizedReason = strtolower(trim($fallbackReason));
            $nonMeaningfulReasons = ['', 'none', 'default', 'unknown', 'n/a', 'null'];
            if (!in_array($normalizedReason, $nonMeaningfulReasons, true)) {
                return sprintf('%s (fallback: %s)', $normalized, $normalizedReason);
            }

            return 'GLOBAL_INVALIDATION (fallback)';
        }

        return $normalized;
    }

    /**
     * Derive a normalized final outcome label for debug logs.
     *
     * @param array<string,mixed> $outcome Outcome payload.
     * @param string              $resolution Resolution key.
     * @return string
     */
    private static function deriveFinalOutcome(array $outcome, string $resolution): string
    {
        $outcomeKey = sanitize_key((string) ($outcome['outcome'] ?? ''));

        if ($outcomeKey === 'global_invalidated' || $outcomeKey === 'global_escalated') {
            return 'GLOBAL_INVALIDATION';
        }

        if ($outcomeKey === 'global_targeted') {
            return 'GLOBAL_TARGETED';
        }

        return trim($resolution);
    }
}
