<?php

declare(strict_types=1);

namespace Atdt;

/**
 * The internal scheduler (SPEC §17). Every tick is idempotent and
 * individually try/caught, so one bad tick never starves the rest.
 *
 * Two cadences:
 *  - every run (≤5 min): reap dead sessions, prune old events + transient
 *    chat traffic (the public room itself is only capped, never aged out);
 *  - daily at board-midnight: rotate data/logs, door cron hooks (M8 seam).
 *
 * cron.php calls runDue() with a verified key; the pseudo-cron fallback
 * (api.php shutdown) calls maybePseudo() which self-gates on staleness and
 * a non-blocking flock — correctness never depends on real cron, only
 * promptness (§17).
 */
final class Cron
{
    public const STALE_AFTER = 900;   // pseudo-cron kicks in (15 min)
    public const HEALTH_RED = 1200;   // dashboard warning (20 min, §11)
    /** Public-room lines kept regardless of age (1.6.1): the room is
     *  persistent; only the overflow beyond these is ever swept. */
    public const KEEP_CHAT = 1000;

    /**
     * Run everything currently due. Returns a summary for cron.php output.
     * @return array<string, int|string>
     */
    public static function runDue(Db $db, Settings $settings): array
    {
        $out = ['reaped' => 0, 'pruned_events' => 0, 'pruned_chat' => 0, 'daily' => 'no'];
        try {
            $out = array_merge($out, self::everyRun($db, $settings));
        } catch (\Throwable $e) {
            atdt_log_event('error', 'cron', 'every-run tick failed: ' . $e->getMessage());
        }
        try {
            if (self::dailyDue($settings)) {
                self::daily($db, $settings);
                $out['daily'] = 'ran';
            }
        } catch (\Throwable $e) {
            atdt_log_event('error', 'cron', 'daily tick failed: ' . $e->getMessage());
        }
        $settings->set('cron_last_run', now_utc());
        return $out;
    }

    /**
     * Housekeeping on every tick (§17).
     * @return array<string, int>
     */
    public static function everyRun(Db $db, Settings $settings): array
    {
        $reaped = BbsSession::reap($db, $settings);
        // 1.7.0: DOS door seats whose browser went quiet free up here,
        // riding the same tick as the node sweep (SPEC-DOSDOORS §9.5).
        $dosSwept = \Atdt\Dos\Sessions::sweep($db);
        $prunedEvents = $db->q(
            'DELETE FROM events WHERE at < ?',
            [gmdate('Y-m-d\TH:i:s\Z', time() - 90 * 86400)]
        )->rowCount();
        // Transient wire traffic -- page requests, private sysop lines,
        // their hang-up markers -- expires after a week. The PUBLIC room
        // is the board's living room and is never emptied by the clock
        // (1.6.1, owner report: two quiet boards woke to blank rooms
        // when the old age-based sweep took everything at once). It
        // keeps its newest KEEP_CHAT lines however old they are; only
        // the overflow beyond them is swept.
        $prunedChat = $db->q(
            "DELETE FROM chat_msgs WHERE at < ?
               AND NOT (to_node IS NULL AND kind IN ('chat', 'broadcast'))",
            [gmdate('Y-m-d\TH:i:s\Z', time() - 7 * 86400)]
        )->rowCount();
        $cut = $db->val(
            "SELECT id FROM chat_msgs WHERE to_node IS NULL AND kind IN ('chat', 'broadcast')
             ORDER BY id DESC LIMIT 1 OFFSET " . self::KEEP_CHAT
        );
        if ($cut !== null) {
            $prunedChat += $db->q(
                "DELETE FROM chat_msgs WHERE id <= ?
                   AND to_node IS NULL AND kind IN ('chat', 'broadcast')",
                [(int)$cut]
            )->rowCount();
        }
        return ['reaped' => $reaped, 'pruned_events' => $prunedEvents,
            'pruned_chat' => $prunedChat, 'dos_swept' => $dosSwept];
    }

    /** Daily tick: rotate logs + each enabled door's cron hook (§13.5). */
    public static function daily(Db $db, Settings $settings): void
    {
        // 1.6.5 (SPEC 3.3): scope rows whose area is gone or whose
        // caller is deleted are swept once a day.
        try {
            AreaScope::prune($db);
        } catch (\Throwable $e) {
            atdt_log_event('error', 'cron', 'scope prune failed: ' . $e->getMessage());
        }
        foreach ($db->all('SELECT * FROM doors WHERE enabled = 1 ORDER BY id') as $door) {
            $slug = (string)$door['slug'];
            $dir = DoorRunner::dirFor((string)$door['dir']) ?? DoorRunner::dirFor($slug);
            $m = $dir === null ? null : DoorRunner::manifest($dir);
            if ($m === null || empty($m['cron']) || preg_match('/^[a-z0-9_.-]+\.php$/i', (string)$m['cron']) !== 1) {
                continue;
            }
            try {
                $doorCron = new DoorCronContext(new DoorStore($db, $slug), $slug, $settings, $db);
                (static function (string $__file, DoorCronContext $doorCron): void {
                    include $__file;
                })($dir . '/' . (string)$m['cron'], $doorCron);
            } catch (\Throwable $e) {
                atdt_log_event('error', 'door:' . $slug, 'daily hook failed: ' . $e->getMessage());
            }
        }
        self::rotateLogs();
        // §13.5 door daily hooks land with M8's DoorRunner.
        $settings->set('cron_last_daily', self::boardToday($settings));
    }

    /** Has board-midnight passed since the last daily run? */
    public static function dailyDue(Settings $settings): bool
    {
        return ($settings->get('cron_last_daily', '') ?? '') !== self::boardToday($settings);
    }

    /**
     * Pseudo-cron (§17): when cron_last_run is stale, the tail of a caller
     * request runs the due ticks behind a non-blocking lock. Quietly does
     * nothing when real cron is healthy or another request holds the lock.
     */
    public static function maybePseudo(Db $db, Settings $settings): bool
    {
        $last = (string)($settings->get('cron_last_run', '') ?? '');
        if ($last !== '' && strtotime($last) !== false && time() - strtotime($last) < self::STALE_AFTER) {
            return false;
        }
        $dir = ATDT_ROOT . '/data/locks';
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        $fh = @fopen($dir . '/cron.lock', 'c');
        if ($fh === false || !flock($fh, LOCK_EX | LOCK_NB)) {
            if ($fh !== false) {
                fclose($fh);
            }
            return false; // someone else is on it
        }
        try {
            self::runDue($db, $settings);
        } finally {
            flock($fh, LOCK_UN);
            fclose($fh);
        }
        return true;
    }

    /** Seconds since the last cron tick, or null if it never ran. */
    public static function staleness(Settings $settings): ?int
    {
        $last = (string)($settings->get('cron_last_run', '') ?? '');
        $ts = $last === '' ? false : strtotime($last);
        return $ts === false ? null : max(0, time() - $ts);
    }

    /** Board-timezone midnight as a UTC timestamp (shared with Ctx). */
    public static function boardDayStartUtc(Settings $settings): string
    {
        return (new \DateTimeImmutable('now', self::tz($settings)))->setTime(0, 0)
            ->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
    }

    /** Today's date in the board timezone (daily-tick bookkeeping). */
    public static function boardToday(Settings $settings): string
    {
        return (new \DateTimeImmutable('now', self::tz($settings)))->format('Y-m-d');
    }

    private static function tz(Settings $settings): \DateTimeZone
    {
        try {
            return new \DateTimeZone($settings->get('timezone', 'UTC') ?? 'UTC');
        } catch (\Throwable $e) {
            return new \DateTimeZone('UTC');
        }
    }

    /** Keep data/logs bounded: gzip-less size rotation, three generations. */
    private static function rotateLogs(): void
    {
        $dir = ATDT_ROOT . '/data/logs';
        if (!is_dir($dir)) {
            return;
        }
        foreach (glob($dir . '/*.log') ?: [] as $file) {
            if (filesize($file) < 1_048_576) {
                continue; // under 1 MB rides along
            }
            @unlink($file . '.2');
            @rename($file . '.1', $file . '.2');
            @rename($file, $file . '.1');
        }
    }
}
