<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Per-request screen context (SPEC §9.4): the only services screens see.
 * Owns the state stack, the current user, frame builders, and the session
 * mutations (login token regeneration, hangup) that api.php persists.
 */
final class Ctx
{
    /** @var list<array{screen: string, d: array<string, mixed>}> */
    private array $stack;
    private ?array $user;
    public bool $tokenChanged = false;
    public bool $disconnected = false;
    /** Chat watermark to hand the client (envelope chat_hw, §9.6). */
    public ?int $chatHw = null;
    /** Set when users.prefs changed so api.php sends the client a fresh copy. */
    public bool $prefsChanged = false;
    /** last_login as it stood BEFORE this call (bulletins-if-updated, §10.6). */
    public ?string $prevLastLogin = null;

    public function __construct(
        private Db $db,
        private Settings $settings,
        private Theme $theme,
        private array $session
    ) {
        $decoded = json_decode((string)($session['state'] ?? '[]'), true);
        $this->stack = [];
        foreach (is_array($decoded) ? $decoded : [] as $frame) {
            if (is_array($frame) && isset($frame['screen'])) {
                $this->stack[] = [
                    'screen' => (string)$frame['screen'],
                    'd' => is_array($frame['d'] ?? null) ? $frame['d'] : [],
                ];
            }
        }
        if ($this->stack === []) {
            $this->stack = [['screen' => 'Matrix', 'd' => []]];
        }
        $this->user = null;
        if (!empty($session['user_id'])) {
            $this->user = $db->one('SELECT * FROM users WHERE id = ?', [(int)$session['user_id']]);
        }
    }

    // ---- services -------------------------------------------------------

    public function db(): Db
    {
        return $this->db;
    }

    public function settings(): Settings
    {
        return $this->settings;
    }

    public function theme(): Theme
    {
        return $this->theme;
    }

    public function user(): ?array
    {
        return $this->user;
    }

    public function node(): int
    {
        return (int)($this->session['node'] ?? 0);
    }

    public function session(): array
    {
        return $this->session;
    }

    public function out(): Ansi
    {
        return Ansi::make();
    }

    /** Pipe-render arbitrary text with the current MCI variables (§8.5). */
    public function pipe(string $text, array $extraVars = []): string
    {
        return Pipe::render($text, $extraVars + $this->mciVars());
    }

    /** Pipe-render a theme string by key. */
    public function tstr(string $key, array $extraVars = []): string
    {
        return $this->pipe($this->theme->str($key), $extraVars);
    }

    /**
     * Substitute MCI variables only, no pipe rendering (§8.5, 1.1.1).
     * For text that is already ANSI (decoded .ans art) or that will be
     * carried inside a variable value (subjects), where a full pipe
     * render would be wrong.
     */
    public function mci(string $text): string
    {
        return Pipe::vars($text, $this->mciVars());
    }

    /** @var array<string, string>|null request-scoped cache (§18: queries once) */
    private ?array $mciCache = null;

    /** MCI variable set (§8.5). Cached per request; user mutations clear it. */
    public function mciVars(): array
    {
        if ($this->mciCache !== null) {
            return $this->mciCache;
        }
        try {
            $tz = new \DateTimeZone($this->settings->get('timezone', 'UTC') ?? 'UTC');
        } catch (\Throwable $e) {
            $tz = new \DateTimeZone('UTC');
        }
        $now = new \DateTimeImmutable('now', $tz);
        $u = $this->user;
        $area = $u === null ? null : Messages::currentArea($this->db, $u);
        $this->mciCache = [
            'HN' => (string)($u['handle'] ?? 'caller'),
            'LV' => (string)(int)($u['level'] ?? 0),
            'ND' => (string)$this->node(),
            'BN' => (string)($this->settings->get('board_name', 'ATDT') ?? 'ATDT'),
            'DT' => $now->format('d-M-y'),
            'TM' => $now->format('H:i'),
            'CT' => (string)$this->callsToday($now),
            'CA' => (string)$this->settings->int('total_calls'),
            'LC' => $this->lastCaller(),
            'UT' => (string)(int)$this->db->val("SELECT COUNT(*) FROM users WHERE status <> 'deleted'"),
            'AN' => (string)($area['name'] ?? ''),
            'AD' => (string)($area['descr'] ?? ''),
            'MC' => (string)(int)($area['total'] ?? 0),
            'VN' => ATDT_VERSION,
            'PH' => $this->boardPhone(),
            'BD' => $this->baudWord(),
        ];
        return $this->mciCache;
    }

    /** %BD (1.6.0): the caller's line speed in words — "FAST" when the
     *  throttle is off, the board default before a preference exists. */
    private function baudWord(): string
    {
        $u = $this->user;
        $baud = $u !== null
            ? (int)Users::getPref($this->db, $u, 'baud', (int)($this->settings->get('default_baud', '9600') ?? 9600))
            : (int)($this->settings->get('default_baud', '9600') ?? 9600);
        return $baud === 0 ? 'FAST' : (string)$baud;
    }

    // ---- state stack ----------------------------------------------------

    public function topScreen(): string
    {
        return $this->stack[count($this->stack) - 1]['screen'];
    }

    public function stackDepth(): int
    {
        return count($this->stack);
    }

    /** Read a value from the top frame's state bag. */
    public function get(string $k, mixed $default = null): mixed
    {
        return $this->stack[count($this->stack) - 1]['d'][$k] ?? $default;
    }

    /** Write a value into the top frame's state bag. */
    public function set(string $k, mixed $v): void
    {
        $this->stack[count($this->stack) - 1]['d'][$k] = $v;
    }

    /** Push a screen and return its enter() frames. */
    public function push(string $screen, array $d = []): array
    {
        $this->stack[] = ['screen' => $screen, 'd' => $d];
        return ScreenEngine::enterTop($this);
    }

    /** Pop the top screen; the revealed screen redraws. */
    public function pop(): array
    {
        if (count($this->stack) > 1) {
            array_pop($this->stack);
        } else {
            $this->stack = [['screen' => 'Matrix', 'd' => []]]; // never empty
        }
        return ScreenEngine::enterTop($this);
    }

    /** Replace the top screen. */
    public function swap(string $screen, array $d = []): array
    {
        array_pop($this->stack);
        return $this->push($screen, $d);
    }

    public function stateJson(): string
    {
        return json_encode($this->stack, JSON_UNESCAPED_UNICODE) ?: '[]';
    }

    // ---- frame builders -------------------------------------------------

    public function ansi(string $text): array
    {
        return ['t' => 'ansi', 'd' => $text];
    }

    /** Hotkey mode. $cursor=false also mutes the client's key echo:
     *  door screens own their layout and their own prompt marker, and
     *  the board's output rarely ENDS at a door's prompt (1.7.4). */
    public function hotkey(bool $cursor = true): array
    {
        return $cursor
            ? ['t' => 'mode', 'm' => 'hotkey']
            : ['t' => 'mode', 'm' => 'hotkey', 'cur' => 0];
    }

    /** Local line-input mode (§7.4). x/y are 1-based screen coordinates. */
    /** @param list<string> $fwd named keys the client forwards while typing (chat paging) */
    public function line(int $x, int $y, int $max, bool $mask = false, string $initial = '', int $attr = 15, array $fwd = []): array
    {
        // A field armed past column 80 paints nowhere and reads as dead
        // keys (1.5.5 owner report). Whatever upstream asked, pin it to
        // the grid.
        $x = max(1, min($x, 80));
        $frame = [
            't' => 'mode', 'm' => 'line',
            'max' => max(1, min($max, 81 - $x)), 'mask' => $mask ? '*' : '',
            'x' => $x, 'y' => $y, 'attr' => $attr, 'initial' => $initial,
        ];
        if ($fwd !== []) {
            $frame['fwd'] = $fwd;
        }
        return $frame;
    }

    /** Visible width of rendered ANSI text (prompt-relative placement). */
    public function visibleWidth(string $ansiText): int
    {
        $plain = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $ansiText) ?? $ansiText;
        return mb_strlen($plain);
    }

    /** A prompt keeps at least this many input cells on its row. */
    private const MIN_FIELD = 8;

    /**
     * Prompt placement that survives a long theme string. Theme strings
     * are sysop data — stale copies keep old wording and the Themes page
     * edits freely — so the screen's column is only a suggestion: slide
     * the prompt left until the field keeps min($max, MIN_FIELD) cells on
     * the row, and pin the field inside the grid even when the string
     * alone would overflow it. Strings that fit land exactly where the
     * screen asked, so well-formed themes never move.
     * @return array{0: int, 1: int} draw column, field column
     */
    private function promptCols(int $col, int $width, int $max): array
    {
        $reserve = max(1, min($max, self::MIN_FIELD));
        $col = max(1, min($col, 81 - $width - $reserve));
        return [$col, min($col + $width, 81 - $reserve)];
    }

    /**
     * Draw a themed prompt at (row, col) and return the line-mode frame with
     * input starting right AFTER the visible text. The client blanks the
     * whole input span on entry, so a hardcoded column that lands short
     * chews the prompt's tail — computing it here makes that impossible.
     * The span is clamped to the 80-column row, and an overlong theme
     * string slides the whole prompt left rather than wrapping over the
     * field (1.5.5 owner report: the mail room under a stale theme copy).
     */
    public function promptLine(
        Ansi $o,
        int $row,
        int $col,
        string $stringKey,
        int $max,
        bool $mask = false,
        string $initial = '',
        array $vars = [],
        array $fwd = []
    ): array {
        $prompt = $this->tstr($stringKey, $vars);
        [$col, $start] = $this->promptCols($col, $this->visibleWidth($prompt), $max);
        $o->at($row, $col)->txt($prompt);
        return $this->line($start, $row, max(1, min($max, 81 - $start)), mask: $mask, initial: $initial, fwd: $fwd);
    }

    /** Column right after a themed prompt drawn at $col (for clears and
     *  re-arms). Pass the field's $max so the answer matches promptLine's
     *  slide when the string runs long. */
    public function promptEnd(int $col, string $stringKey, array $vars = [], int $max = 1): int
    {
        return $this->promptCols($col, $this->visibleWidth($this->tstr($stringKey, $vars)), $max)[1];
    }

    public function bell(): array
    {
        return ['t' => 'bell'];
    }

    // ---- session mutations ----------------------------------------------

    /**
     * Attach a verified user: token regenerated at login (§19.3), call_log
     * row adopted, login bookkeeping run.
     * @param array<string, mixed> $user
     */
    public function loginUser(array $user): void
    {
        $this->prevLastLogin = $user['last_login'] === null ? null : (string)$user['last_login'];
        $user = Users::recordLoginSuccess($this->db, $this->settings, $user);
        // Remember the address for the console's user record (1.6.0): an
        // IP ban should start from the caller, not a dig through the logs.
        $ip = (string)($this->session['ip'] ?? '');
        if ($ip !== '') {
            $this->db->q('UPDATE users SET last_ip = ? WHERE id = ?', [$ip, (int)$user['id']]);
            $user['last_ip'] = $ip;
        }
        $newToken = bin2hex(random_bytes(32));
        $this->db->q(
            'UPDATE sessions SET user_id = ?, token = ? WHERE id = ?',
            [(int)$user['id'], $newToken, (int)$this->session['id']]
        );
        $this->db->q(
            'UPDATE call_log SET user_id = ?, handle = ? WHERE off_at IS NULL AND node = ? AND on_at = ?',
            [(int)$user['id'], (string)$user['handle'], $this->node(), (string)$this->session['created_at']]
        );
        $this->session['user_id'] = (int)$user['id'];
        $this->session['token'] = $newToken;
        $this->user = $user;
        $this->mciCache = null;
        $this->tokenChanged = true;
        $this->db->insert('events', [
            'at' => now_utc(), 'level' => 'info', 'actor' => 'node' . $this->node(),
            'msg' => $user['handle'] . ' logged in',
        ]);
    }

    /**
     * Close the call: goodbye art/strings + NO CARRIER + disconnect frame.
     * $body overrides the goodbye display (e.g. the themed time's-up screen).
     */
    public function hangup(string $reason = 'logoff', ?string $body = null): array
    {
        BbsSession::close($this->db, $this->settings, $this->session);
        $this->disconnected = true;
        if ($body === null) {
            $art = $this->theme->art('goodbye');
            if ($art !== null) {
                // Goodbye art gets a clean screen like every other art
                // display (1.5.5 fix): without the clear it flowed from
                // the logoff prompt's cursor and scrolled its own top
                // rows off. NO CARRIER follows a couple of rows under
                // the art (capped at 25), so short pieces read tight and
                // full-height pieces still never scroll.
                $row = min(25, Art::textRows($art) + 2);
                return [
                    $this->ansi(
                        Ansi::make()->cls()->str() . $this->mci($art)
                        . "\x1b[" . $row . ";1H\x1b[K" . $this->tstr('goodbye.nocarrier')
                    ),
                    ['t' => 'disconnect', 'reason' => $reason],
                ];
            }
            $body = Ansi::make()->cls()->nl(10)
                ->txt($this->center($this->tstr('goodbye.text')))->nl()
                ->str();
        }
        return [
            $this->ansi($body . "\r\n" . $this->tstr('goodbye.nocarrier') . "\r\n"),
            ['t' => 'disconnect', 'reason' => $reason],
        ];
    }

    /** Re-read the current user row (after time/bank/pref mutations). */
    public function refreshUser(): void
    {
        if (!empty($this->session['user_id'])) {
            $this->user = $this->db->one('SELECT * FROM users WHERE id = ?', [(int)$this->session['user_id']]);
        }
        $this->mciCache = null;
    }

    /** Center visible text on an 80-column row (ANSI-aware). */
    public function center(string $ansiText): string
    {
        $plain = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $ansiText) ?? $ansiText;
        $pad = max(0, intdiv(80 - mb_strlen($plain), 2));
        return str_repeat(' ', $pad) . $ansiText;
    }

    /** Board-timezone midnight as a UTC timestamp (rate limits, daily stats). */
    public function boardDayStartUtc(): string
    {
        return Cron::boardDayStartUtc($this->settings);
    }

    /** The board's display timezone. */
    public function tz(): \DateTimeZone
    {
        try {
            return new \DateTimeZone($this->settings->get('timezone', 'UTC') ?? 'UTC');
        } catch (\Throwable $e) {
            return new \DateTimeZone('UTC');
        }
    }

    /** A stored UTC stamp rendered in board time (all caller displays). */
    public function localStamp(string $utc, string $format = 'm-d H:i'): string
    {
        return atdt_board_stamp($utc, $format, $this->settings);
    }

    private function callsToday(\DateTimeImmutable $boardNow): int
    {
        $midnight = $boardNow->setTime(0, 0)->setTimezone(new \DateTimeZone('UTC'));
        return (int)$this->db->val(
            'SELECT COUNT(*) FROM call_log WHERE on_at >= ?',
            [$midnight->format('Y-m-d\TH:i:s\Z')]
        );
    }

    /** %PH: the sysop's board number, or the derived ceremony number. */
    private function boardPhone(): string
    {
        $phone = trim((string)$this->settings->get('board_phone', ''));
        if ($phone !== '') {
            return $phone;
        }
        $digits = sprintf('%07u', crc32((string)($this->settings->get('board_name', 'ATDT') ?? 'ATDT')) % 10000000);
        return substr($digits, 0, 3) . '-' . substr($digits, 3);
    }

    private function lastCaller(): string
    {
        // The caller BEFORE this call. This session's own call_log row is
        // adopted at login, so without excluding it the newest stamped row
        // is the current user and %LC greets everyone with their own name.
        // A caller's PREVIOUS call still counts: log on twice in a row and
        // the last caller really is you.
        $row = $this->db->one(
            'SELECT handle FROM call_log
              WHERE user_id IS NOT NULL
                AND NOT (off_at IS NULL AND node = ? AND on_at = ?)
              ORDER BY id DESC LIMIT 1',
            [$this->node(), (string)$this->session['created_at']]
        );
        return $row === null ? 'nobody yet' : (string)$row['handle'];
    }
}
