<?php

declare(strict_types=1);

namespace Atdt\Screens;

use Atdt\Ansi;
use Atdt\AreaScope;
use Atdt\Ctx;
use Atdt\In;
use Atdt\Messages;
use Atdt\Screen;
use Atdt\Users;

/**
 * Area change (SPEC §10.5): ACS-filtered list with totals and unread
 * counts, shelved under categories; picking persists in users.prefs so
 * the board remembers your area between calls.
 *
 * 1.5.5 (owner report): more boards than fit the screen used to fall
 * off the bottom with no way to reach them. The list is a scrolling
 * window now -- arrows walk a highlight one board at a time, PgUp/PgDn
 * leaf a windowful, loud "more above/below" rails say when there is
 * more, ENTER reads the highlighted board, and typing its number still
 * works like it always did. The highlight starts on the board you are
 * reading. Since 1.6.0 an arrow move inside the window repaints only
 * the two rows that changed (sub-9600 lines crawled under the full
 * repaint), and walking off an edge leafs a whole pageful.
 */
final class MsgAreasScreen implements Screen
{
    private const ROW_ABOVE = 6;    // the "more above" rail
    private const LIST_R0 = 7;      // window rows 7..19
    private const LIST_ROWS = 13;
    private const ROW_BELOW = 20;   // the "more below" rail
    private const ROW_IN = 21;
    private const ROW_DESCR = 22;   // the highlighted board's description (1.7.0)
    private const ROW_MSG = 24;

    /** Keys the client forwards while the number field is open (§7.4). */
    private const FWD_KEYS = ['PGUP', 'PGDN', 'UP', 'DOWN'];

    public function describe(): string
    {
        return 'Changing message areas';
    }

    /**
     * What this picker offers (1.6.5). Unfiltered: every readable area
     * that is `listed` -- the unlisted flag's one and only effect
     * (SPEC 6). Category-filtered (an areachange item's arg2): only that
     * category, unlisted INCLUDED -- the sysop hand-picked this list.
     * A category name that no longer matches falls back to unfiltered
     * and says so once in the events log.
     * @return list<array<string, mixed>>
     */
    private function pickable(Ctx $c): array
    {
        $areas = Messages::areasFor($c->db(), $c->user());
        $catName = trim((string)$c->get('category', ''));
        if ($catName !== '') {
            $cat = $c->db()->one(
                'SELECT * FROM message_categories WHERE LOWER(name) = LOWER(?) ORDER BY sort, id LIMIT 1',
                [$catName]
            );
            if ($cat !== null) {
                return array_values(array_filter(
                    $areas,
                    fn(array $a) => (int)$a['category_id'] === (int)$cat['id']
                ));
            }
            if (!(bool)$c->get('catwarned', false)) {
                $c->set('catwarned', true);
                $c->db()->insert('events', [
                    'at' => now_utc(), 'level' => 'warn', 'actor' => 'menus',
                    'msg' => 'Area change filtered to unknown category "' . mb_strimwidth($catName, 0, 30)
                        . '"; showing the full list',
                ]);
            }
        }
        return array_values(array_filter(
            $areas,
            fn(array $a) => (int)($a['listed'] ?? 1) === 1
        ));
    }

    /** The area this picker should mark as current: the invoking menu's
     *  scope when there is one (1.6.5), else the board's, as ever. */
    private function currentFor(Ctx $c): ?array
    {
        $scope = trim((string)$c->get('scope', ''));
        if ($scope !== '') {
            $tag = AreaScope::get($c->db(), $c->user(), $scope);
            $area = $tag === null ? null : AreaScope::byTag($c->db(), $tag);
            return AreaScope::readable($area, $c->user()) ? $area : null;
        }
        return Messages::currentArea($c->db(), $c->user());
    }

    public function enter(Ctx $c): array
    {
        $areas = $this->pickable($c);
        if ($areas === []) {
            $frames = $c->pop();
            $frames[] = $c->ansi(
                Ansi::make()->at(self::ROW_MSG, 1)->txt("\x1b[K")
                    ->txt($c->center($c->tstr('msg.no_area')))->str()
            );
            return $frames;
        }
        $current = $this->currentFor($c);
        $cur = 0;
        foreach ($areas as $i => $area) {
            if ((int)$area['id'] === (int)($current['id'] ?? 0)) {
                $cur = $i;
                break;
            }
        }
        [$rows, $areaRow] = $this->layout($areas);
        $top = $this->ensureTop($rows, $areaRow, $cur, 0);
        $c->set('cur', $cur);
        $c->set('top', $top);

        $o = $c->out()->cls();
        $c->set('descr_drawn', null); // fresh screen: the info line must repaint
        $o->at(2, 1)->txt($c->center($c->tstr('areas.title')));
        $o->at(3, 1)->fg(8)->txt($c->center(str_repeat('─', 50)))->reset();
        $o->at(5, 8)->txt($c->tstr('areas.columns'));
        $o->txt($this->paintList($c, $areas, $rows, $areaRow, $cur, $top));
        $mode = $c->promptLine($o, self::ROW_IN, 12, 'areas.pick', 3, fwd: self::FWD_KEYS);
        return [$c->ansi($o->str()), $mode];
    }

    public function input(Ctx $c, In $in): array
    {
        $areas = $this->pickable($c);
        if ($areas === []) {
            return $c->pop();
        }
        [$rows, $areaRow] = $this->layout($areas);
        $cur = max(0, min((int)$c->get('cur', 0), count($areas) - 1));
        $top = (int)$c->get('top', 0);

        if ($in->kind === 'key') {
            // Forwarded while the number field is open: arrows walk the
            // highlight (dragging the window one row past the edges),
            // PgUp/PgDn leaf a windowful. The reply is ANSI only -- the
            // field stays armed and a half-typed number survives.
            $key = strtoupper((string)$in->key);
            $maxTop = max(0, count($rows) - self::LIST_ROWS);
            if ($key === 'UP' || $key === 'DOWN') {
                $prev = $cur;
                $cur = max(0, min(count($areas) - 1, $cur + ($key === 'DOWN' ? 1 : -1)));
                $r = (int)($areaRow[$cur] ?? 0);
                if ($r < $top || $r >= $top + self::LIST_ROWS) {
                    // Walked off the window: leaf a pageful, like
                    // PgUp/PgDn, instead of dragging one row and
                    // repainting the whole window every keystroke.
                    $top = max(0, min($maxTop, $top + ($key === 'DOWN' ? self::LIST_ROWS : -self::LIST_ROWS)));
                    $top = $this->ensureTop($rows, $areaRow, $cur, $top);
                } else {
                    $nt = $this->ensureTop($rows, $areaRow, $cur, $top);
                    if ($nt === $top) {
                        // The window held still: two rows change, the
                        // rest of the screen is already right (1.6.0,
                        // owner report: sub-9600 lines crawled).
                        $c->set('cur', $cur);
                        return [$c->ansi($this->paintRows($c, $areas, $rows, $areaRow, $cur, $top, [$prev, $cur]))];
                    }
                    $top = $nt; // a shelf header slides into view
                }
            } elseif ($key === 'PGUP' || $key === 'PGDN') {
                $top = max(0, min($maxTop, $top + ($key === 'PGDN' ? self::LIST_ROWS : -self::LIST_ROWS)));
                $cur = $this->snapToWindow($areaRow, $cur, $top, $key === 'PGDN');
            } else {
                return [$c->bell()];
            }
            $c->set('cur', $cur);
            $c->set('top', $top);
            return [$c->ansi($this->paintList($c, $areas, $rows, $areaRow, $cur, $top))];
        }

        if ($in->kind !== 'line') {
            return [$c->bell()];
        }
        if ($in->line === null) {
            return $c->pop(); // ESC leaves without changing anything
        }
        $pick = trim((string)$in->line);
        if ($pick === '') {
            // ENTER on an empty field reads the highlighted board (1.5.5).
            return $this->choose($c, $areas, $cur);
        }
        $idx = (int)$pick - 1;
        if (!ctype_digit($pick) || !isset($areas[$idx])) {
            $o = Ansi::make()
                ->at(self::ROW_MSG, 1)->txt("\x1b[K")->txt($c->center($c->tstr('areas.bad_pick')))
                ->at(self::ROW_IN, $c->promptEnd(12, 'areas.pick', max: 3))->txt("\x1b[K");
            return [
                $c->bell(),
                $c->ansi($o->str()),
                $c->line($c->promptEnd(12, 'areas.pick', max: 3), self::ROW_IN, 3, fwd: self::FWD_KEYS),
            ];
        }
        return $this->choose($c, $areas, $idx);
    }

    /** Persist the pick and fall back to whoever opened the list. A
     *  scoped invocation writes the menu's own slot (1.6.5); scopeless
     *  writes the board's pref exactly as ever (HC-2). A 'then' hand-off
     *  continues into the command that needed an area picked (SPEC 5
     *  step 5) instead of popping. */
    private function choose(Ctx $c, array $areas, int $idx): array
    {
        $user = $c->user();
        $scope = trim((string)$c->get('scope', ''));
        if ($user !== null) {
            if ($scope !== '') {
                AreaScope::set($c->db(), $user, $scope, (string)$areas[$idx]['tag']);
            } else {
                Users::setPref($c->db(), $user, 'area', (int)$areas[$idx]['id']);
                $c->refreshUser();
            }
        }
        $then = (string)$c->get('then', '');
        if ($then !== '') {
            $d = ['area_id' => (int)$areas[$idx]['id'], 'scope' => $scope];
            if ($then === 'Composer') {
                $d['mode'] = 'post';
            }
            return $c->swap($then, $d);
        }
        $frames = $c->pop();
        $frames[] = $c->ansi(
            Ansi::make()->at(self::ROW_MSG, 1)->txt("\x1b[K")
                ->txt($c->center($c->tstr('areas.changed', [
                    'AR' => (string)$areas[$idx]['name'],
                    'AD' => (string)($areas[$idx]['descr'] ?? ''),
                ])))->str()
        );
        return $frames;
    }

    /**
     * The display plan: category headers (with a breath of air between
     * shelves) and area rows, as a flat list the window slides over.
     * A board with no categories stays the flat list it always was.
     * @return array{0: list<array{t: string, i: int, label: string}>, 1: array<int, int>}
     */
    private function layout(array $areas): array
    {
        $shelved = false;
        foreach ($areas as $area) {
            if (trim((string)($area['cat_name'] ?? '')) !== '') {
                $shelved = true;
                break;
            }
        }
        $rows = [];
        $areaRow = [];
        $prevShelf = false;
        foreach ($areas as $i => $area) {
            $shelf = trim((string)($area['cat_name'] ?? ''));
            if ($shelved && $shelf !== $prevShelf) {
                if ($prevShelf !== false) {
                    $rows[] = ['t' => 'gap', 'i' => 0, 'label' => ''];
                }
                $rows[] = ['t' => 'cat', 'i' => 0, 'label' => $shelf];
                $prevShelf = $shelf;
            }
            $areaRow[$i] = count($rows);
            $rows[] = ['t' => 'area', 'i' => $i, 'label' => ''];
        }
        return [$rows, $areaRow];
    }

    /** Slide the window the least distance that shows the highlight; a
     *  highlight sitting at the very top pulls its shelf header along. */
    private function ensureTop(array $rows, array $areaRow, int $cur, int $top): int
    {
        $r = (int)($areaRow[$cur] ?? 0);
        if ($r < $top) {
            $top = $r;
        } elseif ($r > $top + self::LIST_ROWS - 1) {
            $top = $r - self::LIST_ROWS + 1;
        }
        if ($top > 0 && $r === $top && (string)($rows[$top - 1]['t'] ?? '') === 'cat') {
            $top--;
        }
        return max(0, min($top, max(0, count($rows) - self::LIST_ROWS)));
    }

    /** After a page-leaf, keep the highlight on a visible board. */
    private function snapToWindow(array $areaRow, int $cur, int $top, bool $forward): int
    {
        $r = (int)($areaRow[$cur] ?? 0);
        if ($r >= $top && $r < $top + self::LIST_ROWS) {
            return $cur; // already in view
        }
        $visible = [];
        foreach ($areaRow as $i => $row) {
            if ($row >= $top && $row < $top + self::LIST_ROWS) {
                $visible[] = $i;
            }
        }
        if ($visible === []) {
            return $cur;
        }
        return $forward ? $visible[0] : $visible[count($visible) - 1];
    }

    /** Repaint rows 6..20: the rails and the window. ANSI only, so the
     *  open number field is never disturbed (§7.4, the chat idiom). */
    private function paintList(Ctx $c, array $areas, array $rows, array $areaRow, int $cur, int $top): string
    {
        $current = $this->currentFor($c);
        $o = Ansi::make();

        $above = 0;
        $below = 0;
        foreach ($areaRow as $row) {
            if ($row < $top) {
                $above++;
            } elseif ($row >= $top + self::LIST_ROWS) {
                $below++;
            }
        }
        $o->at(self::ROW_ABOVE, 1)->txt("\x1b[K");
        if ($above > 0) {
            $o->at(self::ROW_ABOVE, 1)
                ->txt($c->center($c->tstr('areas.more_above', ['NA' => (string)$above])));
        }

        for ($k = 0; $k < self::LIST_ROWS; $k++) {
            $screenRow = self::LIST_R0 + $k;
            $o->reset()->at($screenRow, 1)->txt("\x1b[K");
            $entry = $rows[$top + $k] ?? null;
            if ($entry === null || (string)$entry['t'] === 'gap') {
                continue;
            }
            if ((string)$entry['t'] === 'cat') {
                $label = (string)$entry['label'] === '' ? $c->tstr('areas.other') : (string)$entry['label'];
                $o->at($screenRow, 4)->txt($c->tstr('areas.cat', ['CN' => mb_strimwidth($label, 0, 40)]));
                continue;
            }
            $this->areaRowAnsi($o, $areas, $current, $screenRow, (int)$entry['i'], $cur);
        }

        $o->reset()->at(self::ROW_BELOW, 1)->txt("\x1b[K");
        if ($below > 0) {
            $o->at(self::ROW_BELOW, 1)
                ->txt($c->center($c->tstr('areas.more_below', ['NB' => (string)$below])));
        }
        return $o->reset()->str() . $this->descrAnsi($c, $areas, $cur);
    }

    /**
     * The highlighted board's description on its own quiet row (1.7.0):
     * dim, optional, and free -- a blank descr paints a blank row, so a
     * board that never filled them in looks exactly as it always did.
     * Skips the wire entirely when the line already on screen matches
     * (blank to blank being the common case).
     */
    private function descrAnsi(Ctx $c, array $areas, int $cur): string
    {
        $d = trim((string)($areas[$cur]['descr'] ?? ''));
        if ($d === (string)$c->get('descr_drawn', "\0")) {
            return '';
        }
        $c->set('descr_drawn', $d);
        $o = Ansi::make()->at(self::ROW_DESCR, 1)->txt("\x1b[K");
        if ($d !== '') {
            $o->at(self::ROW_DESCR, 6)->fg(8)->txt(mb_strimwidth($d, 0, 74))->reset();
        }
        return $o->str();
    }

    /** One board's row, exactly as paintList draws it. */
    private function areaRowAnsi(Ansi $o, array $areas, ?array $current, int $screenRow, int $i, int $cur): void
    {
        $area = $areas[$i];
        $isCur = $i === $cur;
        $reading = (int)$area['id'] === (int)($current['id'] ?? 0);
        $mark = $isCur ? '>' : ($reading ? '·' : ' ');
        $o->at($screenRow, 6)
            ->fg($isCur ? 14 : ((int)$area['unread'] > 0 ? 15 : 7))
            ->txt(sprintf(
                '%s %2d) %-10s %-30s %5d %7d',
                $mark,
                $i + 1,
                mb_strimwidth((string)$area['tag'], 0, 10),
                mb_strimwidth((string)$area['name'], 0, 30),
                (int)$area['total'],
                (int)$area['unread']
            ));
    }

    /** ANSI for just these board rows: the lightbar move (1.6.0). */
    private function paintRows(Ctx $c, array $areas, array $rows, array $areaRow, int $cur, int $top, array $which): string
    {
        $current = $this->currentFor($c);
        $o = Ansi::make();
        foreach (array_unique($which) as $i) {
            $r = (int)($areaRow[$i] ?? -1);
            if ($r < $top || $r >= $top + self::LIST_ROWS) {
                continue;
            }
            $this->areaRowAnsi($o, $areas, $current, self::LIST_R0 + ($r - $top), $i, $cur);
        }
        return $o->reset()->str() . $this->descrAnsi($c, $areas, $cur);
    }
}
