<?php
/**
 * Forge Queue — Shared Job Queue for Forge Suite
 *
 * Lightweight background job processor. Shared across LangForge, Rank Forge,
 * Form Forge, and Field Forge. First plugin to load wins (class_exists guard).
 *
 * Features:
 * - DB-backed queue (wp_forge_queue table)
 * - WP Cron processing every minute
 * - Retry with exponential backoff (1m → 5m → 25m)
 * - Concurrency limiting (default 5)
 * - Stale job recovery (processing > 10 min → reset)
 * - AJAX status endpoint for progress polling
 * - Auto-cleanup of old jobs (7 days)
 *
 * @package ForgeQueue
 * @version 1.0.0
 */

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

// Register cron interval — always, even if class already loaded by another plugin
if (!has_filter('cron_schedules', 'forge_queue_cron_interval')) {
    function forge_queue_cron_interval($schedules) {
        if (!isset($schedules['forge_every_minute'])) {
            $schedules['forge_every_minute'] = [
                'interval' => 60,
                'display'  => 'Every Minute (Forge Queue)',
            ];
        }
        return $schedules;
    }
    add_filter('cron_schedules', 'forge_queue_cron_interval');
}

if (!class_exists('Forge_Queue')) {

// The original guard lived here; keeping the declaration wrapped makes
// it conditional at *runtime*, so PHP 8.3 / OpCache can't hoist the
// class past a sibling's prior include. See docs for context.

class Forge_Queue {

    const VERSION = '1.0.0';
    const DB_VERSION = '1.0.0';
    const TABLE = 'forge_queue';
    const CRON_HOOK = 'forge_queue_process';
    const CLEANUP_HOOK = 'forge_queue_cleanup';

    private static $instance = null;
    private static $handlers = [];
    private $concurrency = 5;
    private $table;

    public static function instance() {
        if (self::$instance === null) self::$instance = new self();
        return self::$instance;
    }

    private function __construct() {
        global $wpdb;
        $this->table = $wpdb->prefix . self::TABLE;

        $this->maybe_create_table();
        $this->schedule_cron();

        add_action(self::CRON_HOOK, [$this, 'process']);
        add_action(self::CLEANUP_HOOK, [$this, 'cleanup']);
        add_action('wp_ajax_forge_queue_status', [$this, 'ajax_status']);
        // Inline fallback for WP-Cron-less hosts (MAMP, Local WP, Docker) runs
        // inside ajax_status on status polls — see the process call there.
    }

    // ─── Registration ───────────────────────────────────────

    /**
 * Register a job handler.
 *
 * @param string $job_type Unique job type identifier.
 * @param callable $callback Handler function. Receives (array $payload). Return true or array on success, WP_Error on failure.
 */
    public static function register($job_type, $callback) {
        self::$handlers[$job_type] = $callback;
    }

    // ─── Dispatch ───────────────────────────────────────────

    /**
 * Add a job to the queue.
 *
 * @param string $job_type Job type (must be registered).
 * @param array $payload Job data (stored as JSON).
 * @param string $plugin Plugin slug (langforge, rankforge, formforge, fieldforge).
 * @param array $opts Optional: delay (seconds), priority (lower = first), max_attempts.
 * @return int|false Job ID or false on failure.
 */
    public static function dispatch($job_type, $payload, $plugin, $opts = []) {
        global $wpdb;
        $table = $wpdb->prefix . self::TABLE;

        $delay = intval($opts['delay'] ?? 0);
        $scheduled = $delay > 0
            ? gmdate('Y-m-d H:i:s', time() + $delay)
            : current_time('mysql', true);

        $inserted = $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
            'plugin'       => sanitize_key($plugin),
            'job_type'     => sanitize_key($job_type),
            'payload'      => wp_json_encode($payload),
            'status'       => 'pending',
            'priority'     => intval($opts['priority'] ?? 10),
            'attempts'     => 0,
            'max_attempts'  => intval($opts['max_attempts'] ?? 3),
            'scheduled_at' => $scheduled,
            'created_at'   => current_time('mysql', true),
        ]);

        if ($inserted) {
            // Nudge WP-Cron so jobs start even on local/MAMP environments
            // where no external HTTP hits trigger cron.
            spawn_cron();
        }

        return $inserted ? $wpdb->insert_id : false;
    }

    /**
 * Dispatch multiple jobs at once.
 *
 * @param array $jobs Array of [job_type, payload, plugin, opts].
 * @return int Number of jobs dispatched.
 */
    public static function dispatch_batch($jobs) {
        $count = 0;
        foreach ($jobs as $job) {
            $result = self::dispatch(
                $job['job_type'] ?? $job[0],
                $job['payload'] ?? $job[1] ?? [],
                $job['plugin'] ?? $job[2] ?? '',
                $job['opts'] ?? $job[3] ?? []
            );
            if ($result) $count++;
        }
        return $count;
    }

    // ─── Processor ──────────────────────────────────────────

    /**
 * Process pending jobs. Called by WP Cron every minute.
 * Runs in a loop until no more pending jobs or time limit reached.
 */
    public function process() {
        global $wpdb;

        $start_time = time();
        $max_runtime = 50; // seconds — stay under PHP max_execution_time (usually 60)

        // Reset stale jobs (processing for > 10 minutes). A job that hangs
        // mid-run (hard timeout / fatal) never reaches the exception path that
        // increments attempts, so increment it HERE — otherwise a job that
        // hangs every run would be requeued to 'pending' forever. Once it
        // reaches max_attempts we mark it 'failed' instead of requeueing.
        $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
            "UPDATE {$this->table}
             SET status       = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'pending' END,
                 result       = CASE WHEN attempts + 1 >= max_attempts
                                     THEN 'Failed: job stalled (timeout) and reached max_attempts'
                                     ELSE result END,
                 completed_at = CASE WHEN attempts + 1 >= max_attempts THEN %s ELSE completed_at END,
                 attempts     = attempts + 1
             WHERE status = 'processing' AND started_at < %s",
            current_time('mysql', true),
            gmdate('Y-m-d H:i:s', time() - 600)
        ));

        // Process in a loop until time runs out or queue is empty
        while ((time() - $start_time) < $max_runtime) {
            // Check concurrency
            $running = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
                "SELECT COUNT(*) FROM {$this->table} WHERE status = 'processing'"
            );
            $slots = $this->concurrency - $running;
            if ($slots <= 0) break;

            // Claim jobs
            $batch_token = wp_generate_uuid4();
            $affected = $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
                "UPDATE {$this->table}
                 SET status = 'processing', started_at = %s, result = %s
                 WHERE status = 'pending' AND scheduled_at <= %s
                 ORDER BY priority ASC, id ASC
                 LIMIT %d",
                current_time('mysql', true),
                $batch_token,
                current_time('mysql', true),
                $slots
            ));

            if (!$affected) break; // No more pending jobs

            // Fetch claimed jobs
            $jobs = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
                "SELECT * FROM {$this->table} WHERE status = 'processing' AND result = %s",
                $batch_token
            ));

            foreach ($jobs as $job) {
                $this->run_job($job);
            }
        }
    }

    /**
 * Execute a single job.
 */
    private function run_job($job) {
        global $wpdb;

        $handler = self::$handlers[$job->job_type] ?? null;
        if (!$handler) {
            $wpdb->update($this->table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                'status' => 'failed',
                'result' => 'No handler registered for: ' . $job->job_type,
                'completed_at' => current_time('mysql', true),
            ], ['id' => $job->id]);
            return;
        }

        $payload = json_decode($job->payload, true) ?: [];

        try {
            $result = call_user_func($handler, $payload);

            if (is_wp_error($result)) {
                throw new \Exception($result->get_error_message());
            }

            // Success
            $wpdb->update($this->table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                'status'       => 'completed',
                'result'       => is_array($result) ? wp_json_encode($result) : 'ok',
                'attempts'     => $job->attempts + 1,
                'completed_at' => current_time('mysql', true),
            ], ['id' => $job->id]);

        } catch (\Throwable $e) {
            $attempts = $job->attempts + 1;

            if ($attempts >= $job->max_attempts) {
                // Failed permanently
                $wpdb->update($this->table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                    'status'       => 'failed',
                    'result'       => $e->getMessage(),
                    'attempts'     => $attempts,
                    'completed_at' => current_time('mysql', true),
                ], ['id' => $job->id]);
            } else {
                // Retry with exponential backoff: 1m, 5m, 25m
                $delay = pow(5, $attempts - 1) * 60;
                $wpdb->update($this->table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
                    'status'       => 'pending',
                    'result'       => 'Retry ' . $attempts . ': ' . $e->getMessage(),
                    'attempts'     => $attempts,
                    'scheduled_at' => gmdate('Y-m-d H:i:s', time() + $delay),
                    'started_at'   => null,
                ], ['id' => $job->id]);
            }
        }
    }

    // ─── Status ─────────────────────────────────────────────

    /**
 * AJAX: Get queue status for a plugin/job_type.
 */
    public function ajax_status() {
        check_ajax_referer('forge_queue_nonce', 'nonce');
        if (!current_user_can('manage_options')) wp_send_json_error();

        global $wpdb;

        $plugin = sanitize_key($_GET['plugin'] ?? $_POST['plugin'] ?? '');
        $job_type = sanitize_key($_GET['job_type'] ?? $_POST['job_type'] ?? '');

        // Inline processing: if there are pending jobs, process them now.
        // This is the fallback for environments where WP-Cron loopback fails
        // (MAMP, Local WP, Docker without cron, etc.).
        $pending = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
            "SELECT COUNT(*) FROM {$this->table} WHERE status = 'pending'"
        );
        if ($pending > 0) {
            $this->process();
        }

        $where = '1=1';
        $binds = [];
        if ($plugin) { $where .= ' AND plugin = %s'; $binds[] = $plugin; }
        if ($job_type) { $where .= ' AND job_type = %s'; $binds[] = $job_type; }

        $query = "SELECT
            COUNT(*) as total,
            SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
            SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
            SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
            SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending
            FROM {$this->table} WHERE {$where}";

        if ($binds) {
            $row = $wpdb->get_row($wpdb->prepare($query, ...$binds)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        } else {
            $row = $wpdb->get_row($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        }

        $total = (int) ($row->total ?? 0);
        $completed = (int) ($row->completed ?? 0);
        $failed = (int) ($row->failed ?? 0);

        wp_send_json_success([
            'total'      => $total,
            'completed'  => $completed,
            'failed'     => $failed,
            'processing' => (int) ($row->processing ?? 0),
            'pending'    => (int) ($row->pending ?? 0),
            'percent'    => $total > 0 ? round(($completed + $failed) / $total * 100) : 0,
            'done'       => $total > 0 && ($completed + $failed) >= $total,
        ]);
    }

    // ─── Utilities ──────────────────────────────────────────

    /**
 * Get a single job by ID.
 */
    public static function get($id) {
        global $wpdb;
        return $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time/admin read
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- own table from $wpdb->prefix; one-time/admin read
            "SELECT * FROM " . $wpdb->prefix . self::TABLE . " WHERE id = %d", $id
        ));
    }

    /**
 * Cancel all pending jobs for a plugin/job_type.
 */
    public static function cancel($plugin, $job_type = '') {
        global $wpdb;
        $table = $wpdb->prefix . self::TABLE;
        $where = $wpdb->prepare("plugin = %s AND status = 'pending'", $plugin);
        if ($job_type) $where .= $wpdb->prepare(" AND job_type = %s", $job_type);
        return $wpdb->query("DELETE FROM {$table} WHERE {$where}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; $where built via $wpdb->prepare
    }

    /**
 * Set concurrency limit.
 */
    public function set_concurrency($n) {
        $this->concurrency = max(1, min(20, intval($n)));
    }

    /**
 * Cleanup old completed/failed jobs.
 */
    public function cleanup() {
        global $wpdb;
        $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table from $wpdb->prefix; one-time/admin read
            "DELETE FROM {$this->table} WHERE status IN ('completed', 'failed') AND completed_at < %s",
            gmdate('Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS)
        ));
    }

    // ─── Database ───────────────────────────────────────────

    private function maybe_create_table() {
        if (get_option('forge_queue_db_version') === self::DB_VERSION) return;

        global $wpdb;
        $charset = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS {$this->table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            plugin varchar(20) NOT NULL,
            job_type varchar(50) NOT NULL,
            payload longtext NOT NULL,
            status varchar(20) NOT NULL DEFAULT 'pending',
            priority int NOT NULL DEFAULT 10,
            attempts int NOT NULL DEFAULT 0,
            max_attempts int NOT NULL DEFAULT 3,
            result text,
            scheduled_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            started_at datetime DEFAULT NULL,
            completed_at datetime DEFAULT NULL,
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY idx_status_scheduled (status, scheduled_at),
            KEY idx_plugin_status (plugin, status)
        ) $charset;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
        update_option('forge_queue_db_version', self::DB_VERSION);
    }

    // ─── Cron Scheduling ────────────────────────────────────

    private function schedule_cron() {
        if (!wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_event(time(), 'forge_every_minute', self::CRON_HOOK);
        }
        if (!wp_next_scheduled(self::CLEANUP_HOOK)) {
            wp_schedule_event(time(), 'daily', self::CLEANUP_HOOK);
        }
    }

    public static function register_cron_interval($schedules) {
        if (!isset($schedules['forge_every_minute'])) {
            $schedules['forge_every_minute'] = [
                'interval' => 60,
                'display'  => 'Every Minute (Forge Queue)',
            ];
        }
        return $schedules;
    }
}

} // end: if (!class_exists('Forge_Queue'))
// Note: cron interval registered at top of file, before class definition
