<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Core;

use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Canonical emission helper for ekesto_ci_invalidation_urls.
 */
final class EmissionHelper
{
    /** @var string Canonical emission action hook name. */
    private const EMISSION_HOOK = 'ekesto_ci_invalidation_urls';

    /** @var string Canonical targeted resolution/intent key. */
    private const TARGETED = 'TARGETED';

    /** @var string Canonical global resolution/intent key. */
    private const GLOBAL_INVALIDATION = 'GLOBAL_INVALIDATION';

    /**
     * Prepare finalized emission URLs with extension filter results.
     *
     * @param array<int,string>   $urls Base URLs from internal resolution.
     * @param array<string,mixed> $context Emission context payload.
     * @return array{
     *   base_urls:array<int,string>,
     *   additional_urls:array<int,string>,
     *   all_urls:array<int,string>,
     *   was_extended:bool
     * }
     */
    public static function prepareEmissionUrls(array $urls, array $context): array
    {
        $baseUrls = self::normalizeUrls($urls);

        /**
         * Filter additional URLs to include in the invalidation emission payload.
         *
         * Returned URLs are merged with core emitted URLs and normalized.
         * Signature: (array $urls, array $context): array
         *
         * @param array<int,string>   $baseUrls Base emitted URLs before extension.
         * @param array<string,mixed> $context Emission context payload.
         */
        $filtered = apply_filters('ekesto_ci_additional_urls', $baseUrls, $context);
        $additionalUrls = is_array($filtered) ? self::normalizeUrls($filtered) : [];
        $allUrls = self::normalizeUrls(array_merge($baseUrls, $additionalUrls));

        return [
            'base_urls' => $baseUrls,
            'additional_urls' => $additionalUrls,
            'all_urls' => $allUrls,
            'was_extended' => self::didUrlsExtend($baseUrls, $allUrls),
        ];
    }

    /**
     * Apply extension filters and emit invalidation URLs.
     *
     * @param array<int,string>   $urls URLs that were invalidated.
     * @param array<string,mixed> $context Emission context payload.
     * @return void
     */
    public static function emit(array $urls, array $context): void
    {
        $context = self::normalizeContext($context);
        $hasOriginalTrustedSource = array_key_exists('trusted_source', $context);
        $originalTrustedSource = $hasOriginalTrustedSource
            ? trim((string) $context['trusted_source'])
            : '';
        $mode = self::resolveEmissionMode($context);

        if ($mode === self::GLOBAL_INVALIDATION) {
            self::emitPrepared([], $context);
            return;
        }

        $prepared = self::prepareEmissionUrls($urls, $context);
        // Trust origin is only adapter-controlled emit() input context, not filter-modified state.
        if ($hasOriginalTrustedSource) {
            if ($originalTrustedSource === '') {
                unset($context['trusted_source']);
            } else {
                $context['trusted_source'] = $originalTrustedSource;
            }
        }

        self::emitPrepared($prepared['all_urls'], $context);
    }

    /**
     * Emit a precomputed URL list without re-running extension filters.
     *
     * @param array<int,string>   $urls Final URLs to emit.
     * @param array<string,mixed> $context Emission context payload.
     * @return void
     */
    public static function emitPrepared(array $urls, array $context): void
    {
        $context = self::normalizeContext($context);
        $mode = self::resolveEmissionMode($context);

        $finalUrls = $mode === self::GLOBAL_INVALIDATION ? [] : self::normalizeUrls($urls);
        if ($mode !== self::GLOBAL_INVALIDATION && $finalUrls === []) {
            Debug::log('eci_emission_skipped_empty_targeted', [
                'trigger_event' => (string) ($context['trigger_event'] ?? ''),
                'reason' => 'targeted_empty_urls',
            ]);
            return;
        }

        $canonicalContext = self::buildCanonicalContext($finalUrls, $context, $mode);

        self::dispatch($finalUrls, $canonicalContext);
    }

    /**
     * Dispatch the canonical emission payload to the shared action hook.
     *
     * @param array<int,string>   $urls Final URL payload.
     * @param array<string,mixed> $context Final context payload.
     * @return void
     */
    private static function dispatch(array $urls, array $context): void
    {
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant resolves to project-prefixed public bridge hook ekesto_ci_invalidation_urls.
        do_action(self::EMISSION_HOOK, $urls, $context);

        $correlationId = (string) ($context['correlation_id'] ?? '');
        static $seen = [];
        if (!isset($seen[$correlationId])) {
            Debug::log('emission_contract', [
                'resolution' => (string) ($context['resolution'] ?? ''),
                'intent' => (string) ($context['intent'] ?? ''),
                'url_count' => count($urls),
                'context_urls_present' => isset($context['urls']) && is_array($context['urls']),
                'correlation_id' => $correlationId,
            ]);
            $seen[$correlationId] = true;
        }
    }

    /**
     * Build final canonical context payload for emission.
     *
     * @param array<int,string>   $urls Final URL payload.
     * @param array<string,mixed> $context Raw context payload.
     * @param string              $mode Canonical emission mode.
     * @return array<string,mixed>
     */
    private static function buildCanonicalContext(array $urls, array $context, string $mode): array
    {
        $context['correlation_id'] = self::resolveCorrelationId($context);
        $context['intent'] = $mode;
        $context['resolution'] = $mode;
        $context['urls'] = $urls;
        if (!isset($context['trusted_source']) || trim((string) $context['trusted_source']) === '') {
            $context['trusted_source'] = self::EMISSION_HOOK;
        } else {
            $context['trusted_source'] = trim((string) $context['trusted_source']);
        }
        // trusted_source must never be overwritten after canonical context build.
        // Any downstream mutation invalidates trust guarantees.

        $isExternalSignal = self::isExternalCachePluginSignal($context);

        if ($mode === self::GLOBAL_INVALIDATION) {
            $context['urls'] = [];

            if ($isExternalSignal) {
                // Canonical full-clear contract: trusted + global + empty URLs.
                $context['trusted_urls'] = [];
            } elseif (array_key_exists('trusted_urls', $context) && !is_array($context['trusted_urls'])) {
                unset($context['trusted_urls']);
            }

            return $context;
        }

        if ($isExternalSignal) {
            // Canonical URL-signal contract: external URL signals are always untrusted.
            unset($context['trusted_urls']);
            return $context;
        }

        if (!array_key_exists('trusted_urls', $context)) {
            return $context;
        }

        if (!is_array($context['trusted_urls'])) {
            unset($context['trusted_urls']);
            return $context;
        }

        $trustedUrls = self::normalizeUrls($context['trusted_urls']);
        $urlLookup = array_fill_keys($urls, true);
        $trustedSubset = [];

        foreach ($trustedUrls as $trustedUrl) {
            if (isset($urlLookup[$trustedUrl])) {
                $trustedSubset[] = $trustedUrl;
            }
        }

        $context['trusted_urls'] = self::normalizeUrls($trustedSubset);

        return $context;
    }

    /**
     * Determine canonical emission mode from mixed legacy context values.
     *
     * @param array<string,mixed> $context
     * @return string
     */
    private static function resolveEmissionMode(array $context): string
    {
        $resolution = strtoupper(sanitize_key((string) ($context['resolution'] ?? '')));
        $intent = strtoupper(sanitize_key((string) ($context['intent'] ?? '')));

        if ($resolution === self::GLOBAL_INVALIDATION || $intent === self::GLOBAL_INVALIDATION) {
            return self::GLOBAL_INVALIDATION;
        }

        return self::TARGETED;
    }

    /**
     * Determine whether the context represents an external cache-plugin signal.
     *
     * @param array<string,mixed> $context
     * @return bool
     */
    private static function isExternalCachePluginSignal(array $context): bool
    {
        $triggerClass = sanitize_key((string) ($context['trigger_class'] ?? ''));
        if ($triggerClass !== 'system') {
            return false;
        }

        $event = sanitize_key((string) ($context['trigger_event'] ?? ''));
        $reason = sanitize_key((string) ($context['reason'] ?? ''));

        if (str_starts_with($event, 'cache_plugin_') || str_starts_with($reason, 'cache_plugin_')) {
            return true;
        }

        return isset($context['source']) && sanitize_key((string) $context['source']) !== '';
    }

    /**
     * Return the request-scoped correlation id, generating one on first call.
     *
     * Shared accessor so async enqueue sites can attach the same id that a
     * later sync emission in this request would use, enabling sync ↔ async
     * correlation across the ECI → queue → processor → ECW chain.
     */
    public static function getOrCreateRequestCorrelationId(): string
    {
        return self::resolveCorrelationId([]);
    }

    /**
     * Resolve or generate a request correlation id.
     *
     * @param array<string,mixed> $context
     * @return string
     */
    private static function resolveCorrelationId(array $context): string
    {
        $existing = isset($context['correlation_id']) ? trim((string) $context['correlation_id']) : '';
        if ($existing !== '') {
            return $existing;
        }

        static $requestCorrelationId = '';
        if ($requestCorrelationId !== '') {
            return $requestCorrelationId;
        }

        if (function_exists('wp_generate_uuid4')) {
            $requestCorrelationId = 'eci-' . wp_generate_uuid4();
            return $requestCorrelationId;
        }

        $requestCorrelationId = 'eci-' . md5(uniqid('eci', true));
        return $requestCorrelationId;
    }

    /**
     * Normalize context payload to array shape.
     *
     * @param array<string,mixed> $context
     * @return array<string,mixed>
     */
    private static function normalizeContext(array $context): array
    {
        return $context;
    }

    /**
     * Determine whether finalized emission URLs extend beyond base deterministic URLs.
     *
     * @param array<int,string> $baseUrls Internal deterministic URLs.
     * @param array<int,string> $allUrls Final URLs after extension filters.
     * @return bool
     */
    private static function didUrlsExtend(array $baseUrls, array $allUrls): bool
    {
        $baseSet = array_values(array_unique($baseUrls));
        $allSet = array_values(array_unique($allUrls));

        sort($baseSet, SORT_STRING);
        sort($allSet, SORT_STRING);

        return $baseSet !== $allSet;
    }

    /**
     * Normalize URL list (trim, canonical dedupe, preserve first-seen order).
     *
     * @param array<int,mixed> $urls Raw URL list.
     * @return array<int,string>
     */
    private static function normalizeUrls(array $urls): array
    {
        $normalized = [];
        $seen = [];

        foreach ($urls as $url) {
            $value = trim((string) $url);
            if ($value === '') {
                continue;
            }

            $dedupeKey = self::canonicalDedupeKey($value);
            if (isset($seen[$dedupeKey])) {
                continue;
            }

            $seen[$dedupeKey] = true;
            $normalized[] = $value;
        }

        return $normalized;
    }

    /**
     * Build a canonical dedupe key for one URL.
     *
     * @param string $url
     * @return string
     */
    private static function canonicalDedupeKey(string $url): string
    {
        $parts = wp_parse_url($url);
        if (!is_array($parts)) {
            return strtolower(rtrim($url, '/'));
        }

        $scheme = isset($parts['scheme']) ? strtolower((string) $parts['scheme']) . '://' : '';
        $host = isset($parts['host']) ? strtolower((string) $parts['host']) : '';
        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
        $path = isset($parts['path']) ? (string) $parts['path'] : '';

        if ($path === '') {
            $path = '/';
        } elseif ($path !== '/') {
            $path = rtrim($path, '/');
        }

        $query = isset($parts['query']) ? '?' . (string) $parts['query'] : '';

        return $scheme . $host . $port . $path . $query;
    }
}
