<?php

declare(strict_types=1);

namespace Atdt\Dos;

use Atdt\Acs;
use Atdt\Db;
use Atdt\Settings;

/**
 * DOS door sessions (SPEC-DOSDOORS §9.1, §9.5): who is inside which
 * door, the capacity gate, the daily minutes budget, the advisory DOS
 * record locks, and the sweep that frees a dead browser's seat. The
 * sweep rides the existing cron tick; the client heartbeats by ping.
 */
final class Sessions
{
    /** A session whose browser has been quiet this long is dead (§9.5). */
    public const STALE_SECONDS = 90;

    /**
     * Open a session for this caller (§9.1). Refusals come back as a
     * short code the door screen words via theme strings.
     * @return array{ok: bool, error?: string, session?: array<string, mixed>}
     */
    public static function open(Db $db, Settings $settings, array $user, int $node, string $slug): array
    {
        if (!Doors::on($settings)) {
            return ['ok' => false, 'error' => 'door_disabled'];
        }
        $door = Doors::bySlug($db, $slug);
        if ($door === null || (int)$door['row']['enabled'] !== 1) {
            return ['ok' => false, 'error' => 'door_disabled'];
        }
        if (!Acs::check((string)$door['row']['acs'], $user)) {
            return ['ok' => false, 'error' => 'acs_denied'];
        }
        self::sweep($db);
        $live = (int)$db->val(
            'SELECT COUNT(*) FROM dos_sessions WHERE slug = ? AND last_seen >= ?',
            [$slug, gmdate('Y-m-d\TH:i:s\Z', time() - self::STALE_SECONDS)]
        );
        if ($live >= (int)$door['row']['max_nodes']) {
            return ['ok' => false, 'error' => 'at_capacity'];
        }
        $minutes = (int)$door['row']['minutes'];
        $left = $minutes - self::minutesUsedToday($db, (int)$user['id'], $slug);
        if ($left <= 0) {
            return ['ok' => false, 'error' => 'no_time_left'];
        }
        // One session per caller per door: re-entry replaces the old seat.
        foreach ($db->all(
            'SELECT * FROM dos_sessions WHERE slug = ? AND user_id = ?',
            [$slug, (int)$user['id']]
        ) as $old) {
            self::close($db, (string)$old['token']);
        }
        Doors::ensureState($slug);
        $manifest = $door['manifest'];
        $token = bin2hex(random_bytes(20));
        $db->insert('dos_sessions', [
            'token' => $token,
            'slug' => $slug,
            'user_id' => (int)$user['id'],
            'node' => $node,
            'started_at' => now_utc(),
            'last_seen' => now_utc(),
            'minutes_left' => $left,
        ]);
        atdt_log_event('info', "dosdoor:$slug", 'Session opened for ' . (string)$user['handle'] . " on node $node");
        $comport = (int)($manifest['comport'] ?? 1);
        $baud = (int)($manifest['baud'] ?? 38400);
        return ['ok' => true, 'session' => [
            'token' => $token,
            'node' => $node,
            'gen' => Fs::gen($slug),
            'manifest' => $manifest,
            'minutes' => $left,
            // §13: with diagnostics on, a SysOp caller also gets the door's
            // local DOS screen mirrored in their tab. That screen is where
            // a door that dies before opening the COM port says why.
            'debug' => (int)$door['row']['debug'] === 1,
            'sysop' => (int)($user['level'] ?? 0) === 255,
            // Cache stamp for the runtime files: the boot floppy changes
            // between releases and browsers cache it hard; the client
            // appends this to every runtime URL so an upgraded board is
            // never booted from a stale cached image.
            'rtv' => (int)@filemtime(ATDT_ROOT . '/dosdoors/boot/freedos.img'),
            'tzoffmin' => self::tzOffMin($settings),
            'dropfile' => [
                'name' => (string)$manifest['dropfile'],
                'text' => Doors::dropfile((string)$manifest['dropfile'], $settings, $user, $node, $left, $comport, $baud),
            ],
            'doorbat' => Doors::doorBat($manifest, $node),
        ]];
    }

    /**
     * A SysOp maintenance seat (§13): the machine on the live game
     * drive with the SysOp's own command in DOOR.BAT, driven from the
     * console. No ACS, capacity, or daily-minutes gates - this is the
     * SysOp working on the door, and it may be disabled while they do.
     * @return array{ok: bool, error?: string, session?: array<string, mixed>}
     */
    public static function openMaint(Db $db, Settings $settings, array $adminUser, string $slug, string $command): array
    {
        if (!Doors::on($settings)) {
            return ['ok' => false, 'error' => 'door_disabled'];
        }
        $door = Doors::bySlug($db, $slug);
        if ($door === null) {
            return ['ok' => false, 'error' => 'door_disabled'];
        }
        self::sweep($db);
        // One maintenance seat per admin per door: re-entry replaces it.
        foreach ($db->all(
            'SELECT * FROM dos_sessions WHERE slug = ? AND user_id = ?',
            [$slug, (int)$adminUser['id']]
        ) as $old) {
            self::close($db, (string)$old['token']);
        }
        Doors::ensureState($slug);
        $manifest = $door['manifest'];
        $token = bin2hex(random_bytes(20));
        $db->insert('dos_sessions', [
            'token' => $token,
            'slug' => $slug,
            'user_id' => (int)$adminUser['id'],
            'node' => 0,
            'started_at' => now_utc(),
            'last_seen' => now_utc(),
            'minutes_left' => 480,
        ]);
        atdt_log_event('info', "dosdoor:$slug", 'Maintenance session opened by ' . (string)$adminUser['handle']);
        $comport = (int)($manifest['comport'] ?? 1);
        $baud = (int)($manifest['baud'] ?? 38400);
        return ['ok' => true, 'session' => [
            'token' => $token,
            'node' => 0,
            'gen' => Fs::gen($slug),
            'manifest' => $manifest,
            'minutes' => 480,
            'rtv' => (int)@filemtime(ATDT_ROOT . '/dosdoors/boot/freedos.img'),
            'tzoffmin' => self::tzOffMin($settings),
            'dropfile' => [
                'name' => (string)$manifest['dropfile'],
                'text' => Doors::dropfile((string)$manifest['dropfile'], $settings, $adminUser, 0, 480, $comport, $baud),
            ],
            'doorbat' => Doors::maintBat($command),
        ]];
    }

    /**
     * The board timezone's current UTC offset, in minutes. The machine's
     * hardware clock serves UTC; the client shifts it by this once at
     * boot so the door's wall clock and, more importantly, the door's
     * DAY are the board's (LORD rolls its day at the DOS midnight).
     */
    private static function tzOffMin(Settings $settings): int
    {
        try {
            $tz = new \DateTimeZone((string)($settings->get('timezone', 'UTC') ?? 'UTC'));
            return intdiv($tz->getOffset(new \DateTimeImmutable('now')), 60);
        } catch (\Throwable) {
            return 0;
        }
    }

    /** @return ?array the live session row for a dos token + owner */
    public static function byToken(Db $db, string $token, int $userId): ?array
    {
        if (preg_match('/^[0-9a-f]{40}$/', $token) !== 1) {
            return null;
        }
        $row = $db->one('SELECT * FROM dos_sessions WHERE token = ?', [$token]);
        if ($row === null || (int)$row['user_id'] !== $userId) {
            return null;
        }
        return $row;
    }

    /** The 30-second heartbeat (§9.5). Returns minutes left, or null. */
    public static function ping(Db $db, array $sess): ?int
    {
        $left = (int)$sess['minutes_left']
            - (int)floor((time() - (int)strtotime((string)$sess['started_at'])) / 60);
        $db->q('UPDATE dos_sessions SET last_seen = ? WHERE id = ?', [now_utc(), (int)$sess['id']]);
        return max(0, $left);
    }

    public static function close(Db $db, string $token): void
    {
        $row = $db->one('SELECT * FROM dos_sessions WHERE token = ?', [$token]);
        if ($row === null) {
            return;
        }
        $db->q('DELETE FROM dos_locks WHERE session_id = ?', [(int)$row['id']]);
        $db->q('DELETE FROM dos_sessions WHERE id = ?', [(int)$row['id']]);
        atdt_log_event('info', 'dosdoor:' . (string)$row['slug'], 'Session closed (node ' . (int)$row['node'] . ')');
    }

    /** Dead browsers free their seats and their locks (§9.5). */
    public static function sweep(Db $db): int
    {
        $cut = gmdate('Y-m-d\TH:i:s\Z', time() - self::STALE_SECONDS);
        $stale = $db->all('SELECT * FROM dos_sessions WHERE last_seen < ?', [$cut]);
        foreach ($stale as $s) {
            $db->q('DELETE FROM dos_locks WHERE session_id = ?', [(int)$s['id']]);
            $db->q('DELETE FROM dos_sessions WHERE id = ?', [(int)$s['id']]);
            atdt_log_event('warn', 'dosdoor:' . (string)$s['slug'],
                'Session swept for timeout (node ' . (int)$s['node'] . ')');
        }
        return count($stale);
    }

    private static function minutesUsedToday(Db $db, int $userId, string $slug): int
    {
        $today = gmdate('Y-m-d');
        $used = 0;
        foreach ($db->all(
            'SELECT * FROM dos_sessions WHERE user_id = ? AND slug = ? AND started_at >= ?',
            [$userId, $slug, $today . 'T00:00:00Z']
        ) as $s) {
            $used += (int)ceil((time() - (int)strtotime((string)$s['started_at'])) / 60);
        }
        return $used;
    }

    // ---- DOS record locks (§9.5): advisory, table-backed ------------------

    public static function lock(Db $db, string $slug, int $sessionId, string $path, int $offset, int $length): array
    {
        $j = Fs::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'granted' => false, 'error' => 'bad path'];
        }
        $rel = $j[1];
        $clash = $db->one(
            'SELECT id FROM dos_locks WHERE slug = ? AND path = ? AND session_id <> ?
              AND offset < ? AND offset + length > ?',
            [$slug, $rel, $sessionId, $offset + max(1, $length), $offset]
        );
        if ($clash !== null) {
            return ['ok' => true, 'granted' => false];
        }
        $db->insert('dos_locks', [
            'session_id' => $sessionId, 'slug' => $slug, 'path' => $rel,
            'offset' => $offset, 'length' => max(1, $length), 'taken_at' => now_utc(),
        ]);
        return ['ok' => true, 'granted' => true];
    }

    public static function unlock(Db $db, string $slug, int $sessionId, string $path, int $offset, int $length): array
    {
        $j = Fs::jail($slug, $path);
        if ($j === null) {
            return ['ok' => false, 'granted' => false, 'error' => 'bad path'];
        }
        $db->q(
            'DELETE FROM dos_locks WHERE slug = ? AND path = ? AND session_id = ? AND offset = ?',
            [$slug, $j[1], $sessionId, $offset]
        );
        return ['ok' => true, 'granted' => true];
    }
}
