<?php

declare(strict_types=1);

namespace Atdt\Dos;

use Atdt\Db;
use Atdt\Settings;

/**
 * DOS door registry (SPEC-DOSDOORS §6, §13): manifests, the Scan that
 * registers them, the live-state seeding, and the drop files. Doors are
 * installed by dropping a folder into dosdoors/ off the BBS root, next
 * to doors/; the live shared game directory lives under
 * data/doorstore/dos/<slug>/ and is seeded from the door's files/ on
 * first entry. files/ is never written to.
 */
final class Doors
{
    public const SLUG_RE = '/^[a-z0-9_-]{2,16}$/';
    public const DROPFILES = ['DOOR.SYS', 'DORINFO1.DEF', 'CHAIN.TXT'];

    public static function root(): string
    {
        return ATDT_ROOT . '/dosdoors';
    }

    public static function stateDir(string $slug): string
    {
        return ATDT_ROOT . '/data/doorstore/dos/' . $slug;
    }

    /** The master switch (§13): off hides everything and 404s the API. */
    public static function on(Settings $settings): bool
    {
        return $settings->bool('dos_doors', false);
    }

    /** @return list<string> problems; empty = valid */
    public static function validateManifest(array $m): array
    {
        $bad = [];
        if ((int)($m['api'] ?? 0) !== 1) {
            $bad[] = 'api must be 1';
        }
        if (!is_string($m['slug'] ?? null) || preg_match(self::SLUG_RE, (string)$m['slug']) !== 1) {
            $bad[] = 'slug must be 2-16 of a-z 0-9 _ - (example: "lord")';
        }
        if (!is_string($m['title'] ?? null) || trim((string)$m['title']) === '') {
            $bad[] = 'title is required';
        }
        if (!is_string($m['command'] ?? null) || trim((string)$m['command']) === '') {
            $bad[] = 'command is required (what you would type at a DOS prompt, example: "LORD.EXE /D")';
        }
        if (!in_array((string)($m['dropfile'] ?? ''), self::DROPFILES, true)) {
            $bad[] = 'dropfile must be one of DOOR.SYS, DORINFO1.DEF, CHAIN.TXT';
        }
        foreach (['static', 'session'] as $globs) {
            if (isset($m[$globs])) {
                if (!is_array($m[$globs])) {
                    $bad[] = "$globs must be a list of file patterns (example: [\"*.EXE\", \"*.ANS\"])";
                    continue;
                }
                foreach ($m[$globs] as $g) {
                    if (!is_string($g) || $g === '' || preg_match('#[/\\\\]|\.\.#', $g) === 1) {
                        $bad[] = "$globs patterns are plain filenames with * and ? only (bad: " . json_encode($g) . ')';
                        break;
                    }
                }
            }
        }
        $nodes = (int)($m['max_nodes'] ?? 4);
        if ($nodes < 1 || $nodes > 32) {
            $bad[] = 'max_nodes must be 1 to 32';
        }
        $minutes = (int)($m['minutes'] ?? 45);
        if ($minutes < 5 || $minutes > 480) {
            $bad[] = 'minutes must be 5 to 480';
        }
        $com = (int)($m['comport'] ?? 1);
        if ($com < 1 || $com > 4) {
            $bad[] = 'comport must be 1 to 4';
        }
        if (!in_array($m['jit'] ?? 'on', ['on', 'off'], true)) {
            $bad[] = 'jit must be "on" or "off" (off runs the door on the CPU interpreter, for doors that hang in a Turbo Pascal delay loop)';
        }
        return $bad;
    }

    /**
     * Walk dosdoors/<slug>/dosdoor.json and register or update each
     * valid manifest (§13 Scan). Registration only; no menu changes.
     * @return array{added: list<string>, updated: list<string>, errors: array<string, list<string>>}
     */
    public static function scan(Db $db): array
    {
        $added = [];
        $updated = [];
        $errors = [];
        foreach (glob(self::root() . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
            $name = basename($dir);
            if (in_array($name, ['boot', 'client'], true)) {
                continue;                                // shipped runtime, not doors
            }
            $mf = $dir . '/dosdoor.json';
            if (!is_file($mf)) {
                continue;
            }
            $m = json_decode((string)file_get_contents($mf), true);
            if (!is_array($m)) {
                $errors[$name] = ['dosdoor.json is not valid JSON'];
                continue;
            }
            $bad = self::validateManifest($m);
            if ((string)($m['slug'] ?? '') !== $name) {
                $bad[] = "slug \"{$m['slug']}\" must match the folder name \"$name\"";
            }
            if (!is_dir($dir . '/files')) {
                $bad[] = 'no files/ directory: the game itself goes in dosdoors/' . $name . '/files/';
            }
            if ($bad !== []) {
                $errors[$name] = $bad;
                continue;
            }
            $row = $db->one('SELECT * FROM dos_doors WHERE slug = ?', [$name]);
            $fields = [
                'title' => mb_strimwidth((string)$m['title'], 0, 60),
                'max_nodes' => (int)($m['max_nodes'] ?? 4),
                'minutes' => (int)($m['minutes'] ?? 45),
                'manifest' => (string)json_encode($m),
            ];
            if ($row === null) {
                $db->insert('dos_doors', $fields + [
                    'slug' => $name, 'enabled' => 1, 'acs' => '',
                    'sort' => 10 + (int)$db->val('SELECT COALESCE(MAX(sort), 0) FROM dos_doors'),
                    'debug' => 0, 'created_at' => now_utc(),
                ]);
                $added[] = $name;
                atdt_log_event('info', "dosdoor:$name", 'Registered by Scan');
            } else {
                $db->q(
                    'UPDATE dos_doors SET title = ?, max_nodes = ?, minutes = ?, manifest = ? WHERE id = ?',
                    [$fields['title'], $fields['max_nodes'], $fields['minutes'], $fields['manifest'], (int)$row['id']]
                );
                $updated[] = $name;
                atdt_log_event('info', "dosdoor:$name", 'Updated by Scan');
            }
        }
        return ['added' => $added, 'updated' => $updated, 'errors' => $errors];
    }

    /** @return ?array{row: array, manifest: array} */
    public static function bySlug(Db $db, string $slug): ?array
    {
        if (preg_match(self::SLUG_RE, $slug) !== 1) {
            return null;
        }
        $row = $db->one('SELECT * FROM dos_doors WHERE slug = ?', [$slug]);
        if ($row === null) {
            return null;
        }
        $m = json_decode((string)$row['manifest'], true);
        return is_array($m) ? ['row' => $row, 'manifest' => $m] : null;
    }

    /**
     * Seed the live state directory from files/ on first entry (§6).
     * Filenames canonicalize to uppercase on the way in: Linux is
     * case-sensitive and DOS is not (§9.3).
     */
    public static function ensureState(string $slug): bool
    {
        $state = self::stateDir($slug);
        if (is_dir($state)) {
            return false;
        }
        $src = self::root() . '/' . $slug . '/files';
        if (!@mkdir($state, 0755, true)) {
            return false;
        }
        self::copyUpper($src, $state);
        atdt_log_event('info', "dosdoor:$slug", 'Game state seeded from files/');
        return true;
    }

    private static function copyUpper(string $from, string $to): void
    {
        foreach (glob($from . '/*') ?: [] as $f) {
            $name = strtoupper(basename($f));
            if (is_dir($f)) {
                @mkdir($to . '/' . $name, 0755, true);
                self::copyUpper($f, $to . '/' . $name);
            } elseif (str_ends_with($name, '.BAT')) {
                // A batch file that lost its carriage returns (the
                // classic ASCII-mode FTP upload) dies after its FIRST
                // line in DOS, silently — the door flashes and exits and
                // nothing anywhere says why. No DOS door ever wants an
                // LF-only .BAT, so the seed normalizes line endings.
                $text = (string)@file_get_contents($f);
                @file_put_contents(
                    $to . '/' . $name,
                    preg_replace('/\r?\n/', "\r\n", $text) ?? $text
                );
            } else {
                @copy($f, $to . '/' . $name);
            }
        }
    }

    /** Wipe and re-seed (the console's Reset game state). */
    public static function resetState(string $slug): void
    {
        $state = self::stateDir($slug);
        if (is_dir($state)) {
            self::rmTree($state);
        }
        @unlink($state . '.gen');
        self::ensureState($slug);
        atdt_log_event('info', "dosdoor:$slug", 'Game state reset and reseeded');
    }

    /**
     * Delete the live game state whole, with no reseed (the console's
     * Remove + data). The door's files/ under dosdoors/ are untouched:
     * a later Scan re-installs the door factory-new from them.
     */
    public static function purgeState(string $slug): void
    {
        $state = self::stateDir($slug);
        if (is_dir($state)) {
            self::rmTree($state);
        }
        @unlink($state . '.gen');
        @unlink($state . '.diag');
        atdt_log_event('info', "dosdoor:$slug", 'Game state deleted');
    }

    private static function rmTree(string $dir): void
    {
        foreach (glob($dir . '/*') ?: [] as $f) {
            is_dir($f) ? self::rmTree($f) : @unlink($f);
        }
        @rmdir($dir);
    }

    // ---- drop files (§9.1) ------------------------------------------------

    /**
     * The drop file text for this caller and node. Served to the client
     * and planted in the session overlay by the runner, never written
     * into the shared directory, so nodes cannot collide on it.
     */
    public static function dropfile(
        string $kind,
        Settings $settings,
        array $user,
        int $node,
        int $minutes,
        int $comport,
        int $baud
    ): string {
        $board = (string)($settings->get('board_name', 'ATDT') ?? 'ATDT');
        $sysop = (string)($settings->get('sysop_handle', 'Sysop') ?? 'Sysop');
        $handle = (string)($user['handle'] ?? 'CALLER');
        $level = (int)($user['level'] ?? 10);
        $location = (string)($user['location'] ?? '');
        $crlf = "\r\n";
        // Dates in a drop file are BOARD dates, not UTC: the door's day
        // must be the board's day, or a caller's "last call" reads like
        // time travel in the evening.
        try {
            $tz = new \DateTimeZone((string)($settings->get('timezone', 'UTC') ?? 'UTC'));
        } catch (\Throwable) {
            $tz = new \DateTimeZone('UTC');
        }
        $today = (new \DateTimeImmutable('now', $tz))->format('m/d/y');
        if ($kind === 'DORINFO1.DEF') {
            $names = preg_split('/\s+/', trim($sysop)) ?: [$sysop];
            $first = strtoupper((string)($names[0] ?? 'SYSOP'));
            $last = strtoupper((string)($names[1] ?? ''));
            $unames = preg_split('/\s+/', trim($handle)) ?: [$handle];
            return implode($crlf, [
                $board,
                $first,
                $last,
                'COM' . $comport,
                $baud . ' BAUD,N,8,1',
                '0',
                strtoupper((string)($unames[0] ?? $handle)),
                strtoupper((string)($unames[1] ?? '')),
                strtoupper($location),
                '1',
                (string)$level,
                (string)$minutes,
                '-1',
            ]) . $crlf;
        }
        if ($kind === 'CHAIN.TXT') {
            return implode($crlf, [
                '1',                       // user number
                $handle,
                $handle,                   // real name: the handle, on purpose
                '',                        // callsign
                '30',                      // age
                'M',
                '0.00',
                $location,
                '80', '25',
                (string)$level,
                '1',                       // ANSI
                '0',                       // remote
                (string)($minutes * 60),
                $today,
                (string)$baud,
                'COM' . $comport,
                (string)$node,
            ]) . $crlf;
        }
        // DOOR.SYS (GAP, 52 lines): the widest-read format of the three.
        $lines = [
            'COM' . $comport . ':',        // 1 comm port
            (string)$baud,                 // 2 baud
            '8',                           // 3 data bits
            (string)$node,                 // 4 node
            (string)$baud,                 // 5 dte rate
            'Y',                           // 6 screen display
            'Y',                           // 7 printer
            'Y',                           // 8 page bell
            'Y',                           // 9 caller alarm
            $handle,                       // 10 full name
            $location,                     // 11 location
            '555 555-5555',                // 12 home phone
            '555 555-5555',                // 13 work phone
            'PASSWORD',                    // 14 password (never the real one)
            (string)$level,                // 15 security level
            '1',                           // 16 total calls
            $today,                        // 17 last call date
            (string)($minutes * 60),       // 18 seconds remaining
            (string)$minutes,              // 19 minutes remaining
            'GR',                          // 20 graphics (ANSI)
            '25',                          // 21 screen length
            'N',                           // 22 expert mode
            '',                            // 23 conferences
            '',                            // 24 conference exited to
            '01/01/99',                    // 25 expiration date
            '1',                           // 26 user record number
            'Y',                           // 27 default protocol
            '0',                           // 28 uploads
            '0',                           // 29 downloads
            '0',                           // 30 daily dl k
            '999999',                      // 31 daily dl max k
        ];
        while (count($lines) < 52) {
            $lines[] = '';
        }
        return implode($crlf, $lines) . $crlf;
    }

    /**
     * DOOR.BAT for a SysOp maintenance session (§13): runs whatever the
     * SysOp typed - a config tool, an installer, DIR - on the live game
     * drive, then signals teardown the usual way. Maintenance tools are
     * console programs, so the screen and keyboard are the machine's
     * own; only the sentinel rides COM1.
     */
    public static function maintBat(string $command): string
    {
        $crlf = "\r\n";
        $cmd = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $command) ?? '');
        return '@ECHO OFF' . $crlf
            . 'SET NODE=0' . $crlf
            . ($cmd === '' ? 'DIR' : mb_strimwidth($cmd, 0, 100)) . $crlf
            . 'ECHO ATDT-DOOR-EOF > COM1' . $crlf;
    }

    /**
     * DOOR.BAT, generated per session (§8.3 moved server-side): the
     * runner plants it in the session overlay on drive D:. The sentinel
     * line tells the shim the door has exited.
     */
    public static function doorBat(array $manifest, int $node): string
    {
        $crlf = "\r\n";
        // The sentinel always goes out COM1: the virtual node has exactly
        // one UART and it is the one the browser listens on. The manifest
        // comport only changes what the DROP FILE claims, for doors whose
        // own config insists on a particular COM number.
        return '@ECHO OFF' . $crlf
            . 'SET NODE=' . $node . $crlf
            . trim((string)$manifest['command']) . $crlf
            . 'ECHO ATDT-DOOR-EOF > COM1' . $crlf;
    }
}
