<?php

namespace WPSN\Modules\Audit;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Core\Queue\QueueInstaller;
use WPSN\Core\Queue\QueuePause;

defined('ABSPATH') || exit;

/**
 * Scheduled Audit scans.
 *
 * Uses an hourly cron tick (wpsn_audit_tick) to decide whether it's time
 * to start a daily/weekly scan.
 *
 * Notes:
 * - Scheduling is best-effort (WP-Cron).
 * - Skips in Manual queue mode (to avoid piling jobs).
 * - Skips while Queue is paused.
 */
final class AuditAutomation
{
    public const CRON_TICK = 'wpsn_audit_tick';

    private const OPTION_DEFERRED = 'wpsn_audit_automation_deferred';

    private AuditJobs $jobs;
    private Settings $settings;
    private Logger $logger;
    private QueueInterface $queue;

    public function __construct(AuditJobs $jobs, Settings $settings, Logger $logger, QueueInterface $queue)
    {
        $this->jobs = $jobs;
        $this->settings = $settings;
        $this->logger = $logger;
        $this->queue = $queue;
    }

    public function register(): void
    {
        add_action('init', [$this, 'ensureCron'], 20);
        add_action(self::CRON_TICK, [$this, 'onScheduleTick']);
    }

    public function ensureCron(): void
    {
        $mode = (string) $this->settings->get('audit.automation.schedule', 'off');
        if (!in_array($mode, ['daily', 'weekly'], true)) {
            if (function_exists('wp_clear_scheduled_hook')) {
                wp_clear_scheduled_hook(self::CRON_TICK);
            }
            return;
        }

        if (!wp_next_scheduled(self::CRON_TICK)) {
            wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', self::CRON_TICK);
        }
    }

    public function onScheduleTick(): void
    {
        $mode = (string) $this->settings->get('audit.automation.schedule', 'off');
        if (!in_array($mode, ['daily', 'weekly'], true)) {
            return;
        }

        // Do not schedule scans when queue is in manual mode.
        $queueMode = (string) $this->settings->get('performance.queue.mode', 'auto');
        if ($queueMode === 'manual') {
            return;
        }

        // Operational pause switch (incidents / limited hosting).
        $queuePaused = QueuePause::isPaused();

        $hour = (int) $this->settings->get('audit.automation.hour_utc', 4);
        $hour = max(0, min(23, $hour));

        $due = false;
        $lockKey = '';
        $lockVal = '';
        $isWeekday = true;

        if ($mode === 'daily') {
            $today = (string) gmdate('Y-m-d');
            $lockKey = 'wpsn_audit_last_daily';
            $lockVal = $today;
            $last = (string) get_option($lockKey, '');
            $due = ($last !== $today);
        } elseif ($mode === 'weekly') {
            $weekday = (int) $this->settings->get('audit.automation.weekday_utc', 1);
            $weekday = max(0, min(6, $weekday));
            $nowW = (int) gmdate('w');
            $isWeekday = ($nowW === $weekday);

            $weekKey = (string) (gmdate('o') . '-W' . gmdate('W'));
            $lockKey = 'wpsn_audit_last_weekly';
            $lockVal = $weekKey;
            $last = (string) get_option($lockKey, '');
            $due = ($last !== $weekKey);
        }

        if (!$due || $lockKey === '' || $lockVal === '') {
            $this->clearDeferredIfStale($lockKey, $lockVal);
            return;
        }

        // Hourly tick: normally run only at the configured hour.
        // If we deferred (queue busy), allow retries on later ticks.
        $nowHour = (int) gmdate('G');
        $atHour = ($nowHour === $hour);
        $deferredReady = $this->deferredIsReady($lockKey, $lockVal);

        $shouldAttempt = false;
        if ($mode === 'daily') {
            $shouldAttempt = $atHour || $deferredReady;
        } else {
            // weekly
            $shouldAttempt = ($isWeekday && $atHour) || $deferredReady;
        }

        if (!$shouldAttempt) {
            return;
        }

        // If queue is paused at the scheduled hour, defer so we can retry
        // on a later tick (otherwise we'd miss the window until next day/week).
        if ($queuePaused) {
            $smart = (bool) $this->settings->get('audit.automation.smart_window', true);
            if ($smart) {
                $this->defer($lockKey, $lockVal, ['busy' => 1, 'reason' => 'queue_paused']);
            }
            return;
        }

        // Smart load window: avoid scheduling scans when the queue is busy with heavy work.
        $smart = (bool) $this->settings->get('audit.automation.smart_window', true);
        if ($smart) {
            $busyInfo = $this->busyInfo();
            if (!empty($busyInfo['busy'])) {
                $this->defer($lockKey, $lockVal, $busyInfo);
                $this->logger->info('Audit automation deferred (queue busy)', [
                    'mode' => $mode,
                    'lock' => $lockVal,
                    'reason' => $busyInfo['reason'] ?? '',
                    'pending' => $busyInfo['pending'] ?? null,
                    'running' => $busyInfo['running'] ?? null,
                    'heavy' => $busyInfo['heavy'] ?? null,
                ]);
                return;
            }
        }

        // Pre-set lock to avoid duplicate triggers if cron fires twice.
        update_option($lockKey, $lockVal, false);

        $includeTerms = (bool) $this->settings->get('audit.automation.include_terms', true);
        $checkSocial = (bool) $this->settings->get('audit.automation.check_social_image', true);

        $scanMode = sanitize_key((string) $this->settings->get('audit.automation.scan_mode', 'normal'));
        if (!in_array($scanMode, ['normal', 'light', 'fast'], true)) {
            $scanMode = 'normal';
        }
        $scanOpts = [];
        if ($scanMode === 'light') {
            $scanOpts['mode'] = 'light';
        } elseif ($scanMode === 'fast') {
            $scanOpts['posts_per_batch'] = 100;
            $scanOpts['terms_per_batch'] = 200;
            $scanOpts['dispatch_limit'] = 50;
            $scanOpts['dispatch_delay'] = 1;
        }

        try {
            $res = $this->jobs->scheduleScan($includeTerms, $checkSocial, $scanOpts);
            $scanId = (int) ($res['scan_id'] ?? 0);
            if ($scanId <= 0) {
                // Revert lock to allow retry next hour.
                update_option($lockKey, '', false);
                // Also defer to allow retry even if the scheduled hour passed.
                $this->defer($lockKey, $lockVal, ['busy' => 1, 'reason' => 'schedule_failed']);
                return;
            }

            $this->clearDeferred();

            update_option('wpsn_audit_last_scheduled', [
                'ts' => time(),
                'mode' => $mode,
                'scan_id' => $scanId,
                'include_terms' => $includeTerms ? 1 : 0,
                'check_social' => $checkSocial ? 1 : 0,
                'scan_mode' => $scanMode,
            ], false);

            $this->logger->info('Audit automation scheduled scan', [
                'mode' => $mode,
                'scan_id' => $scanId,
                'include_terms' => $includeTerms,
                'check_social' => $checkSocial,
                'scan_mode' => $scanMode,
            ]);
        } catch (\Throwable $e) {
            // Revert lock to allow retry.
            update_option($lockKey, '', false);
            // Defer so we can retry on a later tick.
            $this->defer($lockKey, $lockVal, ['busy' => 1, 'reason' => 'exception', 'message' => $e->getMessage()]);
            $this->logger->error('Audit automation schedule failed: ' . $e->getMessage());
        }
    }

    /**
     * @return array{busy:int,reason:string,pending?:int,running?:int,heavy?:int,max_pending?:int}
     */
    private function busyInfo(): array
    {
        // Ensure queue table exists (best-effort).
        try {
            QueueInstaller::maybeInstall();
        } catch (\Throwable $e) {
            // ignore
        }

        $counts = ['pending' => 0, 'running' => 0];
        try {
            $counts = $this->queue->counts();
        } catch (\Throwable $e) {
            $counts = ['pending' => 0, 'running' => 0];
        }

        $pending = (int) ($counts['pending'] ?? 0);
        $running = (int) ($counts['running'] ?? 0);

        $maxPending = (int) $this->settings->get('audit.automation.smart_window_max_pending', 50);
        $maxPending = max(0, min(5000, $maxPending));

        if ($maxPending > 0 && $pending > $maxPending) {
            return [
                'busy' => 1,
                'reason' => 'pending_over_limit',
                'pending' => $pending,
                'running' => $running,
                'max_pending' => $maxPending,
            ];
        }

        $prefixes = [
            'site_crawl_',
            'serp_gap_',
            'gsc_',
            'drift_',
            'content_clusters_',
            'crawl_budget_',
            'autopilot_',
            'saas_',
            'il_',
        ];

        /**
         * Allow extensions to add/remove job type prefixes considered "heavy" for the Audit automation guard.
         *
         * @param array<int,string> $prefixes
         */
        $prefixes = (array) apply_filters('wpsn/audit/automation/heavy_prefixes', $prefixes);
        $prefixes = array_values(array_filter(array_map('strval', $prefixes), static fn($v) => $v !== ''));

        $heavy = $this->countJobsByTypePrefixes($prefixes);
        if ($heavy > 0) {
            return [
                'busy' => 1,
                'reason' => 'heavy_jobs',
                'pending' => $pending,
                'running' => $running,
                'heavy' => $heavy,
            ];
        }

        return [
            'busy' => 0,
            'reason' => '',
            'pending' => $pending,
            'running' => $running,
            'heavy' => 0,
        ];
    }

    /**
     * @param array<int,string> $prefixes
     */
    private function countJobsByTypePrefixes(array $prefixes): int
    {
        if (empty($prefixes)) {
            return 0;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'wpsn_queue_jobs';

        $conds = [];
        $args = [];
        foreach ($prefixes as $p) {
            $p = (string) $p;
            if ($p === '') {
                continue;
            }
            $conds[] = 'job_type LIKE %s';
            $args[] = $wpdb->esc_like($p) . '%';
        }

        if (empty($conds)) {
            return 0;
        }

        $where = '(' . implode(' OR ', $conds) . ')';
        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
        $sql = $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE status IN ('pending','running') AND {$where}",
            $args
        );

        try {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $c = (int) $wpdb->get_var($sql);
            return max(0, $c);
        } catch (\Throwable $e) {
            return 0;
        }
    }

    private function deferredIsReady(string $lockKey, string $lockVal): bool
    {
        $d = get_option(self::OPTION_DEFERRED, []);
        if (!is_array($d)) {
            return false;
        }

        $dk = isset($d['lock_key']) ? (string) $d['lock_key'] : '';
        $dv = isset($d['lock_val']) ? (string) $d['lock_val'] : '';
        $until = isset($d['until']) ? (int) $d['until'] : 0;

        if ($dk === '' || $dv === '' || $until <= 0) {
            return false;
        }

        // Stale defer entry: different lock.
        if ($dk !== $lockKey || $dv !== $lockVal) {
            $this->clearDeferred();
            return false;
        }

        return time() >= $until;
    }

    /**
     * @param array<string,mixed> $meta
     */
    private function defer(string $lockKey, string $lockVal, array $meta = []): void
    {
        $now = time();
        $prev = get_option(self::OPTION_DEFERRED, []);
        $attempts = 0;
        if (is_array($prev) && (string) ($prev['lock_key'] ?? '') === $lockKey && (string) ($prev['lock_val'] ?? '') === $lockVal) {
            $attempts = (int) ($prev['attempts'] ?? 0);
        }
        $attempts = max(0, $attempts) + 1;

        // Retry on the next hourly tick. Even if WP-Cron is delayed, we don't want a tight loop.
        $until = $now + HOUR_IN_SECONDS;

        update_option(self::OPTION_DEFERRED, [
            'lock_key' => $lockKey,
            'lock_val' => $lockVal,
            'until' => $until,
            'attempts' => $attempts,
            'ts' => $now,
            'meta' => $meta,
        ], false);
    }

    private function clearDeferred(): void
    {
        delete_option(self::OPTION_DEFERRED);
    }

    private function clearDeferredIfStale(string $lockKey, string $lockVal): void
    {
        $d = get_option(self::OPTION_DEFERRED, []);
        if (!is_array($d)) {
            return;
        }
        $dk = isset($d['lock_key']) ? (string) $d['lock_key'] : '';
        $dv = isset($d['lock_val']) ? (string) $d['lock_val'] : '';
        if ($dk !== '' && $dv !== '' && ($dk !== $lockKey || $dv !== $lockVal)) {
            $this->clearDeferred();
        }
    }
}
