<?php

declare(strict_types=1);

namespace Atdt;

/**
 * CP437 <-> Unicode tables, .ans loader, SAUCE handling (SPEC §9.3).
 *
 * Streams: bytes 0x00–0x1F stay controls (ESC sequences pass through
 * untouched); 0x20–0x7E are ASCII; 0x7F and 0x80–0xFF map to Unicode glyphs.
 * Full-glyph contexts (the M7 editor's 256-glyph picker) use toGlyph(), where
 * the control range maps to its classic CP437 pictures (☺♥♪ …).
 */
final class Cp437
{
    /** Unicode codepoints for CP437 bytes 0x80–0xFF. */
    private const HIGH = [
        0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, // 80-87
        0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, // 88-8F
        0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, // 90-97
        0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, // 98-9F
        0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, // A0-A7
        0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, // A8-AF
        0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, // B0-B7
        0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, // B8-BF
        0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, // C0-C7
        0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, // C8-CF
        0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, // D0-D7
        0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, // D8-DF
        0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, // E0-E7
        0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, // E8-EF
        0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, // F0-F7
        0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, // F8-FF
    ];

    /** Classic glyphs for control bytes 0x00–0x1F (full-glyph contexts only). */
    private const LOW_GLYPHS = [
        0x0020, 0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, // 00-07
        0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, // 08-0F
        0x25BA, 0x25C4, 0x2195, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, // 10-17
        0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC, // 18-1F
    ];

    private const DEL_GLYPH = 0x2302; // 0x7F ⌂

    /** @var array<int, string>|null byte → UTF-8 for stream decoding */
    private static ?array $streamTable = null;
    /** @var array<int, int>|null Unicode codepoint → CP437 byte */
    private static ?array $reverse = null;

    /** Decode one CP437 byte for a text STREAM (controls preserved). */
    public static function byteToUnicode(int $byte): string
    {
        self::init();
        return self::$streamTable[$byte & 0xFF];
    }

    /** Full-glyph mapping: every byte 0x00–0xFF gets a printable codepoint. */
    public static function toGlyph(int $byte): string
    {
        $byte &= 0xFF;
        if ($byte < 0x20) {
            return mb_chr(self::LOW_GLYPHS[$byte], 'UTF-8');
        }
        if ($byte === 0x7F) {
            return mb_chr(self::DEL_GLYPH, 'UTF-8');
        }
        return self::byteToUnicode($byte);
    }

    /** Decode a CP437 byte string to Unicode, controls/ESC preserved. */
    public static function toUnicode(string $bytes): string
    {
        self::init();
        $out = '';
        $n = strlen($bytes);
        for ($i = 0; $i < $n; $i++) {
            $out .= self::$streamTable[ord($bytes[$i])];
        }
        return $out;
    }

    /**
     * Encode Unicode text back to CP437 bytes (editor export, M7).
     * Unmappable codepoints become '?'.
     */
    public static function fromUnicode(string $text): string
    {
        self::init();
        $out = '';
        $len = mb_strlen($text, 'UTF-8');
        for ($i = 0; $i < $len; $i++) {
            $cp = mb_ord(mb_substr($text, $i, 1, 'UTF-8'), 'UTF-8');
            if ($cp < 0x80) {
                $out .= chr($cp);
            } else {
                $out .= chr(self::$reverse[$cp] ?? 0x3F);
            }
        }
        return $out;
    }

    /**
     * Load a .ans file's bytes: strip the SAUCE record (+ COMNT block) and
     * anything after 0x1A EOF, then map CP437 → Unicode with ESC sequences
     * untouched. Returns a string ready for an ansi frame.
     */
    public static function loadAns(string $bytes): string
    {
        [$body] = self::splitSauce($bytes);
        $eof = strpos($body, "\x1A");
        if ($eof !== false) {
            $body = substr($body, 0, $eof);
        }
        return self::toUnicode($body);
    }

    /** loadAns() for a file path; null when unreadable (fail soft, §3.5). */
    public static function loadAnsFile(string $path): ?string
    {
        if (!is_file($path)) {
            return null;
        }
        $bytes = @file_get_contents($path);
        return $bytes === false ? null : self::loadAns($bytes);
    }

    /**
     * Split raw bytes into [body, sauce|null] where sauce is the parsed
     * record: title, author, group, date, datatype, filetype, tinfo1..2,
     * comments (list of strings), flags.
     *
     * @return array{0: string, 1: ?array<string, mixed>}
     */
    public static function splitSauce(string $bytes): array
    {
        $len = strlen($bytes);
        if ($len < 128 || substr($bytes, $len - 128, 7) !== 'SAUCE00') {
            return [$bytes, null];
        }
        $rec = substr($bytes, $len - 128);
        $sauce = [
            'title' => rtrim(substr($rec, 7, 35), " \0"),
            'author' => rtrim(substr($rec, 42, 20), " \0"),
            'group' => rtrim(substr($rec, 62, 20), " \0"),
            'date' => substr($rec, 82, 8),
            'datatype' => ord($rec[94]),
            'filetype' => ord($rec[95]),
            'tinfo1' => self::u16($rec, 96),
            'tinfo2' => self::u16($rec, 98),
            'comments' => [],
            'flags' => ord($rec[105]),
        ];
        $body = substr($bytes, 0, $len - 128);
        $nComments = ord($rec[104]);
        if ($nComments > 0) {
            $blockLen = 5 + 64 * $nComments;
            if (strlen($body) >= $blockLen && substr($body, -$blockLen, 5) === 'COMNT') {
                $block = substr($body, -$blockLen + 5);
                for ($i = 0; $i < $nComments; $i++) {
                    $sauce['comments'][] = rtrim(substr($block, $i * 64, 64), " \0");
                }
                $body = substr($body, 0, -$blockLen);
            }
        }
        // A lone EOF byte conventionally sits between body and SAUCE.
        if (str_ends_with($body, "\x1A")) {
            $body = substr($body, 0, -1);
        }
        return [$body, $sauce];
    }

    /**
     * Build a SAUCE record (+ optional COMNT block) to append after body+EOF.
     * Used by the editor's .ans export (M7) and the test corpus generator.
     *
     * @param list<string> $comments
     */
    public static function buildSauce(
        string $title,
        string $author,
        string $group,
        string $date,           // CCYYMMDD
        int $tinfo1,            // width
        int $tinfo2,            // height
        int $flags = 0,         // bit 0 = iCE colors
        array $comments = []
    ): string {
        $out = "\x1A";
        if ($comments !== []) {
            $out .= 'COMNT';
            foreach ($comments as $c) {
                $out .= str_pad(substr($c, 0, 64), 64, ' ');
            }
        }
        $out .= 'SAUCE00'
            . str_pad(substr($title, 0, 35), 35, ' ')
            . str_pad(substr($author, 0, 20), 20, ' ')
            . str_pad(substr($group, 0, 20), 20, ' ')
            . str_pad(substr($date, 0, 8), 8, ' ')
            . pack('V', 0)                       // FileSize (0 = unspecified)
            . chr(1) . chr(1)                    // DataType=Character, FileType=ANSi
            . pack('v', $tinfo1) . pack('v', $tinfo2)
            . pack('v', 0) . pack('v', 0)        // TInfo3, TInfo4
            . chr(count($comments))
            . chr($flags)
            . str_pad('', 22, "\0");             // TInfoS
        return $out;
    }

    private static function u16(string $s, int $off): int
    {
        return ord($s[$off]) | (ord($s[$off + 1]) << 8);
    }

    private static function init(): void
    {
        if (self::$streamTable !== null) {
            return;
        }
        $t = [];
        for ($b = 0; $b < 0x80; $b++) {
            $t[$b] = chr($b); // controls + ASCII pass through in streams
        }
        $t[0x7F] = mb_chr(self::DEL_GLYPH, 'UTF-8'); // DEL renders as ⌂ in practice
        for ($b = 0x80; $b <= 0xFF; $b++) {
            $t[$b] = mb_chr(self::HIGH[$b - 0x80], 'UTF-8');
        }
        self::$streamTable = $t;

        $r = [];
        foreach (self::HIGH as $i => $cp) {
            $r[$cp] = 0x80 + $i;
        }
        $r[self::DEL_GLYPH] = 0x7F;
        self::$reverse = $r;
    }
}
