<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Async;

use Ekesto\CacheInvalidator\InvalidatorRuntime;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * Option-based async invalidation queue with deduplication.
 *
 * Stores pending invalidation jobs in a single site option.
 * Jobs are deduplicated by a deterministic key to prevent scheduling storms.
 */
final class AsyncInvalidationQueue
{
    private const OPTION_KEY = 'ekesto_eci_async_queue';
    public const CRON_HOOK = 'ekesto_eci_process_queue';
    private const OVERFLOW_DEDUPE_KEY = 'queue_overflow:global';

    /** Maximum queue size before escalating to deterministic global fallback. */
    private const MAX_QUEUE_SIZE = 200;

    /**
     * Enqueue an async invalidation job.
     *
     * Job data keys:
     * - trigger_class (string): post|presentation|async
     * - trigger_kind (string): post|taxonomy_assign|taxonomy_term|template_archive|queue_overflow
     * - trigger_post_id (int), trigger_post_type (string) — for post triggers
     * - template_slug (string) — for template archive triggers
     * - taxonomy (string), term_id (int), object_id (int) — for taxonomy triggers
     * - reason (string): invalidation reason
     * - immediate_target_page_ids (int[]): immediate configured target page IDs
     * - immediate_target_urls (string[]): immediate URL targets (e.g. archives)
     * - immediate_trusted_urls (string[]): trusted immediate URL targets
     * - trigger_language (string|null)
     *
     * @param array<string,mixed> $job
     * @return void
     */
    public static function enqueue(array $job): void
    {
        Debug::log('async_enqueue_called', [
            'job' => $job,
            'scheduling_decision' => self::getSchedulingDecision(),
        ]);

        $schedulingDecision = self::getSchedulingDecision();
        if (empty($schedulingDecision['should_schedule'])) {
            return;
        }

        $dedupeKey = self::buildDedupeKey($job);
        $queue = self::getQueue();

        // Deduplicate: if key exists, merge immediate targets.
        if (isset($queue['items'][$dedupeKey])) {
            $existing = $queue['items'][$dedupeKey];
            $mergedIds = array_values(array_unique(array_merge(
                is_array($existing['immediate_target_page_ids'] ?? null) ? $existing['immediate_target_page_ids'] : [],
                is_array($job['immediate_target_page_ids'] ?? null) ? $job['immediate_target_page_ids'] : []
            )));
            $queue['items'][$dedupeKey]['immediate_target_page_ids'] = $mergedIds;

            $mergedUrls = array_values(array_unique(array_merge(
                is_array($existing['immediate_target_urls'] ?? null) ? $existing['immediate_target_urls'] : [],
                is_array($job['immediate_target_urls'] ?? null) ? $job['immediate_target_urls'] : []
            )));
            $queue['items'][$dedupeKey]['immediate_target_urls'] = $mergedUrls;

            $mergedTrustedUrls = array_values(array_unique(array_merge(
                is_array($existing['immediate_trusted_urls'] ?? null) ? $existing['immediate_trusted_urls'] : [],
                is_array($job['immediate_trusted_urls'] ?? null) ? $job['immediate_trusted_urls'] : []
            )));
            $queue['items'][$dedupeKey]['immediate_trusted_urls'] = $mergedTrustedUrls;
        } else {
            $job['created_at'] = time();
            $queue['items'][$dedupeKey] = $job;
        }

        if (count($queue['items']) > self::MAX_QUEUE_SIZE) {
            // Deterministic fallback: never silently drop invalidations.
            $queue = self::escalateOverflowToGlobalFallback($queue);
        }

        self::saveQueue($queue);
    }

    /**
     * Drain items from the queue for processing.
     *
     * @return array{items:array<int,array<string,mixed>>,keys:string[],created_map:array<string,int>}
     */
    public static function drain(): array
    {
        $queue = self::getQueue();
        $items = $queue['items'];
        $keys = array_keys($items);
        $createdMap = [];

        foreach ($items as $key => $item) {
            $createdMap[$key] = (int) ($item['created_at'] ?? 0);
        }

        return [
            'items' => array_values($items),
            'keys' => $keys,
            'created_map' => $createdMap,
        ];
    }

    /**
     * Remove processed keys from the queue after processing.
     *
     * @param string[]               $processedKeys
     * @param array<string,int>      $createdMap
     * @return void
     */
    public static function removeProcessed(array $processedKeys, array $createdMap = []): void
    {
        $queue = self::getQueue();

        foreach ($processedKeys as $key) {
            if (!isset($queue['items'][$key])) {
                continue;
            }

            $currentCreated = (int) ($queue['items'][$key]['created_at'] ?? 0);
            $processedCreated = (int) ($createdMap[$key] ?? 0);

            if ($currentCreated <= $processedCreated) {
                unset($queue['items'][$key]);
            }
        }

        $queue['scheduled'] = false;
        $queue['scheduled_at'] = null;
        $queue['version'] = (int) ($queue['version'] ?? 0) + 1;

        self::saveQueue($queue);
    }

    /**
     * Schedule the queue processor if not already scheduled.
     *
     * @param int $delay Delay in seconds.
     * @return void
     */
    public static function schedule(int $delay): void
    {
        /**
         * Async OFF prevents scheduling, not execution of already scheduled work.
         * The processor continues draining existing queue items.
         */
        $schedulingDecision = self::getSchedulingDecision();
        if (empty($schedulingDecision['should_schedule'])) {
            return;
        }

        $queue = self::getQueue();

        // If WP already has a scheduled event, sync internal state and exit.
        $next = wp_next_scheduled(self::CRON_HOOK);
        if ($next !== false) {
            $queue['scheduled'] = true;
            $queue['scheduled_at'] = (int) $next;
            self::saveQueue($queue);
            return;
        }

        $timestamp = time() + max(1, $delay);

        // Attempt scheduling with error reporting enabled.
        $result = wp_schedule_single_event($timestamp, self::CRON_HOOK, [], true);

        if (is_wp_error($result)) {
            Debug::log('async_schedule_failed', [
                'hook' => self::CRON_HOOK,
                'timestamp' => $timestamp,
                'error_code' => $result->get_error_code(),
                'error_message' => $result->get_error_message(),
            ]);

            // Do NOT mark as scheduled → allow retry on next request.
            return;
        }

        // Verify that the event actually exists after scheduling.
        $next = wp_next_scheduled(self::CRON_HOOK);
        if ($next === false) {
            Debug::log('async_schedule_missing_after_insert', [
                'hook' => self::CRON_HOOK,
                'timestamp' => $timestamp,
            ]);

            // Do NOT mark as scheduled → allow retry.
            return;
        }

        // Success: persist authoritative scheduled state.
        $queue['scheduled'] = true;
        $queue['scheduled_at'] = (int) $next;
        self::saveQueue($queue);

        Debug::log('async_schedule_success', [
            'hook' => self::CRON_HOOK,
            'scheduled_at' => (int) $next,
        ]);
    }

    /**
     * Check if the queue has pending items.
     *
     * @return bool
     */
    public static function hasPendingItems(): bool
    {
        $queue = self::getQueue();
        return !empty($queue['items']);
    }

    /**
     * Build a deterministic deduplication key for a job.
     *
     * @param array<string,mixed> $job
     * @return string
     */
    private static function buildDedupeKey(array $job): string
    {
        $triggerKind = sanitize_key((string) ($job['trigger_kind'] ?? 'post'));
        $reason = sanitize_key((string) ($job['reason'] ?? ''));

        if ($triggerKind === 'taxonomy_assign') {
            $taxonomy = sanitize_key((string) ($job['taxonomy'] ?? ''));
            $objectId = (int) ($job['object_id'] ?? 0);
            return sprintf('taxonomy_assign:%s|object:%d|%s', $taxonomy, $objectId, $reason);
        }

        if ($triggerKind === 'taxonomy_term') {
            $taxonomy = sanitize_key((string) ($job['taxonomy'] ?? ''));
            $termId = (int) ($job['term_id'] ?? 0);
            return sprintf('taxonomy_term:%s:%d|%s', $taxonomy, $termId, $reason);
        }

        if ($triggerKind === 'template_archive') {
            $triggerPostId = (int) ($job['trigger_post_id'] ?? 0);
            $templateSlug = sanitize_key((string) ($job['template_slug'] ?? ''));
            $urls = is_array($job['immediate_target_urls'] ?? null) ? $job['immediate_target_urls'] : [];
            $normalizedUrls = array_values(array_unique(array_filter(array_map(
                static fn($url): string => trim((string) $url),
                $urls
            ))));
            sort($normalizedUrls, SORT_STRING);
            $urlHash = md5(implode("\n", $normalizedUrls));
            return sprintf('template_archive:%d:%s:%s|%s', $triggerPostId, $templateSlug, $urlHash, $reason);
        }

        if ($triggerKind === 'queue_overflow') {
            return self::OVERFLOW_DEDUPE_KEY;
        }

        $triggerPostId = (int) ($job['trigger_post_id'] ?? 0);
        $triggerPostType = sanitize_key((string) ($job['trigger_post_type'] ?? ''));
        return sprintf('post:%s:%d|%s', $triggerPostType, $triggerPostId, $reason);
    }

    /**
     * Get the current queue from the option.
     *
     * @return array{items:array<string,array<string,mixed>>,scheduled:bool,scheduled_at:int|null,version:int}
     */
    private static function getQueue(): array
    {
        $queue = get_option(self::OPTION_KEY, []);
        if (!is_array($queue)) {
            $queue = [];
        }

        return [
            'items' => is_array($queue['items'] ?? null) ? $queue['items'] : [],
            'scheduled' => (bool) ($queue['scheduled'] ?? false),
            'scheduled_at' => isset($queue['scheduled_at']) ? (int) $queue['scheduled_at'] : null,
            'version' => (int) ($queue['version'] ?? 0),
        ];
    }

    /**
     * Save the queue to the option.
     *
     * @param array<string,mixed> $queue
     * @return void
     */
    private static function saveQueue(array $queue): void
    {
        update_option(self::OPTION_KEY, $queue, false);
    }

    /**
     * Single scheduling authority for enqueue/schedule entry points.
     *
     * @return array{should_schedule:bool,async_mode:string,execution_mode:string,reason:string,delay_seconds:int}
     */
    private static function getSchedulingDecision(): array
    {
        $repository = InvalidatorRuntime::settingsRepository();
        $decision = $repository->getAsyncSchedulingDecision();

        if (!$repository->isInvalidationEnabled()) {
            $decision['should_schedule'] = false;
            $decision['execution_mode'] = 'sync';
            $decision['reason'] = 'async_disabled';
        }

        return $decision;
    }

    /**
     * Replace queue contents with a deterministic overflow escalation job.
     *
     * @param array<string,mixed> $queue
     * @return array<string,mixed>
     */
    private static function escalateOverflowToGlobalFallback(array $queue): array
    {
        $pendingItems = count((array) ($queue['items'] ?? []));
        $existingOverflowJob = $queue['items'][self::OVERFLOW_DEDUPE_KEY] ?? [];
        $createdAt = (int) ($existingOverflowJob['created_at'] ?? time());

        $queue['items'] = [
            self::OVERFLOW_DEDUPE_KEY => [
                'trigger_kind' => 'queue_overflow',
                'reason' => 'queue_overflow',
                'created_at' => $createdAt,
                'pending_items' => $pendingItems,
            ],
        ];

        Debug::log('Async queue overflow escalation', [
            'pending_items' => $pendingItems,
            'fallback' => 'global_invalidation',
        ]);

        return $queue;
    }
}
