<?php

declare(strict_types=1);

namespace Atdt\Screens;

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

/**
 * The public chat room (SPEC §10.6): a persistent scrollback (stamps carry
 * the date) and an input row. New lines arrive via poll (§9.6): api.php
 * calls asyncFrames() while this screen is on top — those frames repaint
 * only the scroll region, never touch the input row, and send no mode
 * frame, so the caller's half-typed line survives untouched.
 *
 * PgUp/PgDn browse history (the room keeps its newest Cron::KEEP_CHAT
 * lines however old; age never empties it).
 * While browsing, live arrivals wait — PgDn back to the tail catches up.
 * Private sysop conversations live on SysopChatScreen, not here.
 */
final class ChatScreen implements Screen
{
    private const TOP = 2;
    private const ROWS = 19;      // scroll region rows 2..20
    private const ROW_HINT = 22;
    private const ROW_IN = 23;

    /**
     * Named keys the client forwards while the caller is typing (§7.4):
     * history paging must work mid-line. Arrows ride along because plenty
     * of keyboards (Mac laptops) have no PgUp/PgDn at all.
     */
    private const FWD_KEYS = ['PGUP', 'PGDN', 'UP', 'DOWN'];

    public function describe(): string
    {
        return 'In chat';
    }

    public function enter(Ctx $c): array
    {
        $c->set('lines', $this->tail($c));
        $c->set('offset', 0);
        // Fast-forward the client watermark so poll won't re-deliver these.
        // The state bag keeps its own copy ('hw'): the screen's authoritative
        // seen-id, guarding against re-delivery and send-time races.
        $c->chatHw = Chat::maxId($c->db());
        $c->set('hw', $c->chatHw);

        $title = ' ' . mb_strimwidth(\Atdt\Pipe::strip($c->tstr('chat.title')), 0, 77);
        $o = $c->out()->cls();
        $o->at(1, 1)->bg(3)->fg(0)
            ->txt($title . str_repeat(' ', max(0, 79 - mb_strlen($title))))->reset();
        $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('chat.hint')));
        $mode = $c->promptLine($o, self::ROW_IN, 2, 'chat.prompt', Chat::MAX_BODY, fwd: self::FWD_KEYS);
        return [$c->ansi($o->str()), $mode];
    }

    public function input(Ctx $c, In $in): array
    {
        if ($in->kind === 'key') {
            $key = strtoupper((string)$in->key);
            if ($key === 'PGUP' || $key === 'UP') {
                return $this->browse($c, 1);
            }
            if ($key === 'PGDN' || $key === 'DOWN') {
                return $this->browse($c, -1);
            }
            return [$c->bell()];
        }
        if ($in->kind !== 'line') {
            return [$c->bell()];
        }
        $body = Chat::clean((string)$in->line);
        if ($in->line === null || $body === '') {
            return $c->pop();
        }
        // Catch lines that arrived since the last poll BEFORE fast-forwarding
        // the watermark past them — otherwise sending would swallow them.
        $fresh = [];
        foreach (Chat::since($c->db(), $c->node(), (int)$c->get('hw', 0)) as $row) {
            if ((string)$row['kind'] === 'sysop' || (int)$row['from_node'] === $c->node()) {
                continue;
            }
            $fresh[] = Chat::format($row, $c->node(), $this->colors($c), $c->tz());
        }
        Chat::post($c->db(), $c->node(), (string)(($c->user() ?? [])['handle'] ?? 'caller'), $body);
        $c->chatHw = Chat::maxId($c->db());
        $c->set('hw', $c->chatHw);
        $fresh[] = Chat::format([
            'at' => now_utc(), 'kind' => 'chat',
            'from_node' => $c->node(),
            'from_handle' => (string)(($c->user() ?? [])['handle'] ?? 'caller'),
            'body' => $body,
        ], $c->node(), $this->colors($c), $c->tz());
        $lines = array_merge((array)$c->get('lines', []), $fresh);
        $c->set('lines', array_slice($lines, -self::ROWS));

        $col = $c->promptEnd(2, 'chat.prompt', max: Chat::MAX_BODY);
        if ((int)$c->get('offset', 0) > 0) {
            // Sending mid-history snaps back to the live tail: that IS a
            // page turn, so the one full repaint here is honest.
            $c->set('offset', 0);
            $o = Ansi::make()->txt($this->region($c))
                ->at(1, 60)->bg(3)->fg(0)->txt(str_repeat(' ', 20))->reset()
                ->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), fwd: self::FWD_KEYS)];
        }
        // The live tail only grows: paint the new lines, nothing else.
        [$ansi, $drows] = self::appendRows($c, $fresh, 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), fwd: self::FWD_KEYS)];
    }

    /**
     * Poll-delivered lines while this screen is live (§9.6). ANSI only —
     * no mode frame — so the client's local line editing is undisturbed.
     * Private sysop rows never render here (api.php breaks those in).
     * @param list<array<string, mixed>> $rows
     */
    public function asyncFrames(Ctx $c, array $rows): array
    {
        $hw = (int)$c->get('hw', 0);
        $fresh = [];
        foreach ($rows as $row) {
            if ((int)$row['id'] <= $hw) {
                continue; // already on screen (client watermark lagged)
            }
            $hw = (int)$row['id'];
            if ((string)$row['kind'] === 'sysop' || (int)$row['from_node'] === $c->node()) {
                continue; // private lines break in; own broadcasts already echoed
            }
            $fresh[] = Chat::format($row, $c->node(), $this->colors($c), $c->tz());
        }
        $c->set('hw', $hw);
        if ($fresh === []) {
            return []; // nothing new for the screen: no repaint, no bell
        }
        $c->set('lines', array_slice(array_merge((array)$c->get('lines', []), $fresh), -self::ROWS));
        if ((int)$c->get('offset', 0) > 0) {
            return [$c->bell()]; // browsing history: just the bell, no repaint
        }
        // Only the new lines paint (1.6.0): the window scrolls, the rest
        // of the screen and the caller's half-typed line stay untouched.
        [$ansi, $drows] = self::appendRows($c, $fresh, self::TOP, self::ROWS, (int)$c->get('drows', self::ROWS));
        $c->set('drows', $drows);
        return [$c->ansi($ansi), $c->bell()];
    }

    /** PgUp/PgDn through the room's history; offset 0 is the live tail. */
    private function browse(Ctx $c, int $dir): array
    {
        $offset = (int)$c->get('offset', 0) + $dir * self::ROWS;
        $maxBack = max(0, Chat::publicCount($c->db()) - self::ROWS);
        $offset = max(0, min($offset, $maxBack));
        $c->set('offset', $offset);
        if ($offset === 0) {
            $c->set('lines', $this->tail($c));
            $o = Ansi::make()->txt($this->region($c))
                ->at(1, 60)->bg(3)->fg(0)->txt(str_repeat(' ', 20))->reset();
            return [$c->ansi($o->str())];
        }
        $rows = Chat::history($c->db(), $offset, self::ROWS);
        $c->set('lines', array_map(
            fn(array $r) => Chat::format($r, $c->node(), $this->colors($c), $c->tz()),
            $rows
        ));
        $tag = ' history · PgDn newer ';
        $o = Ansi::make()->txt($this->region($c))
            ->at(1, 80 - mb_strlen($tag))->bg(3)->fg(0)->txt($tag)->reset();
        return [$c->ansi($o->str())];
    }

    /** @return list<string> the live tail, formatted */
    private function tail(Ctx $c): array
    {
        return array_map(
            fn(array $r) => Chat::format($r, $c->node(), $this->colors($c), $c->tz()),
            Chat::recent($c->db(), $c->node(), self::ROWS)
        );
    }

    private function colors(Ctx $c): bool
    {
        return $c->settings()->bool('allow_user_color', false);
    }

    /** Repaint the scrollback region (rows 2..20) from d.lines. */
    private function region(Ctx $c): string
    {
        // A long line wraps instead of vanishing off the right edge
        // (1.1.0, owner report): each chat line may take up to three
        // display rows, word-wrapped, colors carried across the fold.
        $rows = [];
        foreach ((array)$c->get('lines', []) as $line) {
            foreach (self::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();
    }

    /**
     * Paint ONLY freshly arrived lines into a scrollback window (1.6.0
     * owner report: a full 19-row repaint per chat line flashed the
     * whole window and crawled at throttled baud). $drows counts the
     * display rows already painted in [top .. top+height-1]; a full
     * window scrolls via the terminal's DECSTBM region instead of
     * repainting. Returns the ANSI and the new painted-row count.
     *
     * @param list<string> $newLines
     * @return array{0: string, 1: int}
     */
    public static function appendRows(Ctx $c, array $newLines, int $top, int $height, int $drows): array
    {
        $rows = [];
        foreach ($newLines as $line) {
            foreach (self::wrap((string)$line, 78) as $r) {
                $rows[] = $r;
            }
        }
        $rows = array_slice($rows, -$height); // only the tail can show
        $o = Ansi::make();
        $scrolled = false;
        foreach ($rows as $r) {
            if ($drows >= $height) {
                if (!$scrolled) {
                    $o->txt(sprintf("\x1b[%d;%dr", $top, $top + $height - 1));
                    $scrolled = true;
                }
                $o->txt("\x1b[1S");
                $row = $top + $height - 1;
            } else {
                $row = $top + $drows;
                $drows++;
            }
            $o->at($row, 1)->txt("\x1b[K");
            $o->at($row, 2)->txt($c->pipe($r));
        }
        if ($scrolled) {
            $o->txt("\x1b[r"); // the region back to full screen, always
        }
        return [$o->reset()->str(), $drows];
    }

    /**
     * Word-wrap a pipe-coded line to visible-width rows, at most $maxRows
     * of them (codes kept, the active color re-asserted after each fold,
     * continuations indented two columns). Overflow past the last row is
     * clipped with the '»' mark, exactly like clip().
     *
     * @return list<string>
     */
    public static function wrap(string $piped, int $maxVisible, int $maxRows = 3): array
    {
        // Words are runs of non-space text with their codes attached;
        // single spaces separate them.
        $words = [];
        $cur = ['s' => '', 'v' => 0];
        $flush = function () use (&$words, &$cur): void {
            if ($cur['s'] !== '') {
                $words[] = $cur;
            }
            $cur = ['s' => '', 'v' => 0];
        };
        $tokens = preg_split('/((?:\|\|)|(?:\|[0-9]{2}))/', $piped, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [];
        foreach ($tokens as $tok) {
            if ($tok === '||') {
                $cur['s'] .= '||';
                $cur['v']++;
                continue;
            }
            if (preg_match('/^\|[0-9]{2}$/', $tok)) {
                $cur['s'] .= $tok;
                continue;
            }
            foreach (preg_split('/( )/', $tok, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [] as $bit) {
                if ($bit === ' ') {
                    $flush();
                    $words[] = ['s' => ' ', 'v' => 1];
                    continue;
                }
                $cur['s'] .= $bit;
                $cur['v'] += mb_strlen($bit);
            }
        }
        $flush();

        $rows = [];
        $row = '';
        $vis = 0;
        $color = '';
        $lastColor = function (string $s) use (&$color): void {
            if (preg_match_all('/\|[0-9]{2}/', str_replace('||', '', $s), $m) && $m[0] !== []) {
                $n = (int)substr(end($m[0]), 1);
                if ($n <= 15) { // foreground only: backgrounds stay on their row
                    $color = end($m[0]);
                }
            }
        };
        $newRow = function () use (&$rows, &$row, &$vis, &$color): void {
            $rows[] = $row;
            $row = '  ' . $color;
            $vis = 2;
        };
        foreach ($words as $w) {
            $width = $maxVisible - (count($rows) + 1 === $maxRows ? 1 : 0); // room for » on the last row
            if ($vis + $w['v'] > $width && $vis > (count($rows) === 0 ? 0 : 2)) {
                if (count($rows) + 1 >= $maxRows) {
                    return [...$rows, $row . '|08»'];
                }
                $newRow();
                if ($w['s'] === ' ') {
                    continue; // the fold swallows the separator
                }
            }
            while ($vis + $w['v'] > $width) {
                // A single word longer than the row: hard-split it.
                $room = $width - $vis;
                $taken = self::clip($w['s'], $room + 1); // clip's » replaces the last char
                $row .= mb_substr($taken, 0, mb_strlen($taken) - 4); // drop the |08» tail
                $lastColor($row);
                if (count($rows) + 1 >= $maxRows) {
                    return [...$rows, $row . '|08»'];
                }
                // Re-derive the remainder by visible offset.
                $w = self::visSlice($w, $room);
                $newRow();
                $width = $maxVisible - (count($rows) + 1 === $maxRows ? 1 : 0);
            }
            $row .= $w['s'];
            $vis += $w['v'];
            $lastColor($w['s']);
        }
        $rows[] = $row;
        return $rows;
    }

    /**
     * The tail of a word after skipping $skip visible characters, codes
     * preserved. @param array{s: string, v: int} $w
     * @return array{s: string, v: int}
     */
    private static function visSlice(array $w, int $skip): array
    {
        $out = '';
        $vis = 0;
        $seen = 0;
        $tokens = preg_split('/((?:\|\|)|(?:\|[0-9]{2}))/', $w['s'], -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [];
        foreach ($tokens as $tok) {
            if (preg_match('/^\|[0-9]{2}$/', $tok)) {
                $out .= $tok;
                continue;
            }
            $units = $tok === '||' ? ['||'] : mb_str_split($tok);
            foreach ($units as $u) {
                if ($seen < $skip) {
                    $seen++;
                    continue;
                }
                $out .= $u;
                $vis++;
            }
        }
        return ['s' => $out, 'v' => $vis];
    }

    /**
     * Trim a pipe-coded line to a VISIBLE width (codes kept, '»' marks the
     * cut) so scrollback rows can never wrap into the next line.
     */
    public static function clip(string $piped, int $maxVisible): string
    {
        $out = '';
        $seen = 0;
        $tokens = preg_split('/((?:\|\|)|(?:\|[0-9]{2}))/', $piped, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [];
        foreach ($tokens as $tok) {
            if ($tok === '||' ) {
                if ($seen + 1 > $maxVisible - 1) {
                    return $out . '|08»';
                }
                $out .= '||';
                $seen++;
                continue;
            }
            if (preg_match('/^\|[0-9]{2}$/', $tok)) {
                $out .= $tok;
                continue;
            }
            $room = $maxVisible - $seen;
            if (mb_strlen($tok) >= $room) {
                return $out . mb_substr($tok, 0, max(0, $room - 1)) . '|08»';
            }
            $out .= $tok;
            $seen += mb_strlen($tok);
        }
        return $out;
    }
}
