<?php

namespace AC_SYNC\Includes\Classes\Async;

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

use AC_SYNC\Includes\Classes\Logger\Action_Wear_Log as Log;
use AC_SYNC\Includes\Classes\Api\Action_Wear_Api_Exception as ApiException;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Product_Import_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Images_Update_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Bulk_Price_Fetch_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Bulk_Price_Batch_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Bulk_Quantity_Fetch_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Bulk_Quantity_Batch_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Diff_Price_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Diff_Quantity_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Diff_Catalog_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Catalog_Generate_Handler;
use AC_SYNC\Includes\Classes\Async\Handlers\Action_Wear_Sku_Cleanup_Handler;

class Action_Wear_Job_Runner
{
    const RUNNER_LOCK_KEY    = '_actionwear_runner_lock';
    const RUNNER_LOCK_TTL    = 130; // 2min + 10s buffer
    const HEARTBEAT_KEY      = '_ACTIONWEAR_RUNNER_HEARTBEAT';
    const STALE_THRESHOLD    = 150; // seconds

    /** @var int */
    private $startTime;

    /** @var int */
    private $timeLimit;

    /** @var string */
    private $runnerId;

    /** @var bool Whether WC heavy-lifting optimizations are active */
    private $wcOptimized = false;

    /** @var array<string, string> Map of job_type => handler class */
    private static $handlerMap = [
        'product_import'       => Action_Wear_Product_Import_Handler::class,
        'images_update'        => Action_Wear_Images_Update_Handler::class,
        'bulk_price_fetch'     => Action_Wear_Bulk_Price_Fetch_Handler::class,
        'bulk_price_batch'     => Action_Wear_Bulk_Price_Batch_Handler::class,
        'bulk_quantity_fetch'  => Action_Wear_Bulk_Quantity_Fetch_Handler::class,
        'bulk_quantity_batch'  => Action_Wear_Bulk_Quantity_Batch_Handler::class,
        'diff_price'           => Action_Wear_Diff_Price_Handler::class,
        'diff_quantity'        => Action_Wear_Diff_Quantity_Handler::class,
        'diff_catalog'         => Action_Wear_Diff_Catalog_Handler::class,
        'catalog_generate_categories' => Action_Wear_Catalog_Generate_Handler::class,
        'catalog_generate_products'   => Action_Wear_Catalog_Generate_Handler::class,
        'catalog_generate_images'     => Action_Wear_Catalog_Generate_Handler::class,
        'sku_cleanup'                 => Action_Wear_Sku_Cleanup_Handler::class,
    ];

    public function __construct()
    {
        $this->runnerId = uniqid('runner_', true);
        $this->timeLimit = $this->calculateTimeLimit();
    }

    /**
     * Main entry point. Acquires lock, processes jobs in a loop, releases lock.
     */
    public function run(): void
    {
        @ini_set('memory_limit', '512M');
        @set_time_limit(300);

        if (!$this->acquireRunnerLock()) {
            return; // Another runner is active
        }

        // Recalculate after set_time_limit
        $this->timeLimit = $this->calculateTimeLimit();
        $this->startTime = time();
        $this->registerShutdownHandler();
        $this->updateHeartbeat();

        $jobsProcessed = 0;

        try {
            while (!$this->timeBudgetExceeded() && !$this->memoryExceeded()) {
                $job = $this->claimNextJob();
                if (!$job) {
                    break; // No more claimable jobs
                }

                // Enable WC optimizations when processing product imports
                if ($job->job_type === 'product_import' && !$this->wcOptimized) {
                    $this->enableWcOptimizations();
                }

                $this->processJob($job);
                $this->updateHeartbeat();
                $jobsProcessed++;

                // If we were optimized but next jobs won't be product_import, flush now
                if ($this->wcOptimized && $job->job_type === 'product_import') {
                    // Peek: is there more product_import pending? If not, disable now
                    if (!Action_Wear_Job_Queue::hasPending('product_import', (int) $job->id)) {
                        $this->disableWcOptimizations();
                    }
                }
            }
        } catch (\Throwable $e) {
            Log::write("JobRunner: Fatal error in run loop: " . $e->getMessage(), Log::ERROR, Log::CONTEXT_CRONJOB, __LINE__, __FILE__);
        } finally {
            $this->disableWcOptimizations();
            $this->releaseRunnerLock();
        }

        $elapsed = time() - $this->startTime;
        if ($jobsProcessed > 0) {
            Log::write("JobRunner: Completed {$jobsProcessed} jobs in {$elapsed}s (limit: {$this->timeLimit}s)", Log::INFO, Log::CONTEXT_CRONJOB);
            Action_Wear_Job_Queue::pruneCompleted(48);
            Log::pruneOldLogs(30);
        }

        // Self-retrigger: if there are still pending jobs, fire an async loopback
        // to start a new runner cycle immediately instead of waiting for the next cron tick.
        if (Action_Wear_Job_Queue::hasPendingJobs()) {
            $this->asyncRetrigger();
        }
    }

    /**
     * Enable WooCommerce optimizations for bulk product processing.
     * Disables expensive hooks and defers term counting.
     */
    private function enableWcOptimizations(): void
    {
        if ($this->wcOptimized) {
            return;
        }

        // Defer term counting — avoids COUNT(*) per term assignment
        wp_defer_term_counting(true);

        // Defer comment counting
        wp_defer_comment_counting(true);

        // Suspend WP object cache flushing
        wp_suspend_cache_addition(true);

        // Disable WC transient cleanup hooks during import
        add_filter('woocommerce_product_get_rating_counts', '__return_empty_array', 999);
        add_filter('woocommerce_product_get_review_count', '__return_zero', 999);

        // Prevent WC from re-counting product terms on each save
        add_filter('woocommerce_product_recount_terms', '__return_false', 999);

        $this->wcOptimized = true;
    }

    /**
     * Restore normal WooCommerce behavior and flush deferred counts.
     */
    private function disableWcOptimizations(): void
    {
        if (!$this->wcOptimized) {
            return;
        }

        // Flush deferred term counts
        wp_defer_term_counting(false);
        wp_defer_comment_counting(false);

        // Re-enable cache additions
        wp_suspend_cache_addition(false);

        // Remove our filters
        remove_filter('woocommerce_product_get_rating_counts', '__return_empty_array', 999);
        remove_filter('woocommerce_product_get_review_count', '__return_zero', 999);
        remove_filter('woocommerce_product_recount_terms', '__return_false', 999);

        // Flush WC product lookup tables once
        if (function_exists('wc_update_product_lookup_tables_column')) {
            wc_update_product_lookup_tables_is_running(false);
        }

        $this->wcOptimized = false;
    }

    /**
     * Fire a non-blocking HTTP request to the WP cron endpoint to immediately
     * start a new runner cycle. This avoids the 120s cron interval gap.
     */
    private function asyncRetrigger(): void
    {
        $url = add_query_arg('doing_wp_cron', wp_hash(time()), site_url('wp-cron.php'));

        wp_remote_post($url, [
            'timeout'   => 0.01,
            'blocking'  => false,
            'sslverify' => apply_filters('https_local_ssl_verify', false),
            'headers'   => [],
            'cookies'   => [],
        ]);
    }

    /**
     * Atomic lock acquisition using INSERT IGNORE + compare-and-swap UPDATE.
     *
     * @return bool
     */
    private function acquireRunnerLock(): bool
    {
        global $wpdb;

        $lockKey = self::RUNNER_LOCK_KEY;
        $now = time();
        $lockValue = $this->runnerId . ':' . $now;

        // Try INSERT IGNORE (succeeds only if no lock row exists)
        $wpdb->query($wpdb->prepare(
            "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
            $lockKey,
            $lockValue
        ));

        // Invalidate cache so get_option reads fresh from DB
        wp_cache_delete($lockKey, 'options');

        // Check if we got it
        $current = get_option($lockKey);
        if ($current === $lockValue) {
            return true;
        }

        // Lock exists — check if it's expired (self-healing)
        $parts = explode(':', $current);
        $lockTime = isset($parts[1]) ? (int) $parts[1] : 0;

        if (($now - $lockTime) > self::RUNNER_LOCK_TTL) {
            // Try CAS update on expired lock
            $updated = $wpdb->query($wpdb->prepare(
                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
                $lockValue,
                $lockKey,
                $current
            ));

            if ($updated > 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Release the runner lock.
     * Uses direct DELETE query to stay consistent with the INSERT IGNORE in acquireRunnerLock(),
     * and explicitly invalidates the WP object cache so get_option() won't return stale data.
     */
    private function releaseRunnerLock(): void
    {
        global $wpdb;
        $wpdb->delete($wpdb->options, ['option_name' => self::RUNNER_LOCK_KEY]);
        wp_cache_delete(self::RUNNER_LOCK_KEY, 'options');
        delete_option(self::HEARTBEAT_KEY);
    }

    /**
     * Register shutdown handler to detect fatal errors and release lock.
     */
    private function registerShutdownHandler(): void
    {
        $runnerId = $this->runnerId;
        $lockKey = self::RUNNER_LOCK_KEY;

        register_shutdown_function(function () use ($runnerId, $lockKey) {
            $error = error_get_last();
            if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
                global $wpdb;

                // Release lock
                $wpdb->delete($wpdb->options, ['option_name' => $lockKey]);
                delete_option(Action_Wear_Job_Runner::HEARTBEAT_KEY);

                // Reset any claimed jobs back to pending with 30s delay
                $wpdb->query($wpdb->prepare(
                    "UPDATE {$wpdb->prefix}actionwear_jobs
                     SET status = 'pending', claimed_at = NULL, claimed_by = NULL, available_at = DATE_ADD(NOW(), INTERVAL 30 SECOND)
                     WHERE claimed_by = %s AND status = 'claimed'",
                    $runnerId
                ));

                // Record crash
                update_option('_ACTIONWEAR_LAST_BG_CRASH', [
                    'identifier' => 'job_runner',
                    'error' => $error['message'],
                    'file' => $error['file'],
                    'line' => $error['line'],
                    'time' => time(),
                ]);

                Log::write("JobRunner: Fatal crash: " . $error['message'] . " in " . $error['file'] . ":" . $error['line'], Log::CRITICAL, Log::CONTEXT_CRONJOB);
            }
        });
    }

    /**
     * Claim the next available job, respecting conflict groups.
     *
     * @return object|null
     */
    private function claimNextJob(): ?object
    {
        global $wpdb;
        $table = $wpdb->prefix . 'actionwear_jobs';

        // L4: See latest committed state for conflict group checks.
        // Graceful: if SET fails (restricted hosting), UPDATE is still atomic per-row.
        $wpdb->query("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");
        $wpdb->last_error = ''; // Clear to prevent false-positive error attribution

        // Atomic claim with conflict group check
        // MySQL does not allow referencing the same table in a subquery for UPDATE,
        // so we use a derived table workaround
        $wpdb->query($wpdb->prepare(
            "UPDATE {$table}
             SET status = 'claimed', claimed_at = NOW(), claimed_by = %s, attempts = attempts + 1
             WHERE id = (
                SELECT id FROM (
                    SELECT j.id FROM {$table} j
                    WHERE j.status = 'pending'
                      AND j.available_at <= NOW()
                      AND (j.conflict_group IS NULL
                           OR NOT EXISTS (
                               SELECT 1 FROM {$table} c
                               WHERE c.conflict_group = j.conflict_group AND c.status = 'claimed'
                           ))
                    ORDER BY j.priority ASC, j.id ASC
                    LIMIT 1
                ) AS sub
             )",
            $this->runnerId
        ));

        if ($wpdb->rows_affected === 0) {
            return null;
        }

        // Fetch the job we just claimed
        $job = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table} WHERE claimed_by = %s AND status = 'claimed' ORDER BY claimed_at DESC LIMIT 1",
            $this->runnerId
        ));

        return $job ?: null;
    }

    /**
     * Process a single claimed job.
     *
     * @param object $job
     */
    private function processJob(object $job): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'actionwear_jobs';

        $handlerClass = self::$handlerMap[$job->job_type] ?? null;

        if (!$handlerClass || !class_exists($handlerClass)) {
            Log::write("JobRunner: Unknown job type '{$job->job_type}' for job #{$job->id}", Log::ERROR, Log::CONTEXT_CRONJOB);
            $wpdb->update($table, ['status' => 'failed'], ['id' => $job->id]);
            return;
        }

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

        try {
            @set_time_limit(300);

            /** @var Action_Wear_Job_Handler_Interface $handler */
            $handler = new $handlerClass();
            $result = $handler->handle($job, $payload);

            if ($result->isSuccess()) {
                $wpdb->update($table, ['status' => 'completed'], ['id' => $job->id]);

                // Enqueue follow-up jobs
                foreach ($result->getFollowUpJobs() as $followUp) {
                    Action_Wear_Job_Queue::push(
                        $followUp['type'],
                        $followUp['payload'] ?? null,
                        $followUp['priority'] ?? 50,
                        null,
                        $followUp['conflict_group'] ?? null
                    );
                }
            } elseif ($result->isRetry()) {
                $this->handleRetry($job, $result->getCooldownSeconds(), $result->getReason());
            } else {
                // Fail
                $wpdb->update($table, ['status' => 'failed'], ['id' => $job->id]);
                if ($result->getReason()) {
                    Log::write("JobRunner: Job #{$job->id} ({$job->job_type}) failed permanently: " . $result->getReason(), Log::ERROR, Log::CONTEXT_CRONJOB);
                }
            }
        } catch (ApiException $e) {
            // API unavailable — retry with 300s cooldown
            $this->handleRetry($job, 300, "API unavailable (code {$e->getStatusCode()})");
        } catch (\Throwable $e) {
            // Unexpected error — retry with exponential backoff or fail
            $backoff = 60 * (int) $job->attempts;
            $this->handleRetry($job, $backoff, $e->getMessage());
        }
    }

    /**
     * Handle retry logic: re-schedule or mark as failed if max attempts reached.
     *
     * @param object $job
     * @param int    $cooldownSeconds
     * @param string $reason
     */
    private function handleRetry(object $job, int $cooldownSeconds, string $reason): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'actionwear_jobs';

        if ((int) $job->attempts >= (int) $job->max_attempts) {
            $wpdb->update($table, ['status' => 'failed'], ['id' => $job->id]);
            Log::write("JobRunner: Job #{$job->id} ({$job->job_type}) failed after {$job->attempts} attempts: {$reason}", Log::ERROR, Log::CONTEXT_CRONJOB);
            return;
        }

        $wpdb->query($wpdb->prepare(
            "UPDATE {$table} SET status = 'pending', claimed_at = NULL, claimed_by = NULL, available_at = DATE_ADD(NOW(), INTERVAL %d SECOND) WHERE id = %d",
            $cooldownSeconds,
            $job->id
        ));

        Log::write("JobRunner: Job #{$job->id} ({$job->job_type}) retry in {$cooldownSeconds}s: {$reason}", Log::WARNING, Log::CONTEXT_CRONJOB);
    }

    /**
     * Update heartbeat timestamp.
     */
    private function updateHeartbeat(): void
    {
        update_option(self::HEARTBEAT_KEY, time());
    }

    /**
     * Check if time budget is exceeded.
     *
     * @return bool
     */
    private function timeBudgetExceeded(): bool
    {
        return (time() - $this->startTime) >= $this->timeLimit;
    }

    /**
     * Check if memory usage exceeds 90% threshold.
     *
     * @return bool
     */
    private function memoryExceeded(): bool
    {
        $limit = $this->getMemoryLimit();
        $usage = memory_get_usage(true);
        return $usage >= ($limit * 0.9);
    }

    /**
     * Calculate time limit for this runner invocation.
     *
     * @return int
     */
    private function calculateTimeLimit(): int
    {
        $override = (int) get_option('_ACTIONWEAR_BG_TIME_LIMIT', 0);
        if ($override > 0) {
            return $override;
        }

        $maxExec = (int) ini_get('max_execution_time');
        if ($maxExec > 0) {
            return max(10, $maxExec - 5);
        }

        return 110; // Default 110s
    }

    /**
     * Get memory limit in bytes.
     *
     * @return int
     */
    private function getMemoryLimit(): int
    {
        $limit = ini_get('memory_limit');

        if ($limit === '-1' || !$limit) {
            return 32 * 1024 * 1024 * 1024; // 32GB if unlimited
        }

        $value = (int) $limit;
        $unit = strtolower(substr(trim($limit), -1));

        switch ($unit) {
            case 'g':
                $value *= 1024;
                // fall through
            case 'm':
                $value *= 1024;
                // fall through
            case 'k':
                $value *= 1024;
        }

        return $value;
    }

    /**
     * Get the last heartbeat timestamp.
     *
     * @return int|null
     */
    public static function getLastHeartbeat(): ?int
    {
        $hb = get_option(self::HEARTBEAT_KEY, null);
        return $hb ? (int) $hb : null;
    }

    /**
     * Check if the runner appears to be stale.
     *
     * @return bool
     */
    public static function isStale(): bool
    {
        $lockValue = get_option(self::RUNNER_LOCK_KEY);
        if (!$lockValue) {
            return false; // No runner active
        }

        $heartbeat = self::getLastHeartbeat();
        if (!$heartbeat) {
            return true;
        }

        return (time() - $heartbeat) > self::STALE_THRESHOLD;
    }

    /**
     * Check if the runner lock is currently held.
     *
     * @return bool
     */
    public static function isLocked(): bool
    {
        return (bool) get_option(self::RUNNER_LOCK_KEY);
    }

    /**
     * Force-release the runner lock and reset claimed jobs.
     */
    public static function forceRelease(): void
    {
        global $wpdb;
        $wpdb->delete($wpdb->options, ['option_name' => self::RUNNER_LOCK_KEY]);
        wp_cache_delete(self::RUNNER_LOCK_KEY, 'options');
        delete_option(self::HEARTBEAT_KEY);
        Action_Wear_Job_Queue::resetClaimed();
    }
}
