<?php

declare(strict_types=1);

namespace Atdt\Screens;

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

/**
 * Data-driven menus (SPEC §10.3). Render ladder: theme art slot → pipe
 * template → generated layout. Actions: GOTO pushes a submenu, BACK pops,
 * MODULE opens a registered screen (softly deferred while unbuilt), DOOR is
 * softly deferred until M8, ART shows a theme art file with a pause,
 * LOGOFF confirms then hangs up (§10.7).
 */
final class MenuScreen implements Screen
{
    private const ROW_MSG = 24;

    /** MODULE arg → [screen, initial d]. Complete for v1 (doors ride M8). */
    private const MODULES = [
        'mailbox' => ['MailBox', []],
        'areachange' => ['MsgAreas', []],
        'read' => ['MsgList', []],
        'post' => ['Composer', ['mode' => 'post']],
        'newscan' => ['NewScan', []],
        'oneliners' => ['Oneliners', []],
        'rumors' => ['Rumors', []],
        'automsg' => ['AutoMsg', []],
        'voting' => ['Voting', []],
        'bbslist' => ['BbsList', []],
        'bulletins' => ['Bulletins', []],
        'userlist' => ['UserList', []],
        'lastcallers' => ['LastCallers', []],
        'whosonline' => ['WhosOnline', []],
        'stats' => ['Stats', []],
        'usersettings' => ['UserSettings', []],
        'chat' => ['Chat', []],
        'pagesysop' => ['PageSysop', []],
    ];

    /** Modules whose second argument may target a message area (1.6.5). */
    public const TARGETABLE = ['read', 'post', 'newscan'];

    /** Registered module args, for the console's menu designer (§11.5).
     *  setarea and setareaback (1.6.5) are actions, not screens, so they
     *  ride beside the map rather than in it. */
    public static function moduleKeys(): array
    {
        return array_merge(array_keys(self::MODULES), ['setarea', 'setareaback']);
    }

    public function describe(): string
    {
        return 'Browsing the menus';
    }

    public function enter(Ctx $c): array
    {
        $c->set('step', 'prompt');
        $name = strtoupper((string)$c->get('menu', 'MAIN'));
        $data = MenuEngine::load($c->db(), $c->user(), $name, $c->settings());
        if ($data === null && $name !== 'MAIN') {
            $c->set('menu', 'MAIN'); // fail soft (§3.5)
            return $this->enter($c);
        }
        if ($data === null) {
            throw new \RuntimeException('MAIN menu missing from the database');
        }

        $menu = $data['menu'];
        $body = null;
        // Per-level backdrops (M7): the first variant, in sort order, whose
        // ACS the caller passes wins; the menu's own art slot is the
        // fallback. A missing file falls through to the next rung.
        $slots = [];
        foreach ($c->db()->all(
            'SELECT * FROM menu_art WHERE menu_id = ? ORDER BY sort, id',
            [(int)$menu['id']]
        ) as $variant) {
            if (\Atdt\Acs::check((string)$variant['acs'], $c->user())) {
                $slots[] = (string)$variant['art'];
                break;
            }
        }
        if (!empty($menu['art'])) {
            $slots[] = (string)$menu['art'];
        }
        $bandTop = 21;
        foreach ($slots as $slot) {
            // Menu art names gallery pieces (1.1.0); a theme-slot lookup
            // remains as the fallback for boards mid-upgrade.
            $art = \Atdt\Art::load($slot) ?? $c->theme()->art($slot);
            if ($art !== null) {
                $body = Ansi::make()->cls()->txt($c->mci($art))->str();
                // A short backdrop keeps the band close (1.5.5): one
                // blank row, then rule/status/prompt. Tall art keeps
                // the classic row-21 band, overlay and all.
                $bandTop = min(21, \Atdt\Art::textRows($art) + 2);
                break;
            }
        }
        if ($body === null && !empty($menu['template'])) {
            $body = Ansi::make()->cls()->str() . $c->pipe((string)$menu['template'], $this->scopeVars($c, $menu));
        }
        if ($body === null) {
            $body = MenuEngine::render($c, $menu, $data['items']);
        }
        $c->set('bandtop', $bandTop);
        return [$c->ansi($body . MenuEngine::promptBand($c, $bandTop)), $c->hotkey()];
    }

    /**
     * Inside a SCOPED menu, %AN and %MC speak for the scope's own current
     * area (1.6.5, owner ruling): a section menu that shows "Area: %AN"
     * must not name the board's unrelated legacy area. Scopeless menus
     * pass an empty map through -- their render is byte-identical (HC-1).
     * @param array<string, mixed> $menu
     * @return array<string, string>
     */
    private function scopeVars(Ctx $c, array $menu): array
    {
        if (trim((string)($menu['area_scope'] ?? '')) === '') {
            return [];
        }
        $area = \Atdt\AreaScope::resolveScoped($c->db(), $c->user(), $menu);
        return [
            'AN' => (string)($area['name'] ?? ''),
            'MC' => $area === null ? '0' : (string)\Atdt\Messages::count($c->db(), (int)$area['id']),
        ];
    }

    public function input(Ctx $c, In $in): array
    {
        if ($in->kind !== 'key') {
            return $this->enter($c); // stray line/editor payload: repaint
        }
        $key = strtoupper((string)$in->key);
        $step = (string)$c->get('step', 'prompt');

        if ($step === 'confirm_logoff') {
            if ($key === 'Y') {
                return $c->hangup();
            }
            $c->set('step', 'prompt');
            return [$c->ansi(MenuEngine::promptBand($c, (int)$c->get('bandtop', 21))), $c->hotkey()];
        }
        if ($step === 'pause_art') {
            return $this->enter($c);
        }
        if ($key === '?') {
            return $this->enter($c);
        }

        $data = MenuEngine::load($c->db(), $c->user(), strtoupper((string)$c->get('menu', 'MAIN')), $c->settings());
        $item = $data === null ? null : MenuEngine::match($data['items'], $key);
        if ($item === null) {
            return $this->notice($c, 'menu.invalid', bell: true);
        }
        return $this->run($c, $item, $data['menu']);
    }

    /** @param array<string, mixed> $item @param array<string, mixed> $menu */
    private function run(Ctx $c, array $item, array $menu): array
    {
        $arg = (string)($item['arg'] ?? '');
        switch ((string)$item['action']) {
            case 'GOTO':
                return $c->push('Menu', ['menu' => strtoupper($arg)]);
            case 'BACK':
                return $c->stackDepth() > 1 ? $c->pop() : $this->enter($c);
            case 'MODULE':
                $module = strtolower($arg);
                if ($module === 'setarea' || $module === 'setareaback') {
                    return $this->setArea($c, $item, $menu, back: $module === 'setareaback');
                }
                [$screen, $d] = self::MODULES[$module] ?? [null, []];
                if ($screen === null) {
                    return $this->notice($c, 'menu.soon');
                }
                return $this->openModule($c, $item, $menu, $module, $screen, $d);
            case 'DOOR':
                return $c->push('Door', ['slug' => strtolower($arg)]);
            case 'DOSDOOR':
                // SPEC-DOSDOORS §12: the frame hands the tab to the DOS
                // runner. The plain line before it is the fallback for a
                // client that does not know the frame kind; a client
                // that does clears the screen the moment it boots.
                if (!\Atdt\Dos\Doors::on($c->settings())
                    || \Atdt\Dos\Doors::bySlug($c->db(), strtolower($arg)) === null) {
                    return $this->notice($c, 'menu.soon');
                }
                // Park in the art-pause step: whatever key comes next -- an
                // old client's caller reading the fallback line, or the
                // runner's teardown key -- repaints the whole menu.
                $c->set('step', 'pause_art');
                return [
                    $c->ansi("\r\n" . $c->pipe('|07This door needs a newer terminal. Reload the page and try again.') . "\r\n"),
                    ['t' => 'dosdoor', 'slug' => strtolower($arg)],
                    $c->hotkey(),
                ];
            case 'ART':
                $art = \Atdt\Art::load($arg) ?? $c->theme()->art($arg);
                if ($art === null) {
                    return $this->notice($c, 'menu.soon');
                }
                $c->set('step', 'pause_art');
                // The pause follows the piece (1.5.5), capped at row 25.
                $pause = Ansi::make()->at(min(25, \Atdt\Art::textRows($art) + 2), 1)
                    ->txt($c->center($c->tstr('pause.more')))->str();
                return [$c->ansi(Ansi::make()->cls()->str() . $c->mci($art) . $pause), $c->hotkey()];
            case 'HELP':
                return $c->push('Help');
            case 'EMAILSYSOP':
                return $c->push('EmailSysop', ['to' => $arg]);
            case 'LOGOFF':
                $c->set('step', 'confirm_logoff');
                $promptRow = (int)$c->get('bandtop', 21) + 2;
                $o = Ansi::make()->at($promptRow, 1)->txt("\x1b[K")->at($promptRow, 7)
                    ->txt($c->tstr('logoff.confirm'));
                return [$c->ansi($o->str()), $c->hotkey()];
            default:
                return $this->notice($c, 'menu.invalid', bell: true);
        }
    }

    /**
     * Open a message-capable module with the 1.6.5 resolution order
     * (SPEC 5). The scopeless, untargeted press takes the exact push the
     * board has always made -- byte-identical, nothing consulted, nothing
     * written (HC-1/HC-2). Everything new happens only when a sysop set
     * arg2 or a menu scope.
     * @param array<string, mixed> $item @param array<string, mixed> $menu
     */
    private function openModule(Ctx $c, array $item, array $menu, string $module, string $screen, array $d): array
    {
        $arg2 = trim((string)($item['arg2'] ?? ''));
        if ($module === 'areachange') {
            $d2 = $d;
            if ($arg2 !== '') {
                $d2['category'] = $arg2; // filtered picker (SPEC 4.4)
            }
            $scope = trim((string)($menu['area_scope'] ?? ''));
            if ($scope !== '') {
                $d2['scope'] = $scope;   // the pick lands in this menu's slot
            }
            return $c->push($screen, $d2);
        }
        if (!in_array($module, self::TARGETABLE, true)) {
            return $c->push($screen, $d); // arg2 on anything else is ignored (SPEC 4.1)
        }
        if ($arg2 !== '') {
            // One-shot targeting: this press acts on the named area and
            // writes nothing anywhere (SPEC 4.2).
            $area = $this->targetArea($c, $item, $arg2);
            if (!is_array($area)) {
                return $area === null
                    ? $this->notice($c, 'menu.area_missing', bell: true)
                    : $this->notice($c, 'menu.area_denied', bell: true);
            }
            return $c->push($screen, $d + ['area_id' => (int)$area['id']]);
        }
        $scope = trim((string)($menu['area_scope'] ?? ''));
        if ($scope === '') {
            return $c->push($screen, $d); // step 2: the legacy path, untouched
        }
        $area = \Atdt\AreaScope::resolveScoped($c->db(), $c->user(), $menu);
        if ($area === null) {
            // Step 5: nothing selected and no default -- the picker, which
            // hands off into the asked-for command once an area is chosen.
            return $c->push('MsgAreas', ['scope' => $scope, 'then' => $screen]);
        }
        return $c->push($screen, $d + ['area_id' => (int)$area['id'], 'scope' => $scope]);
    }

    /**
     * Resolve an arg2 tag for this caller: the area row, or null when the
     * tag dangles (logged at warn so the sysop can diagnose the item,
     * SPEC 4.2), or false when Read ACS refuses (HC-4).
     * @param array<string, mixed> $item
     */
    private function targetArea(Ctx $c, array $item, string $tag): array|null|false
    {
        $area = \Atdt\AreaScope::byTag($c->db(), $tag);
        if ($area === null) {
            $menuName = strtoupper((string)$c->get('menu', 'MAIN'));
            $c->db()->insert('events', [
                'at' => now_utc(), 'level' => 'warn', 'actor' => 'menu:' . $menuName,
                'msg' => 'Menu ' . $menuName . ' key ' . (string)$item['hotkey']
                    . ' targets missing area "' . mb_strimwidth($tag, 0, 32) . '"',
            ]);
            return null;
        }
        return \Atdt\AreaScope::readable($area, $c->user()) ? $area : false;
    }

    /**
     * setarea and setareaback (1.6.5, SPEC 4.3): change the invoking
     * menu's current area. setarea stays on the menu (the combined
     * section-menu pattern); setareaback then steps BACK a menu, so a
     * hand-built area list -- ANSI art or generated -- behaves exactly
     * like the stock picker: pick, and you are returned to the menu you
     * came from. On a scoped menu the write lands in the scope's slot;
     * scopeless writes the board's current area, as the picker does.
     * Either way the menu repaints in full, so a template or backdrop
     * showing %AN/%MC tells the truth immediately.
     * @param array<string, mixed> $item @param array<string, mixed> $menu
     */
    private function setArea(Ctx $c, array $item, array $menu, bool $back = false): array
    {
        $arg2 = trim((string)($item['arg2'] ?? ''));
        $menuName = strtoupper((string)$c->get('menu', 'MAIN'));
        if ($arg2 === '') {
            // The console refuses this at save; a hand-edited row logs
            // once and the press does nothing (SPEC 4.3).
            $c->db()->insert('events', [
                'at' => now_utc(), 'level' => 'warn', 'actor' => 'menu:' . $menuName,
                'msg' => 'Menu ' . $menuName . ' key ' . (string)$item['hotkey'] . ' is a '
                    . ($back ? 'setareaback' : 'setarea') . ' item with no area',
            ]);
            return [$c->ansi(MenuEngine::promptBand($c, (int)$c->get('bandtop', 21))), $c->hotkey()];
        }
        $area = $this->targetArea($c, $item, $arg2);
        if (!is_array($area)) {
            return $area === null
                ? $this->notice($c, 'menu.area_missing', bell: true)
                : $this->notice($c, 'menu.area_denied', bell: true);
        }
        $scope = trim((string)($menu['area_scope'] ?? ''));
        $user = $c->user();
        if ($scope !== '') {
            \Atdt\AreaScope::set($c->db(), $user, $scope, (string)$area['tag']);
        } elseif ($user !== null) {
            \Atdt\Users::setPref($c->db(), $user, 'area', (int)$area['id']);
            $c->refreshUser();
        }
        // Repaint (pop for setareaback, stay for setarea), then the
        // standard area-selected confirmation on the fresh screen's row.
        $frames = $back && $c->stackDepth() > 1 ? $c->pop() : $this->enter($c);
        $row = min(self::ROW_MSG, (int)$c->get('bandtop', 21) + 3);
        $o = Ansi::make()->at($row, 1)->txt("\x1b[K")
            ->txt($c->center($c->tstr('areas.changed', ['AR' => (string)$area['name']])));
        $frames[] = $c->ansi($o->str());
        return $frames;
    }

    private function notice(Ctx $c, string $stringKey, bool $bell = false): array
    {
        $row = min(self::ROW_MSG, (int)$c->get('bandtop', 21) + 3);
        $o = Ansi::make()
            ->at($row, 1)->txt("\x1b[K")
            ->txt($c->center($c->tstr($stringKey)));
        $frames = [$c->ansi($o->str()), $c->hotkey()];
        return $bell ? array_merge([$c->bell()], $frames) : $frames;
    }
}
