<?php

namespace classes;

use RuntimeException;
use Throwable;

class cron_scheduler
{
    private cron_task_registry $registry;
    private string $lock_owner;

    public function __construct(?cron_task_registry $registry = null)
    {
        $this->registry = $registry ?? new cron_task_registry();
        $this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
    }

    public function listTasks(): array
    {
        $this->ensureReady();
        $this->syncDefinitions();

        $states = $this->stateRows();
        $estimates = $this->durationEstimates();
        $tasks = [];
        $now = time();

        foreach ($this->registry->definitions() as $definition) {
            $state = $states[$definition->id] ?? [];
            $schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
                ? $state['schedule']
                : $definition->schedule;
            $nextRunAt = $state['next_run_at'] ?? null;
            if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
                $nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
            }

            $task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
            $task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
            $task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
            $tasks[] = $task;
        }

        return [
            'tasks' => $tasks,
            'summary' => [
                'total' => count($tasks),
                'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
                'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
            ],
        ];
    }

    public function listRuns(?string $task_id = null, int $limit = 50): array
    {
        $this->ensureReady();
        $limit = max(1, min(200, $limit));

        $where = '';
        if ($task_id !== null && trim($task_id) !== '') {
            $where = "WHERE task_id = " . $this->sql($task_id);
        }

        return $this->fetchAll(
            "SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
        );
    }

    public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
    {
        $this->ensureReady();
        $this->syncDefinitions();

        $definition = $this->registry->get($task_id_or_legacy_name);
        if ($definition === null) {
            throw new RuntimeException('Cron task not found.');
        }

        $state = $this->stateRows()[$definition->id] ?? [];
        $enabled = (bool)($state['enabled'] ?? $definition->enabled);
        if (!$enabled && !$force) {
            throw new RuntimeException('Cron task is disabled.');
        }
        if ($this->taskIsLocked($state)) {
            throw new RuntimeException('Cron task is already running.');
        }

        $existing = $this->fetchOne(
            "SELECT * FROM cron_task_runs
            WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
            ORDER BY id DESC LIMIT 1"
        );
        if ($existing !== null) {
            $this->markTaskQueued($definition);
            return $this->publicRun($existing);
        }

        $scheduled_for = date('Y-m-d H:i:s');
        $this->query(
            "INSERT INTO cron_task_runs
                (task_id, module, source, status, actor_user_id, scheduled_for, force_run)
            VALUES ("
                . $this->sql($definition->id) . ', '
                . $this->sql($definition->module) . ", 'manual', 'queued', "
                . ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
                . $this->sql($scheduled_for) . ', '
                . ($force ? '1' : '0')
            . ")"
        );

        $run_id = (int)$this->insertId();
        $this->markTaskQueued($definition);

        return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
    }

    public function runDue(string $source = 'automatic'): array
    {
        $this->ensureReady();
        $this->syncDefinitions();

        $ran = [];
        foreach ($this->queuedRuns() as $queuedRun) {
            try {
                $run = $this->runQueuedRun($queuedRun);
                if ($run !== null) {
                    $ran[] = $run;
                }
            } catch (Throwable $throwable) {
                $ran[] = [
                    'task_id' => (string)($queuedRun['task_id'] ?? ''),
                    'module' => (string)($queuedRun['module'] ?? ''),
                    'source' => (string)($queuedRun['source'] ?? 'manual'),
                    'status' => 'skipped',
                    'error_message' => $throwable->getMessage(),
                ];
            }
        }

        $now = time();
        $states = $this->stateRows();
        foreach ($this->registry->definitions() as $definition) {
            $state = $states[$definition->id] ?? [];
            if (!(bool)($state['enabled'] ?? $definition->enabled)) {
                continue;
            }

            $nextRunAt = (string)($state['next_run_at'] ?? '');
            if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
                continue;
            }

            try {
                $ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
            } catch (Throwable $throwable) {
                $ran[] = [
                    'task_id' => $definition->id,
                    'module' => $definition->module,
                    'source' => $source,
                    'status' => 'skipped',
                    'error_message' => $throwable->getMessage(),
                ];
            }
        }

        return [
            'ran' => $ran,
            'count' => count($ran),
        ];
    }

    public function markExpiredRunningRuns(): int
    {
        $this->ensureReady();
        $now = date('Y-m-d H:i:s');
        $message = 'Task lock expired before completion.';

        $this->query(
            "UPDATE cron_task_runs r
            INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
            SET r.status = 'timed_out',
                r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
                r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
                s.current_run_id = NULL,
                s.locked_until = NULL,
                s.lock_owner = NULL,
                s.last_status = 'timed_out',
                s.last_error = " . $this->sql($message) . "
            WHERE r.status = 'running'
              AND s.locked_until IS NOT NULL
              AND s.locked_until < " . $this->sql($now)
        );

        return $this->affectedRows();
    }

    public function runTask(
        string $task_id_or_legacy_name,
        string $source = 'manual',
        ?int $actor_user_id = null,
        bool $force = false,
        ?string $scheduled_for = null
    ): array {
        $this->ensureReady();
        $this->syncDefinitions();

        $definition = $this->registry->get($task_id_or_legacy_name);
        if ($definition === null) {
            throw new RuntimeException('Cron task not found.');
        }

        $state = $this->stateRows()[$definition->id] ?? [];
        $enabled = (bool)($state['enabled'] ?? $definition->enabled);
        if (!$enabled && !$force) {
            throw new RuntimeException('Cron task is disabled.');
        }

        if (!$this->claimLock($definition)) {
            throw new RuntimeException('Cron task is already running.');
        }

        $started = microtime(true);
        $started_at = date('Y-m-d H:i:s', (int)$started);
        $run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
        $this->query(
            "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
        );

        return $this->executeClaimedRun($definition, $run_id, $started);
    }

    private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
    {
        $status = 'succeeded';
        $summary = [];
        $error_message = null;
        $output = '';

        try {
            if (function_exists('set_time_limit')) {
                @set_time_limit($definition->timeout_seconds + 30);
            }

            $this->ensureLegacyFunctionsLoaded($definition);
            if (!is_callable($definition->handler)) {
                throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
            }

            ob_start();
            $result = call_user_func($definition->handler);
            $output = (string)ob_get_clean();
            $summary = is_array($result) ? $result : [];
        } catch (Throwable $throwable) {
            if (ob_get_level() > 0) {
                $output .= (string)ob_get_clean();
            }
            $status = 'failed';
            $error_message = $throwable->getMessage();
        }

        $completed = microtime(true);
        $duration_ms = (int)round(($completed - $started) * 1000);
        if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
            $status = 'timed_out';
            $error_message = 'Task exceeded its configured timeout window.';
        }

        if ($output !== '') {
            $summary['output'] = substr($output, 0, 8000);
        }

        $completed_at = date('Y-m-d H:i:s', (int)$completed);
        $this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
        $this->releaseLock($definition, $status, $error_message, $completed_at);

        return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
    }

    public function updateTaskConfig(string $task_id, array $config): array
    {
        $this->ensureReady();
        $this->syncDefinitions();

        $definition = $this->registry->get($task_id);
        if ($definition === null) {
            throw new RuntimeException('Cron task not found.');
        }

        $updates = [];
        if (array_key_exists('enabled', $config)) {
            $updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
        }

        if (array_key_exists('schedule', $config)) {
            $schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
            $updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
            $anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
            $updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
        }

        if ($updates !== []) {
            $this->query(
                "UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
            );
        }

        return $this->listTasks();
    }

    private function ensureReady(): void
    {
        cron_schema_bootstrap::ensureTables();
    }

    private function syncDefinitions(): void
    {
        $now = time();
        foreach ($this->registry->definitions() as $definition) {
            $row = $this->fetchOne(
                "SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
            );
            if ($row !== null) {
                continue;
            }

            $legacyLastRun = $this->legacyLastRun($definition);
            $nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
            $this->query(
                "INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
                VALUES ("
                . $this->sql($definition->id) . ', '
                . $this->sql($definition->module) . ', '
                . ($definition->enabled ? '1' : '0') . ', NULL, '
                . $this->sql($nextRunAt)
                . ")"
            );
        }
    }

    private function legacyLastRun(cron_task_definition $definition): ?int
    {
        if ($definition->legacy_name === null || !defined('redis')) {
            return null;
        }

        try {
            $last_run = redis->get_last_crond_run($definition->legacy_name);
            return $last_run !== null ? (int)$last_run : null;
        } catch (Throwable) {
            return null;
        }
    }

    private function claimLock(cron_task_definition $definition): bool
    {
        $now = date('Y-m-d H:i:s');
        $locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
        $this->query(
            "UPDATE cron_task_state
            SET locked_until = " . $this->sql($locked_until) . ",
                lock_owner = " . $this->sql($this->lock_owner) . "
            WHERE task_id = " . $this->sql($definition->id) . "
              AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
        );

        return $this->affectedRows() === 1;
    }

    private function taskIsLocked(array $state): bool
    {
        $lockedUntil = (string)($state['locked_until'] ?? '');
        return $lockedUntil !== ''
            && strtotime($lockedUntil) !== false
            && strtotime($lockedUntil) >= time();
    }

    private function markTaskQueued(cron_task_definition $definition): void
    {
        $now = date('Y-m-d H:i:s');
        $this->query(
            "UPDATE cron_task_state
            SET last_status = 'queued',
                last_error = NULL,
                next_run_at = CASE
                    WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
                    ELSE next_run_at
                END
            WHERE task_id = " . $this->sql($definition->id)
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function queuedRuns(): array
    {
        return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
    }

    private function runQueuedRun(array $queuedRun): ?array
    {
        $run_id = (int)($queuedRun['id'] ?? 0);
        $definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
        if ($run_id < 1 || $definition === null) {
            if ($run_id > 0) {
                $this->skipQueuedRun($run_id, 'Cron task not found.');
                return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
            }
            return null;
        }

        $force = (bool)($queuedRun['force_run'] ?? false);
        $state = $this->stateRows()[$definition->id] ?? [];
        $enabled = (bool)($state['enabled'] ?? $definition->enabled);
        if (!$enabled && !$force) {
            $this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
            return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
        }

        if (!$this->claimLock($definition)) {
            return null;
        }

        $started = microtime(true);
        $started_at = date('Y-m-d H:i:s', (int)$started);
        $this->query(
            "UPDATE cron_task_runs
            SET status = 'running',
                started_at = " . $this->sql($started_at) . ",
                lock_owner = " . $this->sql($this->lock_owner) . "
            WHERE id = $run_id AND status = 'queued'"
        );

        if ($this->affectedRows() !== 1) {
            $this->clearClaimedLock($definition);
            return null;
        }

        $this->query(
            "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
        );

        return $this->executeClaimedRun($definition, $run_id, $started);
    }

    private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
    {
        $completed_at = date('Y-m-d H:i:s');
        $this->query(
            "UPDATE cron_task_runs
            SET status = 'skipped',
                completed_at = " . $this->sql($completed_at) . ",
                duration_ms = 0,
                error_message = " . $this->sql($message) . "
            WHERE id = $run_id AND status = 'queued'"
        );
        $updated = $this->affectedRows() === 1;
        if (!$updated || $definition === null) {
            return;
        }

        $this->query(
            "UPDATE cron_task_state
            SET last_status = 'skipped',
                last_error = " . $this->sql($message) . "
            WHERE task_id = " . $this->sql($definition->id)
        );
    }

    private function clearClaimedLock(cron_task_definition $definition): void
    {
        $this->query(
            "UPDATE cron_task_state
            SET locked_until = NULL,
                lock_owner = NULL
            WHERE task_id = " . $this->sql($definition->id) . "
              AND lock_owner = " . $this->sql($this->lock_owner)
        );
    }

    private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
    {
        $state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
        $schedule = $this->decodeJson($state['schedule_json'] ?? null);
        if ($schedule === []) {
            $schedule = $definition->schedule;
        }

        $nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
        if ($status !== 'succeeded') {
            $retrySeconds = min(300, max(60, (int)$schedule['seconds']));
            $nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
        }

        $this->query(
            "UPDATE cron_task_state
            SET last_run_at = " . $this->sql($completed_at) . ",
                next_run_at = " . $this->sql($nextRunAt) . ",
                locked_until = NULL,
                lock_owner = NULL,
                current_run_id = NULL,
                last_status = " . $this->sql($status) . ",
                last_error = " . $this->nullableSql($error_message) . "
            WHERE task_id = " . $this->sql($definition->id) . "
              AND lock_owner = " . $this->sql($this->lock_owner)
        );

        if ($definition->legacy_name !== null && defined('redis')) {
            try {
                redis->set_last_crond_run($definition->legacy_name, time());
            } catch (Throwable) {
            }
        }
    }

    private function createRun(
        cron_task_definition $definition,
        string $source,
        ?int $actor_user_id,
        ?string $scheduled_for,
        string $started_at,
        bool $force = false
    ): int {
        $this->query(
            "INSERT INTO cron_task_runs
                (task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
            VALUES ("
                . $this->sql($definition->id) . ', '
                . $this->sql($definition->module) . ', '
                . $this->sql($source) . ", 'running', "
                . ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
                . $this->nullableSql($scheduled_for) . ', '
                . $this->sql($started_at) . ', '
                . $this->sql($this->lock_owner) . ', '
                . ($force ? '1' : '0')
            . ")"
        );

        return $this->insertId();
    }

    private function completeRun(
        int $run_id,
        string $status,
        string $completed_at,
        int $duration_ms,
        array $summary,
        ?string $error_message
    ): void {
        $this->query(
            "UPDATE cron_task_runs
            SET status = " . $this->sql($status) . ",
                completed_at = " . $this->sql($completed_at) . ",
                duration_ms = " . (string)$duration_ms . ",
                summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
                error_message = " . $this->nullableSql($error_message) . "
            WHERE id = " . (string)$run_id
        );
    }

    private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
    {
        if (function_exists($definition->handler)) {
            return;
        }

        if (!defined('WD')) {
            return;
        }

        if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
            define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
        }

        require_once WD . '/cron/Cron.php';
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function stateRows(): array
    {
        $rows = $this->fetchAll("SELECT * FROM cron_task_state");
        $states = [];
        foreach ($rows as $row) {
            $row['enabled'] = (bool)$row['enabled'];
            $row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
            $states[(string)$row['task_id']] = $row;
        }
        return $states;
    }

    /**
     * @return array<string, int>
     */
    private function durationEstimates(): array
    {
        $rows = $this->fetchAll(
            "SELECT task_id, AVG(duration_ms) AS avg_duration_ms
            FROM (
                SELECT task_id, duration_ms
                FROM cron_task_runs
                WHERE status = 'succeeded' AND duration_ms IS NOT NULL
                ORDER BY id DESC
                LIMIT 500
            ) recent_runs
            GROUP BY task_id"
        );

        $estimates = [];
        foreach ($rows as $row) {
            $estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
        }
        return $estimates;
    }

    private function decodeJson(mixed $json): array
    {
        if (!is_string($json) || trim($json) === '') {
            return [];
        }
        $decoded = json_decode($json, true);
        return is_array($decoded) ? $decoded : [];
    }

    private function publicRun(array $run): array
    {
        if ($run === []) {
            return [];
        }

        $run['force_run'] = (bool)($run['force_run'] ?? false);
        $run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
        return $run;
    }

    private function fetchOne(string $sql): ?array
    {
        $rows = $this->fetchAll($sql);
        return $rows[0] ?? null;
    }

    private function fetchAll(string $sql): array
    {
        $result = $this->query($sql);
        if ($result === false || $result === true) {
            return [];
        }
        return $result->fetch_all(MYSQLI_ASSOC);
    }

    private function query(string $sql): \mysqli_result|bool
    {
        global $db;
        return $db->query($sql);
    }

    private function sql(string $value): string
    {
        global $db;
        return "'" . $db->escape_string($value) . "'";
    }

    private function nullableSql(?string $value): string
    {
        return $value === null ? 'NULL' : $this->sql($value);
    }

    private function affectedRows(): int
    {
        global $db;
        return (int)$db->conn()->affected_rows;
    }

    private function insertId(): int
    {
        global $db;
        return (int)$db->insert_id();
    }
}
