<?php

declare(strict_types=1);

namespace Atdt;

/**
 * ANSI letter fonts (1.5.5): TheDraw's .TDF spirit as boring JSON under
 * data/fonts/. A font is a uniform-height set of glyphs for A-Z and
 * 0-9, each glyph a small cell grid; cells are the editor's packed
 * integers (ch | fg<<16 | bg<<20 | blink<<24) with null meaning
 * transparent. The built-in fonts live in the editor's JS and never
 * touch disk; this class only stores the ones sysops draw themselves.
 */
final class AnsiFonts
{
    public const NAME_RE = '/^[a-z0-9_-]{1,24}$/';
    public const MAX_BYTES = 131072;   // a full 36-glyph set is ~20 KB
    public const MAX_W = 16;           // per glyph, cells
    public const MAX_H = 8;
    public const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    private const CELL_MAX = 0x1FFFFFF; // ch 16 bits, fg/bg 4, blink 1

    public static function dir(): string
    {
        return ATDT_ROOT . '/data/fonts';
    }

    /** @return list<string> every stored font, sorted by name */
    public static function pieces(): array
    {
        $out = [];
        foreach (glob(self::dir() . '/*.json') ?: [] as $file) {
            $name = basename($file, '.json');
            if (preg_match(self::NAME_RE, $name) === 1) {
                $out[] = $name;
            }
        }
        sort($out);
        return $out;
    }

    /** Validated absolute path for a font name, or null for a bad name. */
    public static function path(string $name): ?string
    {
        if (preg_match(self::NAME_RE, $name) !== 1) {
            return null;
        }
        return self::dir() . '/' . $name . '.json';
    }

    /** @return ?array{h: int, glyphs: array<string, array{w: int, cells: list<int|null>}>} */
    public static function load(string $name): ?array
    {
        $path = self::path($name);
        if ($path === null || !is_file($path)) {
            return null;
        }
        $raw = @file_get_contents($path);
        if ($raw === false) {
            return null;
        }
        return self::validate(json_decode($raw, true));
    }

    public static function save(string $name, mixed $font): bool
    {
        $path = self::path($name);
        $clean = self::validate($font);
        if ($path === null || $clean === null) {
            return false;
        }
        $json = json_encode($clean);
        if ($json === false || strlen($json) > self::MAX_BYTES) {
            return false;
        }
        if (!is_dir(self::dir()) && !@mkdir(self::dir(), 0755, true)) {
            return false;
        }
        return @file_put_contents($path, $json) !== false;
    }

    public static function delete(string $name): bool
    {
        $path = self::path($name);
        return $path !== null && is_file($path) && @unlink($path);
    }

    /**
     * Normalize an untrusted decode into a font, or null. Unknown keys
     * and glyphs outside A-Z 0-9 are dropped; anything malformed sinks
     * the whole document (a half-valid font is worse than none).
     *
     * @return ?array{h: int, glyphs: array<string, array{w: int, cells: list<int|null>}>}
     */
    public static function validate(mixed $font): ?array
    {
        if (!is_array($font)) {
            return null;
        }
        $h = (int)($font['h'] ?? 0);
        if ($h < 1 || $h > self::MAX_H) {
            return null;
        }
        $in = $font['glyphs'] ?? null;
        if (!is_array($in) || $in === []) {
            return null;
        }
        $glyphs = [];
        foreach ($in as $ch => $g) {
            $ch = strtoupper((string)$ch);
            if (strlen($ch) !== 1 || !str_contains(self::CHARSET, $ch)) {
                continue;
            }
            if (!is_array($g)) {
                return null;
            }
            $w = (int)($g['w'] ?? 0);
            $cells = $g['cells'] ?? null;
            if ($w < 1 || $w > self::MAX_W || !is_array($cells) || count($cells) !== $w * $h) {
                return null;
            }
            $flat = [];
            foreach ($cells as $cell) {
                if ($cell === null) {
                    $flat[] = null;
                    continue;
                }
                if (!is_int($cell) || $cell < 0 || $cell > self::CELL_MAX) {
                    return null;
                }
                $flat[] = $cell;
            }
            $glyphs[$ch] = ['w' => $w, 'cells' => $flat];
        }
        if ($glyphs === []) {
            return null;
        }
        ksort($glyphs);
        return ['h' => $h, 'glyphs' => $glyphs];
    }
}
