<?php

declare(strict_types=1);

/**
 * JSON API for the terminal client (SPEC §7.4, §9.1).
 *
 * Envelope: {frames: [...], events: [...], session?: {...}}. Inputs are
 * dispatched to the ScreenEngine (§9.4); per-session writes are serialized
 * with flock (§9.5); the seq counter discards out-of-order duplicates (§7.4).
 * Calls are untimed by owner decree — no clocks in the lifecycle.
 */

const ATDT_JSON = true;
require __DIR__ . '/lib/bootstrap.php';

use Atdt\BbsSession;
use Atdt\Ctx;
use Atdt\In;
use Atdt\ScreenEngine;
use Atdt\Theme;

header('Content-Type: application/json; charset=utf-8');

// Pseudo-cron (§17): after the response is out the door, run any overdue
// housekeeping. Self-gates on staleness + a non-blocking lock; a healthy
// real cron makes this a no-op.
register_shutdown_function(static function (): void {
    if (!atdt_installed()) {
        return;
    }
    try {
        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request(); // caller never waits on our chores
        }
        \Atdt\Cron::maybePseudo(atdt_db(), atdt_settings());
    } catch (\Throwable $e) {
        error_log('[atdt:cron] pseudo-cron failed: ' . $e->getMessage());
    }
});

if (!atdt_installed()) {
    http_response_code(503);
    respond([], ['error' => 'not_installed', 'hint' => 'Run install.php first']);
}
atdt_migrate();
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    respond([], ['error' => 'post_only']);
}

$in = readInput();
$db = atdt_db();
$settings = atdt_settings();
$action = (string)($in['a'] ?? '');

if ($action === 'connect') {
    // The door is barred before a node is even considered (1.1.0): a
    // banned address gets its notice -- a theme string, editable like
    // any other -- and never starts the ceremony.
    if (ipBanned(clientIp(), $settings)) {
        $theme = new Theme($settings->get('default_theme', 'default'));
        $barred = \Atdt\Pipe::render($theme->str('connect.banned'));
        respond([
            ['t' => 'ansi', 'd' => "\r\n$barred\r\n"],
            ['t' => 'disconnect', 'reason' => 'banned'],
        ]);
    }
    // Board offline (1.7.0): the sysop is in the engine room, so nobody
    // connects -- not even level 255. The sysop's own wording (or the
    // stock line) renders with pipe codes, then the line drops. Callers
    // already on line are left alone; the console has a separate
    // "disconnect everyone" switch for them.
    if (BbsSession::offline($settings)) {
        $notice = \Atdt\Pipe::render(BbsSession::offlineMsg($settings));
        $notice = preg_replace('/\r\n?|\n/', "\r\n", $notice) ?? $notice;
        respond([
            ['t' => 'ansi', 'd' => "\r\n$notice\r\n"],
            ['t' => 'disconnect', 'reason' => 'offline'],
        ]);
    }
    $lock = BbsSession::globalLock('connect');
    try {
        BbsSession::reap($db, $settings);
        $node = BbsSession::allocateNode($db, max(1, $settings->int('max_nodes', 10)));
        if ($node === null) {
            $theme = new Theme($settings->get('default_theme', 'default'));
            $busy = \Atdt\Pipe::render($theme->str('matrix.busy'));
            respond([
                ['t' => 'ansi', 'd' => "\r\n$busy\r\n"],
                ['t' => 'disconnect', 'reason' => 'busy'],
            ]);
        }
        $sess = BbsSession::create($db, $node, clientIp());
    } finally {
        BbsSession::unlockGlobal($lock);
    }
    $ctx = makeCtx($db, $settings, $sess);
    $frames = ScreenEngine::enterTop($ctx);
    persist($db, $ctx, $sess);
    respond($frames, ['session' => [
        'token' => $sess['token'],
        'node' => $node,
        'seq' => 0,
        'poll_ms' => 4000,
        'ice' => $settings->bool('ice_colors'),
    ]]);
}

// Read-only menu preview for the console's designer (§11.5): rides the
// admin PHP session, never a caller token, and persists nothing.
if ($action === 'preview') {
    if (atdt_admin_uid() <= 0) {
        http_response_code(403);
        respond([], ['error' => 'admin_only']);
    }
    $adminUser = $db->one('SELECT * FROM users WHERE id = ?', [atdt_admin_uid()]);
    if ($adminUser === null || (int)$adminUser['level'] !== 255) {
        http_response_code(403);
        respond([], ['error' => 'admin_only']);
    }
    $menu = strtoupper((string)($in['menu'] ?? 'MAIN'));
    if (!preg_match('/^[A-Z0-9_]{1,20}$/', $menu)) {
        $menu = 'MAIN';
    }
    $fake = [
        'id' => 0, 'token' => '', 'user_id' => (int)$adminUser['id'],
        'node' => 0, 'state' => '', 'seq' => 0,
    ];
    $ctx = makeCtx($db, $settings, $fake);
    respond($ctx->push('Menu', ['menu' => $menu]));
}

// Every other action addresses an existing session by token.
$sess = BbsSession::byToken($db, (string)($in['token'] ?? ''));
if ($sess === null) {
    respond([['t' => 'disconnect', 'reason' => 'no_session']]);
}
if (ipBanned(clientIp(), $settings)) {
    // Banned mid-call: the line goes dead on the next keystroke.
    BbsSession::close($db, $settings, $sess);
    respond([['t' => 'disconnect', 'reason' => 'banned']]);
}
BbsSession::lock((int)$sess['id']);

// The console can hang up any line (1.6.0): checked before anything
// else runs, so the next request -- even a poll -- ends the call.
if ((int)($sess['kick'] ?? 0) === 1) {
    BbsSession::close($db, $settings, $sess);
    atdt_log_event('info', 'node' . (int)$sess['node'], 'Line closed from the console');
    respond([
        ['t' => 'ansi', 'd' => "\r\n\r\n  The SysOp has closed this line.\r\n\r\n  NO CARRIER\r\n"],
        ['t' => 'disconnect', 'reason' => 'kicked'],
    ]);
}

// The idle rule (1.6.0): a caller who has PRESSED nothing for
// idle_minutes gets hung up on, whatever screen they sit at -- polls
// keep last_seen warm but do not count as being there. 0 disables.
$idleMin = max(0, (int)($settings->get('idle_minutes', '30') ?? 30));
$lastInput = (string)($sess['last_input'] ?? '');
if ($idleMin > 0 && $action === 'poll') {
    $since = strtotime($lastInput !== '' ? $lastInput : (string)$sess['created_at']);
    if ($since !== false && time() - $since > $idleMin * 60) {
        BbsSession::close($db, $settings, $sess);
        atdt_log_event('info', 'node' . (int)$sess['node'], sprintf('Idle for %d minutes; the line let go', $idleMin));
        respond([
            ['t' => 'ansi', 'd' => "\r\n\r\n  " . $idleMin . " quiet minutes. The line lets go.\r\n\r\n  NO CARRIER\r\n"],
            ['t' => 'disconnect', 'reason' => 'idle'],
        ]);
    }
}

// Discard out-of-order duplicates (§7.4). The client bumps seq per request.
$seq = (int)($in['seq'] ?? 0);
if ($seq <= (int)$sess['seq']) {
    respond([], ['session' => ['seq' => (int)$sess['seq']]]);
}
$db->q(
    $action === 'poll'
        ? 'UPDATE sessions SET last_seen = ?, seq = ? WHERE id = ?'
        : 'UPDATE sessions SET last_seen = ?, seq = ?, last_input = ? WHERE id = ?',
    $action === 'poll'
        ? [now_utc(), $seq, (int)$sess['id']]
        : [now_utc(), $seq, now_utc(), (int)$sess['id']]
);

switch ($action) {
    case 'poll':
        BbsSession::reap($db, $settings);
        $ctx = makeCtx($db, $settings, $sess);
        // Async chat delivery (§9.6): the client holds the watermark.
        $frames = [];
        $extra = [];
        $rows = [];
        $hw = isset($in['chat_hw']) ? (int)$in['chat_hw'] : -1;
        if ($hw < 0) {
            $extra['chat_hw'] = \Atdt\Chat::maxId($db);
        } else {
            $rows = \Atdt\Chat::since($db, (int)$sess['node'], $hw);
            if ($rows !== []) {
                $extra['chat_hw'] = (int)end($rows)['id'];
            }
        }
        $top = $ctx->topScreen();
        $sysopDirect = array_values(array_filter(
            $rows,
            fn(array $r) => (string)$r['kind'] === 'sysop' && (int)($r['to_node'] ?? -1) === (int)$sess['node']
        ));
        // Never break in over the full-screen editor (a draft would die) or
        // before login; hold the watermark so the line lands right after.
        $deferBreakIn = $ctx->user() === null || in_array($top, ['Composer', 'AutoMsg'], true);
        if ($sysopDirect !== [] && $deferBreakIn && isset($extra['chat_hw'])) {
            $extra['chat_hw'] = min((int)$extra['chat_hw'], (int)$sysopDirect[0]['id'] - 1);
        }
        if ($top === 'PageSysop') {
            // The classic wait: every tick animates the ring; a sysop answer
            // swaps the caller onto the private line (the screen handles it).
            $ps = ScreenEngine::make('PageSysop');
            if ($ps instanceof \Atdt\Screens\PageSysopScreen) {
                $frames = $ps->onPoll($ctx, $rows);
                persist($db, $ctx, $sess);
            }
        } elseif ($top === 'SysopChat') {
            $sc = ScreenEngine::make('SysopChat');
            $frames = $sc instanceof \Atdt\Screens\SysopChatScreen
                ? $sc->asyncFrames($ctx, $rows)
                : [];
            persist($db, $ctx, $sess);
        } elseif ($sysopDirect !== [] && !$deferBreakIn) {
            // BREAK-IN (owner round 3): a console line aimed at this node
            // interrupts whatever the caller is doing — the private line
            // pushes on top with the message on screen and says out loud
            // that the sysop broke in (1.5.5); an empty line returns them.
            $frames = $ctx->push('SysopChat', ['note' => 'breakin']);
            array_unshift($frames, $ctx->bell());
            persist($db, $ctx, $sess);
        } elseif ($top === 'Chat' && $rows !== []) {
            $chat = ScreenEngine::make('Chat');
            $frames = $chat instanceof \Atdt\Screens\ChatScreen
                ? $chat->asyncFrames($ctx, $rows)
                : [];
            persist($db, $ctx, $sess);
        }
        if ($ctx->chatHw !== null) {
            $extra['chat_hw'] = max((int)($extra['chat_hw'] ?? 0), $ctx->chatHw);
        }
        // A level-255 caller hears the page bell anywhere on the board.
        if ($hw >= 0 && (int)(($ctx->user() ?? [])['level'] ?? 0) === 255) {
            $pages = $db->all("SELECT * FROM chat_msgs WHERE id > ? AND kind = 'page' ORDER BY id", [$hw]);
            if ($pages !== []) {
                $last = end($pages);
                $extra['chat_hw'] = max((int)($extra['chat_hw'] ?? 0), (int)$last['id']);
                $frames[] = $ctx->bell();
                $frames[] = $ctx->ansi(
                    "\x1b[s\x1b[25;1H\x1b[K"
                    . $ctx->tstr('page.paged_notice', [
                        'PB' => \Atdt\Pipe::escape((string)$last['from_handle'] . ' (node ' . (int)$last['from_node'] . ')'),
                    ])
                    . "\x1b[u"
                );
            }
        }
        // Systemwide banner (owner round 3): the client shows it as a strip
        // above the terminal and hides it when the clock runs out.
        $bUntil = (int)($settings->get('broadcast_until', '0') ?? 0);
        if ($bUntil > time()) {
            $extra['banner'] = [
                'id' => (string)($settings->get('broadcast_id', '0') ?? '0'),
                // The overlay is plain text (no pipe rendering), but MCI
                // expands per caller (1.1.1): "back at %TM, %HN" works.
                'text' => \Atdt\Pipe::vars((string)($settings->get('broadcast_msg', '') ?? ''), $ctx->mciVars()),
                'secs' => $bUntil - time(),
            ];
        }
        respond($frames, $extra);

    case 'logoff':
        $ctx = makeCtx($db, $settings, $sess);
        respond($ctx->hangup());

    // ---- DOS doors (SPEC-DOSDOORS §9): the server stores bytes,
    // arbitrates locks, and counts. The emulator is the caller's tab.
    case 'dosdoor.open':
    case 'dosdoor.ping':
    case 'dosdoor.close':
    case 'dosfs':
        if (!\Atdt\Dos\Doors::on($settings)) {
            http_response_code(404);
            respond([], ['error' => 'not_found']);
        }
        $ctx = makeCtx($db, $settings, $sess);
        $dosUser = $ctx->user();
        if ($dosUser === null) {
            http_response_code(403);
            respond([], ['error' => 'login_first']);
        }
        if ($action === 'dosdoor.open') {
            $r = \Atdt\Dos\Sessions::open(
                $db,
                $settings,
                $dosUser,
                (int)$sess['node'],
                (string)($in['slug'] ?? '')
            );
            respond([], $r);
        }
        $dosSess = \Atdt\Dos\Sessions::byToken($db, (string)($in['t'] ?? ''), (int)$dosUser['id']);
        if ($dosSess === null) {
            atdt_log_event('security', 'dosdoor', 'Token mismatch from ' . clientIp());
            http_response_code(403);
            respond([], ['error' => 'bad_token']);
        }
        if ($action === 'dosdoor.ping') {
            respond([], ['ok' => true, 'minutes' => \Atdt\Dos\Sessions::ping($db, $dosSess),
                'gen' => \Atdt\Dos\Fs::gen((string)$dosSess['slug'])]);
        }
        if ($action === 'dosdoor.close') {
            // The client says why it hung up; the log is where a SysOp
            // pieces together a door that ends without a word on screen.
            $why = preg_replace('/[^a-z]/', '', (string)($in['reason'] ?? ''));
            if ($why !== '') {
                atdt_log_event('info', 'dosdoor:' . (string)$dosSess['slug'],
                    'Client hangup: ' . mb_strimwidth($why, 0, 12));
            }
            \Atdt\Dos\Sessions::close($db, (string)$dosSess['token']);
            respond([], ['ok' => true]);
        }
        // dosfs: one op, or a batch of them, stateless either way.
        $slug = (string)$dosSess['slug'];
        $sid = (int)$dosSess['id'];
        $db->q('UPDATE dos_sessions SET last_seen = ? WHERE id = ?', [now_utc(), $sid]);
        $t0 = microtime(true);
        if (is_array($in['ops'] ?? null)) {
            $results = [];
            foreach (array_slice($in['ops'], 0, 64) as $one) {
                if (!is_array($one)) {
                    continue;
                }
                $results[] = \Atdt\Dos\Fs::op($db, $slug, $sid, (string)($one['op'] ?? ''), $one);
            }
            $r = ['ok' => true, 'results' => $results];
        } else {
            $r = \Atdt\Dos\Fs::op($db, $slug, $sid, (string)($in['op'] ?? ''), $in);
        }
        $ms = (int)round((microtime(true) - $t0) * 1000);
        if ($ms > 500) {
            // §14: once per session, not per op — the flag rides the row.
            if ((int)$db->val('SELECT COUNT(*) FROM events WHERE actor = ? AND msg LIKE ?',
                ["dosdoor:$slug", "Slow op%session $sid%"]) === 0) {
                atdt_log_event('warn', "dosdoor:$slug", "Slow op: {$ms}ms (session $sid)");
            }
        }
        \Atdt\Dos\Diag::note($db, $slug, (string)($in['op'] ?? 'batch'), $ms);
        respond([], $r);

    case 'key':
    case 'line':
    case 'editor':
        $ctx = makeCtx($db, $settings, $sess);
        $input = match ($action) {
            'key' => In::key((string)($in['key'] ?? '')),
            'line' => In::line(isset($in['line']) && $in['line'] !== null ? (string)$in['line'] : null),
            default => In::editor(is_array($in['lines'] ?? null) ? $in['lines'] : null),
        };
        $frames = ScreenEngine::dispatch($ctx, $input);
        persist($db, $ctx, $sess);
        $extra = [];
        if ($ctx->chatHw !== null) {
            $extra['chat_hw'] = $ctx->chatHw; // e.g. entering chat fast-forwards
        }
        if ($ctx->tokenChanged || $ctx->prefsChanged) {
            $user = $ctx->user() ?? [];
            $prefs = json_decode((string)($user['prefs'] ?? '{}'), true);
            $extra['session'] = array_filter([
                'token' => $ctx->tokenChanged ? $ctx->session()['token'] : null,
                'handle' => (string)($user['handle'] ?? ''),
                'prefs' => is_array($prefs) ? $prefs : [],
            ], fn($v) => $v !== null);
        }
        respond($frames, $extra);

    default:
        respond([], ['error' => 'unknown_action']);
}

// ---------------------------------------------------------------------------

function makeCtx(\Atdt\Db $db, \Atdt\Settings $settings, array $sess): Ctx
{
    // The board wears the sysop-chosen default (1.1.0); a caller with a
    // theme picked on their account overrides it for themselves.
    $themeName = $settings->get('default_theme', 'default');
    if (!empty($sess['user_id'])) {
        $t = $db->val('SELECT theme FROM users WHERE id = ?', [(int)$sess['user_id']]);
        if (is_string($t) && $t !== '') {
            $themeName = $t;
        }
    }
    return new Ctx($db, $settings, new Theme($themeName), $sess);
}

function ipBanned(string $ip, \Atdt\Settings $settings): bool
{
    return BbsSession::ipBanned($ip, $settings);
}

/** Persist screen-stack + session mutations after a dispatch. */
function persist(\Atdt\Db $db, Ctx $ctx, array $sess): void
{
    if ($ctx->disconnected) {
        return; // session already closed by hangup()
    }
    $db->q('UPDATE sessions SET state = ? WHERE id = ?', [$ctx->stateJson(), (int)$sess['id']]);
}

/** @return array<string, mixed> */
function readInput(): array
{
    $raw = file_get_contents('php://input');
    if (is_string($raw) && $raw !== '' && $raw[0] === '{') {
        $decoded = json_decode($raw, true);
        if (is_array($decoded)) {
            return $decoded;
        }
    }
    return $_POST;
}

function clientIp(): string
{
    return substr((string)($_SERVER['REMOTE_ADDR'] ?? 'local'), 0, 45);
}

/** @param array<int, array<string, mixed>> $frames */
function respond(array $frames, array $extra = []): never
{
    $body = json_encode(
        ['frames' => array_values($frames), 'events' => []] + $extra,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    );
    // §18: frames are small text; a screen that blows the ~64 KB budget
    // is a bug worth a paper trail, not a hard failure for the caller.
    // The DOS drive is the exception: dosfs replies carry file data and
    // are deliberately sized in filter-friendly pieces up to ~512 KB
    // (a prefetch batch runs ~175 KB by design), so their budget is the
    // point past which the CLIENT is misbehaving - logging each big
    // transfer buried the events page under a boot's worth of noise.
    $budget = ($GLOBALS['action'] ?? '') === 'dosfs' ? 614400 : 65536;
    if (strlen((string)$body) > $budget) {
        atdt_log_event('perf', 'api', 'Oversize envelope: ' . strlen((string)$body) . ' bytes');
    }
    echo $body;
    exit;
}
