<?php

namespace Formatrica\Submission;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

use Formatrica\Submission\Integrations\Dispatchers\Brevo_Dispatcher;
use Formatrica\Submission\Integrations\Dispatchers\Generic_Webhook_Dispatcher;
use Formatrica\Submission\Integrations\Dispatchers\Slack_Dispatcher;

/**
 * Async Integration Queue
 *
 * Queues external webhook integrations (Zapier, Make, Slack) for background processing.
 * Uses WordPress Cron to process jobs asynchronously, improving form submission response time.
 *
 * Benefits:
 * - Fast form submissions (< 200ms instead of 5-15 seconds)
 * - Automatic retry on failure (up to 3 attempts)
 * - No external dependencies
 * - Error logging for debugging
 */
final class Integration_Queue
{
    private const HOOK = 'formatrica_process_integration_queue';
    private const OPTION_KEY = 'formatrica_integration_queue';
    private const OVERFLOW_KEY = 'formatrica_integration_queue_overflow';
    private const MAX_ATTEMPTS = 3;
    private const BATCH_SIZE = 10;

    // F-04 mitigation: the queue is a single serialized option, so it must be
    // bounded. When WP-Cron is disabled or a provider is unreachable a new job
    // over these caps is DROPPED (the submission itself already succeeded) and
    // an admin-visible overflow flag is raised, rather than letting the option
    // grow without limit.
    private const MAX_QUEUE_JOBS = 1000;
    private const MAX_QUEUE_BYTES = 5242880; // 5 MB serialized budget

    /**
     * Integrations that should be processed asynchronously
     */
    private const ASYNC_INTEGRATIONS = [
        'zapier',
        'make',
        'slack',
        'webhook', // Custom webhooks
        'brevo',   // Brevo marketing-list contact upsert
        'twilio',  // Twilio SMS (Pro; runtime-gated at dispatch)
    ];

    /**
     * Register hooks for queue processing
     *
     * Call this during plugin initialization to set up the cron job.
     *
     * @return void
     */
    public function register_hooks(): void
    {
        // Register custom cron schedule
        add_filter('cron_schedules', [$this, 'add_cron_interval']);

        // Register cron callback
        add_action(self::HOOK, [$this, 'process_batch']);

        // Schedule cron if not already scheduled
        if (!wp_next_scheduled(self::HOOK)) {
            wp_schedule_event(time(), 'every_minute', self::HOOK);
        }
    }

    /**
     * Add custom cron interval (every minute)
     *
     * @param array<string, array<string, mixed>> $schedules Existing schedules
     * @return array<string, array<string, mixed>> Modified schedules
     */
    public function add_cron_interval(array $schedules): array
    {
        if (!isset($schedules['every_minute'])) {
            $schedules['every_minute'] = [
                'interval' => 60,
                'display' => __('Every Minute', 'formatrica'),
            ];
        }
        return $schedules;
    }

    /**
     * Check if an integration should be queued (async) or run immediately (sync)
     *
     * @param string $integration_id Integration identifier
     * @return bool True if should be queued
     */
    public function should_queue(string $integration_id): bool
    {
        return in_array($integration_id, self::ASYNC_INTEGRATIONS, true);
    }

    /**
     * Add integration job to queue
     *
     * @param string $integration_id Integration type (zapier, make, slack, webhook)
     * @param array<string, mixed> $settings Integration settings
     * @param array<string, mixed> $form Form data
     * @param array<string, mixed> $payload Submission payload
     * @param array<string, mixed> $context Additional context
     * @param array<int, array<string, mixed>> $field_meta Field metadata
     * @return void
     */
    public function enqueue(
        string $integration_id,
        array $settings,
        array $form,
        array $payload,
        array $context,
        array $field_meta
    ): void {
        $queue = $this->get_queue();

        // Job-count cap: drop + flag rather than grow unbounded.
        $max_jobs = (int) apply_filters('formatrica_max_queue_jobs', self::MAX_QUEUE_JOBS);
        if ($max_jobs > 0 && count($queue) >= $max_jobs) {
            $this->flag_overflow('job_count', count($queue));
            return;
        }

        $job = [
            'id' => wp_generate_uuid4(),
            'integration_id' => $integration_id,
            'settings' => $settings,
            'form' => $form,
            'payload' => $payload,
            'context' => $context,
            'field_meta' => $field_meta,
            'timestamp' => time(),
            'attempts' => 0,
            'last_error' => null,
        ];

        // Serialized byte budget: guard against a few very large jobs blowing
        // past the option size limit even under the job-count cap.
        $candidate = $queue;
        $candidate[] = $job;
        $max_bytes = (int) apply_filters('formatrica_max_queue_bytes', self::MAX_QUEUE_BYTES);
        if ($max_bytes > 0 && strlen(maybe_serialize($candidate)) > $max_bytes) {
            $this->flag_overflow('byte_budget', strlen(maybe_serialize($queue)));
            return;
        }

        $queue[] = $job;
        $this->save_queue($queue);
    }

    /**
     * Raise an admin-visible overflow flag and log the drop. Cleared by
     * process_batch() once the backlog drains below the cap.
     */
    private function flag_overflow(string $reason, int $current_size): void
    {
        update_option(self::OVERFLOW_KEY, [
            'reason' => $reason,
            'size'   => $current_size,
            'at'     => time(),
        ], false);

        do_action(
            'formatrica_debug_log',
            sprintf(
                'Integration queue overflow (%s) at size %d — job dropped. Check that WP-Cron runs and integration endpoints are reachable.',
                $reason,
                $current_size
            )
        );
    }

    /**
     * Process a batch of queued jobs
     *
     * Called by WP-Cron every minute. Processes up to BATCH_SIZE jobs.
     *
     * @return void
     */
    public function process_batch(): void
    {
        $queue = $this->get_queue();

        if (empty($queue)) {
            // Drained: clear any stale overflow flag even with nothing to do.
            $this->maybe_clear_overflow([]);
            return;
        }

        $to_process = array_splice($queue, 0, self::BATCH_SIZE);
        $failed_jobs = [];

        foreach ($to_process as $job) {
            try {
                $this->dispatch_integration($job);

                // Success - job is not re-added to queue
            } catch (\Throwable $e) {
                // Failure - retry logic
                $job['attempts']++;
                $job['last_error'] = $e->getMessage();

                if ($job['attempts'] < self::MAX_ATTEMPTS) {
                    // Re-queue for retry
                    $failed_jobs[] = $job;
                } else {
                    // Max attempts reached - log and discard
                    do_action(
                        'formatrica_debug_log',
                        sprintf(
                            'Integration job failed after %d attempts. Integration: %s, Error: %s',
                            self::MAX_ATTEMPTS,
                            $job['integration_id'],
                            $e->getMessage()
                        )
                    );
                }
            }
        }

        // Merge failed jobs back into queue for retry
        $queue = array_merge($queue, $failed_jobs);
        $this->save_queue($queue);

        // Clear the overflow flag once the backlog is back under both caps.
        $this->maybe_clear_overflow($queue);
    }

    /**
     * Dispatch a single integration job
     *
     * @param array<string, mixed> $job Job data
     * @return void
     * @throws \Exception If dispatch fails
     */
    private function dispatch_integration(array $job): void
    {
        $integration_id = $job['integration_id'];
        $dispatcher = $this->get_dispatcher($integration_id);

        if (!$dispatcher) {
            throw new \Exception('Unknown integration dispatcher: ' . sanitize_text_field($integration_id)); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }

        $dispatcher->dispatch(
            $job['settings'],
            $job['form'],
            $job['payload'],
            $job['context'],
            $job['field_meta']
        );
    }

    /**
     * Get dispatcher instance for integration
     *
     * @param string $integration_id Integration identifier
     * @return object|null Dispatcher instance or null
     */
    private function get_dispatcher(string $integration_id): ?object
    {
        switch ($integration_id) {
            case 'zapier':
            case 'make':
            case 'webhook':
                return new Generic_Webhook_Dispatcher($integration_id);
            case 'slack':
                return new Slack_Dispatcher();
            case 'brevo':
                return new Brevo_Dispatcher();
            case 'twilio':
                // Pro integration, stripped from Free packages. Guard so a stale
                // queued job on a downgraded site fails its own job, not the request.
                $twilio = 'Formatrica\\Submission\\Integrations\\Dispatchers\\Twilio_Dispatcher';
                return class_exists($twilio) ? new $twilio() : null;
            default:
                return null;
        }
    }

    /**
     * Get current queue from database
     *
     * @return array<int, array<string, mixed>> Array of jobs
     */
    private function get_queue(): array
    {
        $queue = get_option(self::OPTION_KEY, []);
        return is_array($queue) ? $queue : [];
    }

    /**
     * Save queue to database
     *
     * @param array<int, array<string, mixed>> $queue Jobs to save
     * @return void
     */
    private function save_queue(array $queue): void
    {
        // Hard backstop for EVERY write path (enqueue AND the retry re-queue in
        // process_batch): never persist more than the job-count cap OR the
        // serialized byte budget. Excess (newest) jobs are dropped and the
        // overflow flag raised.
        $max_jobs = (int) apply_filters('formatrica_max_queue_jobs', self::MAX_QUEUE_JOBS);
        if ($max_jobs > 0 && count($queue) > $max_jobs) {
            $this->flag_overflow('job_count', count($queue));
            $queue = array_slice($queue, 0, $max_jobs);
        }

        $max_bytes = (int) apply_filters('formatrica_max_queue_bytes', self::MAX_QUEUE_BYTES);
        if ($max_bytes > 0 && strlen(maybe_serialize($queue)) > $max_bytes) {
            $this->flag_overflow('byte_budget', strlen(maybe_serialize($queue)));
            // Drop newest jobs until under budget; keep the oldest (processed
            // first). The count() guard prevents an empty-queue loop.
            while (count($queue) > 0 && strlen(maybe_serialize($queue)) > $max_bytes) {
                array_pop($queue);
            }
        }

        update_option(self::OPTION_KEY, $queue, false);
    }

    /**
     * Clear the overflow flag once the backlog is back under BOTH the job-count
     * and serialized-byte caps.
     *
     * @param array<int, array<string, mixed>> $queue
     */
    private function maybe_clear_overflow(array $queue): void
    {
        if (get_option(self::OVERFLOW_KEY, false) === false) {
            return;
        }

        $max_jobs  = (int) apply_filters('formatrica_max_queue_jobs', self::MAX_QUEUE_JOBS);
        $max_bytes = (int) apply_filters('formatrica_max_queue_bytes', self::MAX_QUEUE_BYTES);

        $under_count = $max_jobs <= 0 || count($queue) < $max_jobs;
        $under_bytes = $max_bytes <= 0 || strlen(maybe_serialize($queue)) < $max_bytes;

        if ($under_count && $under_bytes) {
            delete_option(self::OVERFLOW_KEY);
        }
    }

    /**
     * Get queue statistics for admin display
     *
     * @return array<string, mixed> Statistics
     */
    public function get_stats(): array
    {
        $queue = $this->get_queue();

        $stats = [
            'total_jobs' => count($queue),
            'pending' => 0,
            'retrying' => 0,
            'oldest_timestamp' => null,
        ];

        foreach ($queue as $job) {
            if ($job['attempts'] === 0) {
                $stats['pending']++;
            } else {
                $stats['retrying']++;
            }

            if ($stats['oldest_timestamp'] === null || $job['timestamp'] < $stats['oldest_timestamp']) {
                $stats['oldest_timestamp'] = $job['timestamp'];
            }
        }

        return $stats;
    }

    /**
     * Clear all queued jobs (for testing/debugging)
     *
     * @return void
     */
    public function clear_queue(): void
    {
        delete_option(self::OPTION_KEY);
        delete_option(self::OVERFLOW_KEY);
    }

    /**
     * Unschedule the cron job and optionally clear queued data
     *
     * Call this during plugin deactivation to clean up.
     *
     * @param bool $clear_data Whether to also clear queued jobs (default: true)
     * @return void
     */
    public static function unschedule(bool $clear_data = true): void
    {
        $timestamp = wp_next_scheduled(self::HOOK);
        if ($timestamp) {
            wp_unschedule_event($timestamp, self::HOOK);
        }

        if ($clear_data) {
            delete_option(self::OPTION_KEY);
            delete_option(self::OVERFLOW_KEY);
        }
    }
}
