<?php

declare(strict_types=1);

namespace Atdt\Screens;

use Atdt\Ansi;
use Atdt\BbsSession;
use Atdt\Ctx;
use Atdt\In;
use Atdt\Screen;
use Atdt\Users;

/**
 * The pre-login screen (SPEC §10.1): [L]ogin [N]ew user [G]oodbye.
 * Theme art slot "connect" replaces the generated banner; the prompt band
 * lives on rows 20–24 either way.
 */
final class MatrixScreen implements Screen
{
    private const ROW_PROMPT = 20;
    private const ROW_INPUT1 = 22;
    private const ROW_INPUT2 = 23;
    private const ROW_MSG = 24;
    private const COL_LABEL = 26;
    private const COL_INPUT = 38;

    public function describe(): string
    {
        return 'At the matrix';
    }

    public function enter(Ctx $c): array
    {
        $c->set('step', 'prompt');
        $o = $c->out()->cls();
        $art = $c->theme()->art('connect');
        if ($art !== null) {
            $o->txt($c->mci($art)); // %XX drawn into art expands (1.1.1); no user yet, so %HN is 'caller'
        } else {
            $this->banner($c, $o);
        }
        $o->at(self::ROW_PROMPT, 1)->txt($c->center($c->tstr('matrix.prompt')));
        $this->hintRow($c, $o);
        return [$c->ansi($o->str()), $c->hotkey()];
    }

    public function input(Ctx $c, In $in): array
    {
        // Board offline (1.7.0): the connect gate stops new callers, but a
        // session already parked at the matrix could still log in. Any
        // press while the board is closed gets the notice and a dead line
        // -- no exceptions, the sysop is in the files. Callers already
        // past the matrix are left alone by owner decree.
        if (BbsSession::offline($c->settings())) {
            return $this->offlineHangup($c);
        }
        if ($in->kind === 'line') {
            return $this->lineInput($c, $in->line);
        }
        if ($in->kind !== 'key') {
            return [$c->bell()];
        }
        if ($c->get('step', 'prompt') !== 'prompt') {
            // A stray hotkey mid-line-mode: repaint cleanly.
            return $this->enter($c);
        }
        $key = strtoupper((string)$in->key);
        switch ($key) {
            case '?':
                return $this->enter($c);
            case 'G':
                return $c->hangup();
            case 'L':
                $c->set('step', 'login_handle');
                $o = $c->out();
                $this->clearBand($o);
                $o->at(self::ROW_INPUT1, self::COL_LABEL)->txt($c->tstr('matrix.login_handle'));
                return [
                    $c->ansi($o->str()),
                    $c->line(self::COL_INPUT, self::ROW_INPUT1, 20),
                ];
            case 'N':
                if (($c->settings()->get('new_user_policy', 'approve') ?? 'approve') === 'closed') {
                    return $this->message($c, 'matrix.closed');
                }
                return $c->push('NewUserApp');
            case 'R':
                // Password reset by email (1.5.0): only offered on boards
                // where the SysOp switched outgoing mail on. R is only
                // ever advertised by the failed-password message, and at
                // that moment the board knows which handle just failed -
                // so one press of R mails that handle's link on the spot
                // (1.5.5 owner ruling). The answer names the handle, so a
                // typo'd one is visible; R again (or a cold R) opens the
                // ask, prefilled and editable.
                if (!\Atdt\Mailer::ready($c->settings())) {
                    return array_merge([$c->bell()], $this->message($c, 'matrix.invalid'));
                }
                $try = trim((string)$c->get('last_try', ''));
                if ($try !== '' && (string)$c->get('reset_fired', '') === '') {
                    $c->set('reset_fired', '1');
                    return $this->resetOutcome($c, $this->requestReset($c, $try), $try);
                }
                $c->set('step', 'reset_handle');
                $o = $c->out();
                $this->clearBand($o);
                $o->at(self::ROW_INPUT1, self::COL_LABEL)->txt($c->tstr('matrix.reset_ask'));
                $o->at(self::ROW_MSG, 1)->txt($c->center($c->tstr('matrix.reset_note')));
                return [
                    $c->ansi($o->str()),
                    $c->line(self::COL_INPUT, self::ROW_INPUT1, 20, initial: $try),
                ];
            default:
                return array_merge([$c->bell()], $this->message($c, 'matrix.invalid'));
        }
    }

    /** @param ?string $line */
    private function lineInput(Ctx $c, ?string $line): array
    {
        $step = $c->get('step', 'prompt');
        if ($step === 'login_handle') {
            $line = trim((string)$line);
            if ($line === '') {
                return $this->backToPrompt($c);
            }
            $c->set('step', 'login_pass');
            $c->set('handle', $line);
            $o = $c->out()
                ->at(self::ROW_INPUT2, self::COL_LABEL)->txt($c->tstr('matrix.login_pass'));
            return [
                $c->ansi($o->str()),
                $c->line(self::COL_INPUT, self::ROW_INPUT2, 40, mask: true),
            ];
        }
        if ($step === 'reset_handle') {
            $handle = trim((string)$line);
            if ($handle === '') {
                return $this->backToPrompt($c);
            }
            return $this->resetOutcome($c, $this->requestReset($c, $handle), $handle);
        }
        if ($step === 'login_pass') {
            if ($line === null) {
                return $this->backToPrompt($c);
            }
            $handle = (string)$c->get('handle', '');
            $res = Users::authenticate(
                $c->db(),
                $handle,
                $line,
                (string)($c->session()['ip'] ?? '?')
            );
            if ($res['code'] === 'ok') {
                // One handle, one node (1.1.0): a live session elsewhere
                // bars this login until it ends or goes silent.
                $other = Users::onlineNode($c->db(), (int)$res['user']['id'], (int)($c->session()['id'] ?? 0));
                if ($other === null) {
                    $c->loginUser($res['user']);
                    return $c->swap('PostLogin');
                }
                $res = ['code' => 'online', 'user' => null];
            }
            $key = match ($res['code']) {
                'throttled' => 'matrix.throttled',
                'pending' => 'matrix.pending',
                'locked' => 'matrix.locked',
                'online' => 'matrix.already_online',
                // With mail on, a failed password also offers the way back.
                default => \Atdt\Mailer::ready($c->settings())
                    ? 'matrix.badlogin_reset' : 'matrix.badlogin',
            };
            // Remember what they tried: one press of R resets exactly
            // this handle (and a fresh failure re-arms that shortcut).
            $c->set('last_try', $handle);
            $c->set('reset_fired', null);
            $frames = $this->backToPrompt($c);
            array_splice($frames, 1, 0, [$c->ansi(
                $c->out()->at(self::ROW_MSG, 1)->txt("\x1b[K")->txt($c->center($c->tstr($key)))->str()
            )]);
            return $frames;
        }
        return $this->backToPrompt($c);
    }

    /**
     * The one answer a reset ask ever gets, back at the prompt: neutral
     * and conditional no matter what happened - the matrix never
     * confirms which handles exist or carry a mailbox - but it names
     * the handle it acted on, so a typo is visible instead of a silent
     * dead end. The SysOp's own account is the one exception (1.5.5):
     * its handle is public on every board, and the silent refusal read
     * as a broken mailer to the one person who runs it.
     */
    private function resetOutcome(Ctx $c, string $kind, string $handle): array
    {
        $frames = $this->backToPrompt($c);
        array_splice($frames, 1, 0, [$c->ansi(
            $c->out()->at(self::ROW_MSG, 1)->txt("\x1b[K")
                ->txt($c->center($c->tstr(
                    $kind === 'sysop' ? 'matrix.reset_sysop' : 'matrix.reset_sent_for',
                    ['RH' => $handle]
                )))->str()
        )]);
        return $frames;
    }

    /** Arm and mail a reset link if everything lines up; log either way.
     *  Returns 'sysop' when the handle is a SysOp account (the matrix
     *  says so out loud), 'neutral' for every other outcome. */
    private function requestReset(Ctx $c, string $handle): string
    {
        $db = $c->db();
        $user = $db->one("SELECT * FROM users WHERE handle = ? AND status = 'active'", [$handle]);
        $kind = 'neutral';
        $why = 'no such active account';
        if ($user !== null) {
            if ((int)$user['level'] >= 255) {
                $why = 'SysOp accounts reset their own locks'; // owner ruling
                $kind = 'sysop';
            } elseif (!\Atdt\Mailer::plausible((string)$user['email'])) {
                $why = 'no plausible mailbox on file';
            } else {
                $token = Users::armReset($db, $user);
                if ($token === null) {
                    $why = 'throttled: a fresh link is already out';
                } else {
                    $board = (string)($c->settings()->get('board_name', 'ATDT') ?? 'ATDT');
                    $url = rtrim((string)($c->settings()->get('base_url', '') ?? ''), '/') . '/reset.php?t=' . $token;
                    $err = \Atdt\Mailer::send(
                        $db,
                        $c->settings(),
                        (string)$user['email'],
                        $board . ': password reset',
                        "Somebody at the login screen of $board asked to reset the password\n"
                        . 'for the handle ' . (string)$user['handle'] . ".\n\n"
                        . "If that was you, set a new password here. The link works once and\n"
                        . "dies twenty minutes from now:\n\n"
                        . '  ' . $url . "\n\n"
                        . "If it was not you, do nothing; the old password still stands.\n\n"
                        . '- ' . $board
                    );
                    $why = $err === null ? 'link sent' : ('send failed: ' . $err);
                }
            }
        }
        atdt_log_event('security', 'reset', "reset requested for '$handle': $why");
        return $kind;
    }

    /** Clean screen, the sysop's closed notice centered, NO CARRIER. */
    private function offlineHangup(Ctx $c): array
    {
        $o = $c->out()->cls();
        $row = 10;
        $msg = \Atdt\Pipe::render(BbsSession::offlineMsg($c->settings()));
        foreach (preg_split('/\r\n?|\n/', $msg) ?: [$msg] as $line) {
            $o->at($row++, 1)->txt($c->center($line));
        }
        $o->nl(2);
        return $c->hangup('offline', $o->str());
    }

    private function backToPrompt(Ctx $c): array
    {
        $c->set('step', 'prompt');
        $c->set('handle', null);
        $o = $c->out();
        $this->clearBand($o);
        $this->hintRow($c, $o);
        return [$c->ansi($o->str()), $c->hotkey()];
    }

    /** @return array<int, array<string, mixed>> */
    private function message(Ctx $c, string $stringKey): array
    {
        $o = $c->out()
            ->at(self::ROW_MSG, 1)->txt("\x1b[K")
            ->txt($c->center($c->tstr($stringKey)));
        $this->hintRow($c, $o);
        return [$c->ansi($o->str()), $c->hotkey()];
    }

    private function hintRow(Ctx $c, Ansi $o): void
    {
        $o->at(self::ROW_INPUT1, 1)->txt("\x1b[K")
            ->at(self::ROW_INPUT1, self::COL_LABEL)->txt($c->tstr('matrix.hint'));
    }

    private function clearBand(Ansi $o): void
    {
        foreach ([self::ROW_INPUT1, self::ROW_INPUT2, self::ROW_MSG] as $row) {
            $o->at($row, 1)->txt("\x1b[K");
        }
    }

    /** Generated fallback banner (§10.1): board name, node, version. */
    private function banner(Ctx $c, Ansi $o): void
    {
        $inner = 60;
        $left = intdiv(80 - ($inner + 2), 2) + 1;
        $name = mb_strimwidth((string)$c->mciVars()['BN'], 0, $inner - 2);
        $pad = fn(string $s): string => str_repeat(' ', max(0, intdiv($inner - mb_strlen($s), 2)));

        $o->at(3, $left)->fg(3)->txt('╔' . str_repeat('═', $inner) . '╗');
        $namePad = $pad($name);
        $o->at(4, $left)->fg(3)->txt('║')->fg(15)
            ->txt($namePad . $name . str_repeat(' ', $inner - mb_strlen($namePad . $name)))
            ->fg(3)->txt('║');
        $tag = $c->tstr('connect.tagline');
        $tagPlain = preg_replace('/\x1b\[[0-9;]*m/', '', $tag) ?? '';
        $tagPad = $pad($tagPlain);
        $o->at(5, $left)->fg(3)->txt('║')->txt($tagPad)->txt($tag)
            ->txt(str_repeat(' ', max(0, $inner - mb_strlen($tagPad . $tagPlain))))->fg(3)->txt('║');
        $o->at(6, $left)->fg(3)->txt('╚' . str_repeat('═', $inner) . '╝');
        $o->at(8, 1)->txt($c->center($c->tstr('connect.welcome')))->reset();
    }
}
