<?php

declare(strict_types=1);

namespace Atdt\Screens;

use Atdt\Ansi;
use Atdt\Chat;
use Atdt\Ctx;
use Atdt\In;
use Atdt\Screen;

/**
 * The private line (owner round 3): a two-way conversation between ONE
 * caller and the sysop at the console. Nobody else sees a word of it.
 * Callers land here when the sysop answers a page or breaks into their
 * session from the dashboard; api.php delivers new lines via poll while
 * this screen is up. Empty line or ESC hangs it up — the caller returns
 * to whatever they were doing underneath.
 */
final class SysopChatScreen implements Screen
{
    private const TOP = 2;
    private const ROWS = 19;      // scroll region rows 2..20 (classic)
    private const ROW_HINT = 22;
    private const ROW_IN = 23;
    // Split style (1.6.0): the caller's own words on the top half, the
    // SysOp's on the bottom, a labelled rule between - the convention
    // half the later boards used.
    private const SPLIT_ROWS = 9;  // each pane: 2..10 and 12..20
    private const SPLIT_DIV = 11;

    public function describe(): string
    {
        return 'On the private line with the sysop';
    }

    /** The board's chosen style, pinned per conversation at enter(). */
    private function split(Ctx $c): bool
    {
        return (string)$c->get('style', 'classic') === 'split';
    }

    public function enter(Ctx $c): array
    {
        $style = (string)($c->settings()->get('chat_style', 'classic') ?? 'classic');
        $c->set('style', $style === 'split' ? 'split' : 'classic');
        // Backfill THIS session's talk only: nodes recycle, and the
        // previous caller's break-in is not this caller's business.
        $rows = Chat::recentSysop($c->db(), $c->node(), self::ROWS, (string)($c->session()['created_at'] ?? ''));
        $fmt = fn(array $r) => Chat::format($r, $c->node(), false, $c->tz());
        $lines = array_map($fmt, $rows);
        // How the line opened (1.5.5): a page answered or a cold break-in
        // gets said out loud in the scrollback, so the caller knows what
        // just happened instead of staring at a screen that appeared. The
        // note slots in BEFORE the console line(s) that opened the talk,
        // so the story reads top to bottom.
        $note = (string)$c->get('note', '');
        if ($note === 'answered' || $note === 'breakin') {
            $cut = count($rows);
            while ($cut > 0 && (int)$rows[$cut - 1]['from_node'] === 0) {
                $cut--;
            }
            array_splice($lines, $cut, 0, [$c->theme()->str('sysopchat.' . $note)]);
            $c->set('note', '');
        }
        $c->set('lines', array_slice($lines, -self::ROWS));
        if ($this->split($c)) {
            // The same history, dealt into the two panes: the caller's
            // own rows above, everything from the console below (the
            // opening note included).
            $mine = [];
            $theirs = [];
            if ($note === 'answered' || $note === 'breakin') {
                $theirs[] = $c->theme()->str('sysopchat.' . $note);
            }
            foreach ($rows as $r) {
                if ((int)$r['from_node'] === $c->node()) {
                    $mine[] = $fmt($r);
                } else {
                    $theirs[] = $fmt($r);
                }
            }
            $c->set('mine', array_slice($mine, -self::SPLIT_ROWS));
            $c->set('theirs', array_slice($theirs, -self::SPLIT_ROWS));
        }
        $c->set('sysgone', 0);
        $c->chatHw = Chat::maxId($c->db());
        $c->set('hw', $c->chatHw);

        $title = ' ' . mb_strimwidth(\Atdt\Pipe::strip($c->tstr('sysopchat.title')), 0, 77);
        $o = $c->out()->cls();
        $o->at(1, 1)->bg(4)->fg(15)
            ->txt($title . str_repeat(' ', max(0, 79 - mb_strlen($title))))->reset();
        if ($this->split($c)) {
            $o->txt($this->divider($c));
            $o->txt($this->pane($c, (array)$c->get('mine', []), self::TOP));
            $o->txt($this->pane($c, (array)$c->get('theirs', []), self::SPLIT_DIV + 1));
        } else {
            $o->txt($this->region($c));
        }
        $o->at(21, 2)->fg(8)->txt(str_repeat('─', 78))->reset();
        $o->at(self::ROW_HINT, 1)->txt($c->center($c->tstr('sysopchat.hint')));
        $mode = $c->promptLine($o, self::ROW_IN, 2, 'chat.prompt', Chat::MAX_BODY);
        return [$c->ansi($o->str()), $mode];
    }

    public function input(Ctx $c, In $in): array
    {
        if ($in->kind !== 'line') {
            return [$c->bell()];
        }
        $body = Chat::clean((string)$in->line);
        if ($in->line === null || $body === '') {
            return $c->pop(); // back to whatever they were doing
        }
        // The caller's side of the line: a sysop-kind row aimed at the
        // console (to_node 0). Other nodes never receive it (Chat::since
        // matches only this node); the dashboard pane threads it live.
        Chat::post(
            $c->db(),
            $c->node(),
            (string)(($c->user() ?? [])['handle'] ?? 'caller'),
            $body,
            'sysop',
            0
        );
        $c->chatHw = Chat::maxId($c->db());
        $c->set('hw', $c->chatHw);
        $echo = Chat::format([
            'at' => now_utc(), 'kind' => 'sysop',
            'from_node' => $c->node(),
            'from_handle' => (string)(($c->user() ?? [])['handle'] ?? 'caller'),
            'body' => $body,
        ], $c->node(), false, $c->tz());
        $lines = (array)$c->get('lines', []);
        $lines[] = $echo;
        $c->set('lines', array_slice($lines, -self::ROWS));

        $col = $c->promptEnd(2, 'chat.prompt', max: Chat::MAX_BODY);
        if ($this->split($c)) {
            $mine = (array)$c->get('mine', []);
            $mine[] = $echo;
            $c->set('mine', array_slice($mine, -self::SPLIT_ROWS));
            $o = Ansi::make()->txt($this->paneAppend($c, [$echo], self::TOP))
                ->at(self::ROW_IN, $col)->txt("\x1b[K");
            return [$c->ansi($o->str()), $c->line($col, self::ROW_IN, min(Chat::MAX_BODY, 81 - $col))];
        }
        [$ansi, $drows] = ChatScreen::appendRows($c, [$echo], self::TOP, self::ROWS, (int)$c->get('drows', self::ROWS));
        $c->set('drows', $drows);
        $o = Ansi::make()->txt($ansi)
            ->at(self::ROW_IN, $col)->txt("\x1b[K");
        return [$c->ansi($o->str()), $c->line($col, self::ROW_IN, min(Chat::MAX_BODY, 81 - $col))];
    }

    /**
     * Poll delivery while the private line is up: sysop-kind rows for this
     * node, the console's hang-up marker, and the console heartbeat (1.5.5)
     * all land here. ANSI + bell, no mode frame (§9.6).
     *
     * 'sysgone' state: 0 = the sysop is on the line, 1 = the console went
     * quiet (heartbeat stale, they may be back), 2 = the console hung this
     * line up on purpose. Each transition says so in the scrollback.
     * @param list<array<string, mixed>> $rows
     */
    public function asyncFrames(Ctx $c, array $rows): array
    {
        $hw = (int)$c->get('hw', 0);
        $lines = (array)$c->get('lines', []);
        $gone = (int)$c->get('sysgone', 0);
        $fresh = []; // everything a poll delivers is the console's side
        foreach ($rows as $row) {
            if ((int)$row['id'] <= $hw) {
                continue;
            }
            $hw = (int)$row['id'];
            $kind = (string)$row['kind'];
            if ($kind === 'sysophup' && (int)($row['to_node'] ?? -1) === $c->node()) {
                // The console left this line on purpose: say so instead of
                // letting the caller keep talking to a dead wire.
                $fresh[] = $c->theme()->str('sysopchat.left');
                $gone = 2;
                continue;
            }
            if ($kind !== 'sysop' || (int)$row['from_node'] === $c->node()) {
                continue; // public chatter waits; own lines already echoed
            }
            if ($gone !== 0) {
                $fresh[] = $c->theme()->str('sysopchat.back');
                $gone = 0;
            }
            $fresh[] = Chat::format($row, $c->node(), false, $c->tz());
        }
        if ($fresh === [] && $gone === 0 && $this->consoleQuiet($c)) {
            // The dashboard pings settings while the sysop watches the wire;
            // ~20s of silence means they walked away from the console.
            $fresh[] = $c->theme()->str('sysopchat.away');
            $gone = 1;
        } elseif ($fresh === [] && $gone === 1 && !$this->consoleQuiet($c)) {
            // The heartbeat is fresh again: they are back at the console.
            $fresh[] = $c->theme()->str('sysopchat.back');
            $gone = 0;
        }
        $c->set('hw', $hw);
        $c->set('sysgone', $gone);
        if ($fresh === []) {
            return [];
        }
        $c->set('lines', array_slice(array_merge($lines, $fresh), -self::ROWS));
        if ($this->split($c)) {
            // Markers included, it all lands in the bottom pane (1.6.0);
            // only the fresh lines paint, the pane scrolls when full.
            $theirs = array_slice(array_merge((array)$c->get('theirs', []), $fresh), -self::SPLIT_ROWS);
            $c->set('theirs', $theirs);
            return [$c->ansi($this->paneAppend($c, $fresh, self::SPLIT_DIV + 1)), $c->bell()];
        }
        [$ansi, $drows] = ChatScreen::appendRows($c, $fresh, self::TOP, self::ROWS, (int)$c->get('drows', self::ROWS));
        $c->set('drows', $drows);
        return [$c->ansi($ansi), $c->bell()];
    }

    /** True when the console heartbeat is stale (nobody watching the wire). */
    private function consoleQuiet(Ctx $c): bool
    {
        $seen = (string)($c->settings()->get('console_seen_at', '') ?? '');
        if ($seen === '') {
            return false; // never armed (mid-upgrade board): no false alarms
        }
        $t = strtotime($seen);
        return $t !== false && time() - $t > 20;
    }

    /** The split rule (1.6.0): your half above, the SysOp's below. */
    private function divider(Ctx $c): string
    {
        $top = ' ' . \Atdt\Pipe::strip($c->tstr('sysopchat.split_top')) . ' ';
        $bottom = ' ' . \Atdt\Pipe::strip($c->tstr('sysopchat.split_bottom')) . ' ';
        $o = Ansi::make()->at(self::SPLIT_DIV, 1)->fg(8)->txt(str_repeat('─', 79))->reset();
        $o->at(self::SPLIT_DIV, 4)->txt($c->pipe('|08·|07' . $top . '|08·'));
        $col = max(5, 76 - mb_strlen($bottom));
        $o->at(self::SPLIT_DIV, $col)->txt($c->pipe('|08·|12' . $bottom . '|08·'));
        return $o->reset()->str();
    }

    /** Repaint one split pane: $rows lines starting at screen row $top. */
    private function pane(Ctx $c, array $lines, int $top): string
    {
        $rows = [];
        foreach ($lines as $line) {
            foreach (ChatScreen::wrap((string)$line, 78) as $r) {
                $rows[] = $r;
            }
        }
        $rows = array_slice($rows, -self::SPLIT_ROWS);
        $c->set($top === self::TOP ? 'mrows' : 'trows', count($rows));
        $o = Ansi::make();
        for ($i = 0; $i < self::SPLIT_ROWS; $i++) {
            $row = $top + $i;
            $o->at($row, 1)->txt("\x1b[K");
            if (isset($rows[$i])) {
                $o->at($row, 2)->txt($c->pipe($rows[$i]));
            }
        }
        return $o->reset()->str();
    }

    /**
     * Incremental pane append (1.6.0): only the fresh lines paint; a
     * full pane scrolls its own DECSTBM window instead of repainting
     * the half (the owner watched every line redraw a whole pane).
     * @param list<string> $fresh
     */
    private function paneAppend(Ctx $c, array $fresh, int $top): string
    {
        $key = $top === self::TOP ? 'mrows' : 'trows';
        [$ansi, $drows] = ChatScreen::appendRows($c, $fresh, $top, self::SPLIT_ROWS, (int)$c->get($key, self::SPLIT_ROWS));
        $c->set($key, $drows);
        return $ansi;
    }

    /** Repaint the scrollback region (rows 2..20) from d.lines. */
    private function region(Ctx $c): string
    {
        // Same fold as the public room (1.1.0): long lines wrap to up
        // to three rows instead of vanishing off the right edge.
        $rows = [];
        foreach ((array)$c->get('lines', []) as $line) {
            foreach (ChatScreen::wrap((string)$line, 78) as $r) {
                $rows[] = $r;
            }
        }
        $rows = array_slice($rows, -self::ROWS);
        $c->set('drows', count($rows));
        $o = Ansi::make();
        for ($i = 0; $i < self::ROWS; $i++) {
            $row = self::TOP + $i;
            $o->at($row, 1)->txt("\x1b[K");
            if (isset($rows[$i])) {
                $o->at($row, 2)->txt($c->pipe($rows[$i]));
            }
        }
        return $o->reset()->str();
    }
}
