<?php

declare(strict_types=1);

namespace Atdt;

/**
 * The art gallery (1.1.0): one board-wide shelf of .ans pieces under
 * data/art/, replacing the per-theme "slot" maze. The ANSI editor
 * reads and writes here; themes bind their built-in slots (connect,
 * logon, goodbye) to pieces by name; menus and bulletins pick pieces
 * directly. Names are boring on purpose — they are filenames.
 *
 * Older boards may still hold art under data/themes/<t>/art/; the v4
 * migration moves those into the gallery, and Theme::art() keeps a
 * legacy per-theme lookup so nothing breaks mid-upgrade.
 */
final class Art
{
    public const NAME_RE = '/^[a-z0-9_-]{1,32}$/';
    public const MAX_BYTES = 262144; // .ans ceiling, matches the editor

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

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

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

    /** Validated absolute path for a piece 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 . '.ans';
    }

    /** A piece as a Unicode ANSI string (SAUCE stripped), or null. */
    public static function load(string $name): ?string
    {
        $path = self::path($name);
        return $path === null ? null : Cp437::loadAnsFile($path);
    }

    /** Raw .ans bytes (for export / preview), or null. */
    public static function bytes(string $name): ?string
    {
        $path = self::path($name);
        if ($path === null || !is_file($path)) {
            return null;
        }
        $bytes = @file_get_contents($path);
        return $bytes === false ? null : $bytes;
    }

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

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

    /**
     * Visual rows a decoded art string occupies (1.5.5): its line count,
     * raised by any explicit ESC[row;colH positioning that reaches lower,
     * capped to the 25-row screen. Prompts that used to park on row 25
     * regardless now sit a couple of rows under the art instead of
     * leaving a lake of black below a short piece.
     */
    public static function textRows(string $art): int
    {
        $rows = substr_count($art, "\n") + 1;
        if (preg_match_all('/\x1b\[(\d+);\d+[Hf]/', $art, $m) > 0 && $m[1] !== []) {
            $rows = max($rows, max(array_map('intval', $m[1])));
        }
        return max(1, min(25, $rows));
    }
}
