<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Pipe-code + MCI variable renderer (SPEC §8.5).
 *
 * |00–|15 foreground, |16–|23 background (the 8 low colors), || literal pipe.
 * %XX variables come from a context array (two uppercase chars → string).
 * Unknown pipe/percent codes pass through literally. User-authored content is
 * escaped with escape() before display unless allow_user_color permits;
 * MCI variables expand in every display path regardless of author (1.1.1),
 * including inside .ans art via vars(). Substituted values are never
 * re-scanned, so a variable's value cannot smuggle codes of its own.
 *
 * Emitted ANSI matches the client's SGR support (§7.3): fg 0–7 → 22;3Xm,
 * fg 8–15 → 1;3Xm, bg → 4Xm.
 */
final class Pipe
{
    /**
     * Render pipe codes + MCI variables to an ANSI string.
     * @param array<string, string|int> $vars e.g. ['BN' => 'My Board', 'ND' => 3]
     */
    public static function render(string $text, array $vars = []): string
    {
        $out = '';
        $n = strlen($text);
        for ($i = 0; $i < $n; $i++) {
            $c = $text[$i];
            if ($c === '|') {
                if ($i + 1 < $n && $text[$i + 1] === '|') {
                    $out .= '|';
                    $i++;
                    continue;
                }
                if ($i + 2 < $n && ctype_digit($text[$i + 1]) && ctype_digit($text[$i + 2])) {
                    $code = (int)substr($text, $i + 1, 2);
                    $sgr = self::sgrFor($code);
                    if ($sgr !== null) {
                        $out .= $sgr;
                        $i += 2;
                        continue;
                    }
                }
                $out .= '|'; // unknown code passes through literally
                continue;
            }
            if ($c === '%' && $i + 2 < $n) {
                $code = substr($text, $i + 1, 2);
                if (array_key_exists($code, $vars)) {
                    $out .= (string)$vars[$code];
                    $i += 2;
                    continue;
                }
            }
            $out .= $c;
        }
        return $out;
    }

    /**
     * Substitute %XX variables ONLY, leaving pipes and everything else
     * untouched (no ANSI is emitted). This is how MCI reaches places that
     * are not pipe-rendered: decoded .ans art and text carried inside
     * variable values (message subjects). Byte-wise like render(), which
     * is UTF-8 safe: '%' and A-Z never occur inside a multibyte sequence.
     * @param array<string, string|int> $vars
     */
    public static function vars(string $text, array $vars): string
    {
        if ($vars === [] || strpos($text, '%') === false) {
            return $text;
        }
        $out = '';
        $n = strlen($text);
        for ($i = 0; $i < $n; $i++) {
            $c = $text[$i];
            if ($c === '%' && $i + 2 < $n) {
                $code = substr($text, $i + 1, 2);
                if (array_key_exists($code, $vars)) {
                    $out .= (string)$vars[$code];
                    $i += 2;
                    continue;
                }
            }
            $out .= $c;
        }
        return $out;
    }

    /** Escape user-authored content: double the pipes (§8.5, §19.7). */
    public static function escape(string $text): string
    {
        return str_replace('|', '||', $text);
    }

    /** Strip pipe codes + substitute vars without emitting ANSI (logs, plain contexts). */
    public static function strip(string $text, array $vars = []): string
    {
        $ansi = self::render($text, $vars);
        $plain = preg_replace('/\x1b\[[0-9;]*m/', '', $ansi) ?? $ansi;
        return $plain;
    }

    /**
     * Word-wrap pipe-coded text to a VISIBLE width: |00-|23 codes cost no
     * columns, '||' costs one. Each continuation row is re-prefixed with the
     * colors active at the break, so a wrap can never bleach a colored run —
     * rows stay self-contained even when paging slices them apart (§19.7).
     * One logical line in, wrapped rows out; '' stays [''].
     * @return list<string>
     */
    public static function wrapVisible(string $text, int $width): array
    {
        $width = max(1, $width);
        // Atoms: [raw, visible cols, is-space, color code or null]. Codes are
        // atomic; visible text is split per character (mb-safe).
        $atoms = [];
        $parts = preg_split(
            '/((?:\|\|)|(?:\|[0-9]{2}))/',
            $text,
            -1,
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
        ) ?: [];
        foreach ($parts as $p) {
            if ($p === '||') {
                $atoms[] = ['||', 1, false, null];
                continue;
            }
            if (preg_match('/^\|([0-9]{2})$/', $p, $m)) {
                if ((int)$m[1] <= 23) {
                    $atoms[] = [$p, 0, false, $p];
                    continue;
                }
                $p = '|' . $m[1]; // |24+ renders literally: fall through as text
            }
            foreach (mb_str_split($p) as $ch) {
                $atoms[] = [$ch, 1, $ch === ' ', null];
            }
        }

        $out = [];
        $fg = null;
        $bg = null;
        // Emit one finished row: prepend the colors carried in from earlier
        // rows, then advance the carried state over this row's codes.
        $emit = function (array $row) use (&$out, &$fg, &$bg): void {
            $pfx = $out === [] ? '' : ($fg ?? '') . ($bg ?? '');
            $raw = '';
            foreach ($row as [$r, , , $code]) {
                $raw .= $r;
                if ($code !== null) {
                    if ((int)substr($code, 1) <= 15) {
                        $fg = $code;
                    } else {
                        $bg = $code;
                    }
                }
            }
            $out[] = $pfx . $raw;
        };

        $cur = [];
        $vis = 0;
        foreach ($atoms as $a) {
            if ($vis + $a[1] > $width) {
                if ($a[2]) {
                    $emit($cur); // break lands ON a space: the space is the break
                    $cur = [];
                    $vis = 0;
                    continue;
                }
                $si = null;
                foreach ($cur as $i => $b) {
                    if ($b[2]) {
                        $si = $i;
                    }
                }
                if ($si !== null) {
                    $emit(array_slice($cur, 0, $si));
                    $cur = array_slice($cur, $si + 1);
                    $vis = array_sum(array_column($cur, 1));
                } else {
                    $emit($cur); // one unbroken run: hard cut, like wordwrap(cut)
                    $cur = [];
                    $vis = 0;
                }
            }
            $cur[] = $a;
            $vis += $a[1];
        }
        $emit($cur);
        return $out;
    }

    /** VGA color index (0=black,1=blue,4=red,…) → ANSI SGR base (30-37). */
    public const VGA_TO_SGR = [30, 34, 32, 36, 31, 35, 33, 37];

    private static function sgrFor(int $code): ?string
    {
        if ($code <= 7) {
            return "\x1b[22;" . self::VGA_TO_SGR[$code] . 'm';
        }
        if ($code <= 15) {
            return "\x1b[1;" . self::VGA_TO_SGR[$code - 8] . 'm';
        }
        if ($code <= 23) {
            return "\x1b[" . (self::VGA_TO_SGR[$code - 16] + 10) . 'm';
        }
        return null; // |24+ is not a color code
    }
}
