<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Settings;

use Closure;
use JTL\DB\DbInterface;
use RuntimeException;
use stdClass;
use Symfony\Component\Yaml\Yaml;

/**
 * Loads + validates the settings ALLOWLIST (settings-allowlist.yaml) and LIVE-ENUMERATES the
 * shop's own `teinstellungenconf` table into {@see SettingRow}s (v2 design, 2026-07-27).
 *
 * There is no per-setting YAML any more: the allowlist only declares WHICH conf sections/keys to
 * surface, which subset may be written, friendly-name aliases, and the handful of fixed table-list
 * settings that have no `teinstellungenconf` row. Everything else — the setting's write-coercion
 * type (from `cInputTyp`), its label/description/current/default value (from the shop via
 * {@see SettingDescriber}) — is read live from the shop, never duplicated into the file.
 *
 * Structural validation of the allowlist is eager (a malformed file fails loudly at construction,
 * exactly like the old per-setting registry). The live enumeration is LAZY — it needs a DB and runs
 * only on first {@see all()}/{@see get()}/{@see has()} — so constructing the registry stays cheap
 * and side-effect-free (e.g. {@see \Plugin\tnt_mcp\src\Mcp\OAuth\ScopeCatalog} builds the tool set
 * for scope metadata without ever booting the shop container).
 */
final class SettingsRegistry
{
    private const KEY_RX   = '/^[a-z][a-z0-9_]*$/';
    private const IDENT_RX = '/^[a-zA-Z][a-zA-Z0-9_]*$/';
    private const ORDER_RX = '/^[a-zA-Z][a-zA-Z0-9_]*(\s+(ASC|DESC))?(\s*,\s*[a-zA-Z][a-zA-Z0-9_]*(\s+(ASC|DESC))?)*$/';
    private const PATTERN_RX = '/^[a-z0-9_*]+$/';

    /** cInputTyp -> write-coercion type. Anything not listed coerces as a string. */
    private const INPUT_TYPE_MAP = [
        'number'    => 'int',
        'kommazahl' => 'float',
    ];

    /** Section id that needs an extra cModulId key on write (PaymentMethod) — never writable here. */
    private const SECTION_PAYMENT = 100;

    /** @var list<int> exposed whole config sections */
    private array $exposeSections = [];

    /** @var list<string> exposed cWertName glob patterns */
    private array $exposePatterns = [];

    /** @var array<string, true> writable cWertName set */
    private array $writable = [];

    /** When true, every exposed setting is writable except the hard-excluded ones. */
    private bool $writableAll = false;

    /** @var array<string, string> cWertName => friendly key */
    private array $aliases = [];

    /** @var array<string, SettingRow> the fixed table-list rows (built eagerly, need no DB) */
    private array $listRows = [];

    /** @var (Closure(): DbInterface)|null lazy DB provider for the live enumeration */
    private ?Closure $dbProvider = null;

    /** @var array<string, SettingRow>|null merged rows, built lazily on first access */
    private ?array $rows = null;

    private function __construct()
    {
    }

    /**
     * @param Closure(): DbInterface $dbProvider resolves the shop DB lazily (only when the live
     *                                           `teinstellungenconf` enumeration first runs)
     */
    public static function fromFile(string $path, Closure $dbProvider): self
    {
        if (!is_file($path)) {
            throw new RuntimeException('settings-allowlist.yaml not found: ' . $path);
        }
        $parsed = Yaml::parseFile($path);

        return self::fromArray(\is_array($parsed) ? $parsed : [], $dbProvider);
    }

    /**
     * @param array<mixed>           $parsed
     * @param Closure(): DbInterface $dbProvider
     */
    public static function fromArray(array $parsed, Closure $dbProvider): self
    {
        $self             = new self();
        $self->dbProvider = $dbProvider;
        $self->parseAllowlist($parsed);

        return $self;
    }

    /**
     * Build a registry from ready-made rows, bypassing the allowlist + live enumeration. For tests
     * and any caller that already holds concrete {@see SettingRow}s.
     *
     * @param list<SettingRow> $rows
     */
    public static function fromRows(array $rows): self
    {
        $self       = new self();
        $self->rows = [];
        foreach ($rows as $row) {
            $self->rows[$row->key] = $row;
        }

        return $self;
    }

    /** @return list<SettingRow> */
    public function all(): array
    {
        return array_values($this->load());
    }

    public function has(string $key): bool
    {
        return isset($this->load()[$key]);
    }

    public function get(string $key): SettingRow
    {
        return $this->load()[$key] ?? throw new RuntimeException('unknown setting: ' . $key);
    }

    // ---- allowlist parsing (eager, DB-free) --------------------------------

    /**
     * @param array<mixed> $parsed
     */
    private function parseAllowlist(array $parsed): void
    {
        self::assertOnlyKeys($parsed, ['version', 'expose', 'writable', 'aliases', 'lists'], 'root');

        if (($parsed['version'] ?? null) !== 1) {
            throw new RuntimeException('settings-allowlist.yaml: "version" must be 1');
        }

        $this->parseExpose($parsed['expose'] ?? null);
        $this->writable = $this->parseWritable($parsed['writable'] ?? []);
        $this->aliases  = $this->parseAliases($parsed['aliases'] ?? []);
        $this->listRows = $this->parseLists($parsed['lists'] ?? []);
    }

    private function parseExpose(mixed $expose): void
    {
        if (!\is_array($expose)) {
            throw new RuntimeException('settings-allowlist.yaml: "expose" must be a map');
        }
        self::assertOnlyKeys($expose, ['sections', 'patterns'], '"expose"');

        $sections = $expose['sections'] ?? [];
        $patterns = $expose['patterns'] ?? [];
        if (!\is_array($sections) || !\is_array($patterns)) {
            throw new RuntimeException('settings-allowlist.yaml: "expose.sections"/"expose.patterns" must be lists');
        }
        foreach ($sections as $section) {
            if (!\is_int($section) || $section < 1) {
                throw new RuntimeException('settings-allowlist.yaml: "expose.sections" must be positive ints');
            }
            $this->exposeSections[] = $section;
        }
        foreach ($patterns as $pattern) {
            if (!\is_string($pattern) || preg_match(self::PATTERN_RX, $pattern) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: "expose.patterns" entry is not a safe glob: ' . self::toStr($pattern),
                );
            }
            $this->exposePatterns[] = $pattern;
        }
        if ($this->exposeSections === [] && $this->exposePatterns === []) {
            throw new RuntimeException('settings-allowlist.yaml: "expose" must list at least one section or pattern');
        }
    }

    /**
     * @param mixed $writable
     * @return array<string, true>
     */
    private function parseWritable(mixed $writable): array
    {
        // "*" means: every exposed setting is writable (hard-excludes still apply). No key list.
        if ($writable === '*') {
            $this->writableAll = true;

            return [];
        }
        if (!\is_array($writable)) {
            throw new RuntimeException('settings-allowlist.yaml: "writable" must be a list or "*"');
        }
        $set = [];
        foreach ($writable as $name) {
            if (!\is_string($name) || preg_match(self::IDENT_RX, $name) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: "writable" entry is not a valid cWertName: ' . self::toStr($name),
                );
            }
            $set[$name] = true;
        }

        return $set;
    }

    /**
     * @param mixed $aliases
     * @return array<string, string> cWertName => friendly key
     */
    private function parseAliases(mixed $aliases): array
    {
        if (!\is_array($aliases)) {
            throw new RuntimeException('settings-allowlist.yaml: "aliases" must be a map');
        }
        $byWertName = [];
        foreach ($aliases as $friendly => $wertName) {
            if (!\is_string($friendly) || preg_match(self::KEY_RX, $friendly) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: alias key is not a valid friendly key: ' . self::toStr($friendly),
                );
            }
            if (!\is_string($wertName) || preg_match(self::IDENT_RX, $wertName) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: alias "' . $friendly . '" target is not a valid cWertName',
                );
            }
            if (isset($byWertName[$wertName])) {
                throw new RuntimeException('settings-allowlist.yaml: two aliases target the same cWertName: ' . $wertName);
            }
            $byWertName[$wertName] = $friendly;
        }

        return $byWertName;
    }

    /**
     * @param mixed $lists
     * @return array<string, SettingRow>
     */
    private function parseLists(mixed $lists): array
    {
        if (!\is_array($lists)) {
            throw new RuntimeException('settings-allowlist.yaml: "lists" must be a map');
        }
        $rows = [];
        foreach ($lists as $key => $spec) {
            if (!\is_string($key) || preg_match(self::KEY_RX, $key) !== 1) {
                throw new RuntimeException('settings-allowlist.yaml: bad list key: ' . self::toStr($key));
            }
            if (!\is_array($spec)) {
                throw new RuntimeException('settings-allowlist.yaml: list "' . $key . '" must be a map');
            }
            self::assertOnlyKeys($spec, ['title', 'summary', 'list'], 'list "' . $key . '"');

            $title   = $spec['title'] ?? null;
            $summary = $spec['summary'] ?? null;
            $listSpec = $spec['list'] ?? null;
            if (!\is_string($title) || $title === '') {
                throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.title" must be a non-empty string');
            }
            if (!\is_string($summary) || $summary === '') {
                throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.summary" must be a non-empty string');
            }
            if (!\is_array($listSpec)) {
                throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list" must be a map');
            }
            $rows[$key] = new SettingRow($key, $title, $summary, 'read', null, self::parseList($key, $listSpec));
        }

        return $rows;
    }

    /**
     * @param array<mixed> $spec
     */
    private static function parseList(string $key, array $spec): SettingList
    {
        self::assertOnlyKeys($spec, ['table', 'fields', 'where', 'order'], 'list "' . $key . '.list"');

        $table  = $spec['table'] ?? null;
        $fields = $spec['fields'] ?? null;
        $where  = $spec['where'] ?? [];
        $order  = $spec['order'] ?? '';

        if (!\is_string($table) || preg_match(self::IDENT_RX, $table) !== 1) {
            throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.table" is not a valid table identifier');
        }
        if (!\is_array($fields) || $fields === []) {
            throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.fields" must be a non-empty list');
        }
        $cleanFields = [];
        foreach ($fields as $field) {
            if (!\is_string($field) || preg_match(self::IDENT_RX, $field) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: "' . $key . '.list.fields" contains a bad column identifier: '
                    . self::toStr($field),
                );
            }
            $cleanFields[] = $field;
        }
        if (!\is_array($where)) {
            throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.where" must be a map');
        }
        $cleanWhere = [];
        foreach ($where as $col => $val) {
            if (!\is_string($col) || preg_match(self::IDENT_RX, $col) !== 1) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: "' . $key . '.list.where" has a bad column identifier: ' . self::toStr($col),
                );
            }
            if (!\is_scalar($val)) {
                throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.where.' . $col . '" must be a scalar');
            }
            $cleanWhere[$col] = $val;
        }
        if (!\is_string($order)) {
            throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.order" must be a string');
        }
        if ($order !== '' && preg_match(self::ORDER_RX, $order) !== 1) {
            throw new RuntimeException('settings-allowlist.yaml: "' . $key . '.list.order" is not a safe column-order expression');
        }

        return new SettingList($table, $cleanFields, $cleanWhere, $order);
    }

    // ---- live enumeration (lazy, needs a DB) -------------------------------

    /** @return array<string, SettingRow> */
    private function load(): array
    {
        if ($this->rows !== null) {
            return $this->rows;
        }
        if ($this->dbProvider === null) {
            throw new RuntimeException('SettingsRegistry: no DB provider — cannot enumerate teinstellungenconf');
        }

        $rows           = $this->enumerateConf(($this->dbProvider)());
        $appliedAliases = [];
        foreach ($rows as $row) {
            if ($row->conf !== null && isset($this->aliases[$row->conf->name])) {
                $appliedAliases[$row->conf->name] = true;
            }
        }
        // A misconfigured allowlist (alias/writable pointing at a key that is never enumerated —
        // typo, wrong section, or an excluded setting) must fail loudly, not silently no-op.
        foreach (array_keys($this->aliases) as $wertName) {
            if (!isset($appliedAliases[$wertName])) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: alias target "' . $wertName . '" matches no exposed setting',
                );
            }
        }
        $enumerated = [];
        foreach ($rows as $row) {
            if ($row->conf !== null) {
                $enumerated[$row->conf->name] = true;
            }
        }
        foreach (array_keys($this->writable) as $wertName) {
            if (!isset($enumerated[$wertName])) {
                throw new RuntimeException(
                    'settings-allowlist.yaml: writable "' . $wertName . '" matches no exposed setting',
                );
            }
        }

        // Merge enumerated conf rows with the fixed list rows; a key collision is a config bug.
        $merged = [];
        foreach (array_merge($rows, array_values($this->listRows)) as $row) {
            if (isset($merged[$row->key])) {
                throw new RuntimeException('settings-allowlist.yaml: duplicate setting key after aliasing: ' . $row->key);
            }
            $merged[$row->key] = $row;
        }

        return $this->rows = $merged;
    }

    /**
     * @return list<SettingRow>
     */
    private function enumerateConf(DbInterface $db): array
    {
        // Coarse fetch; every predicate is re-applied in PHP below so the result is identical even
        // against a fixture DB double that ignores the WHERE clause.
        $candidates = $db->getObjects(
            'SELECT cWertName, kEinstellungenSektion, cInputTyp, cConf, nStandardAnzeigen, nModul, cModulId, nSort
                FROM teinstellungenconf
                WHERE cConf = :y AND cInputTyp IS NOT NULL AND cInputTyp <> :empty AND cInputTyp <> :pass
                ORDER BY kEinstellungenSektion ASC, nSort ASC',
            ['y' => 'Y', 'empty' => '', 'pass' => 'pass'],
        );

        $rows = [];
        foreach ($candidates as $r) {
            $name    = (string)($r->cWertName ?? '');
            $input   = (string)($r->cInputTyp ?? '');
            $section = (int)($r->kEinstellungenSektion ?? 0);
            if ($name === '' || $input === '' || $input === 'pass') {
                continue;
            }
            if ((string)($r->cConf ?? 'Y') !== 'Y') {
                continue;
            }
            if ((int)($r->nStandardAnzeigen ?? 1) <= 0) {
                continue;
            }
            if (!$this->isExposed($name, $section)) {
                continue;
            }

            $type     = self::INPUT_TYPE_MAP[$input] ?? 'string';
            $key      = $this->aliases[$name] ?? $name;
            $writable = ($this->writableAll || isset($this->writable[$name]))
                && !$this->isHardExcludedFromWrite($section, $input, $r);
            $mode     = $writable ? 'both' : 'read';

            $rows[] = new SettingRow(
                $key,
                $key,
                'Shop configuration setting "' . $name . '" (config section ' . $section
                    . '). Call describe_setting for its localized label, type and current/default value.',
                $mode,
                new SettingConf($name, $section, $type),
                null,
            );
        }

        return $rows;
    }

    private function isExposed(string $name, int $section): bool
    {
        if (\in_array($section, $this->exposeSections, true)) {
            return true;
        }
        foreach ($this->exposePatterns as $pattern) {
            $rx = '/^' . str_replace('\*', '[a-z0-9_]*', preg_quote($pattern, '/')) . '$/';
            if (preg_match($rx, $name) === 1) {
                return true;
            }
        }

        return false;
    }

    /**
     * Writes are only ever generic `SectionFactory::getSection($id)->update([name=>value])`. That
     * shape breaks for PaymentMethod (section 100 needs an extra cModulId key), for `pass` secrets,
     * and for module-owned settings (nModul / cModulId set) — so those are hard-excluded from writes
     * regardless of the allowlist. Read exposure is unaffected.
     */
    private function isHardExcludedFromWrite(int $section, string $input, stdClass $r): bool
    {
        if ($section === self::SECTION_PAYMENT || $input === 'pass') {
            return true;
        }
        if ((int)($r->nModul ?? 0) > 0) {
            return true;
        }
        $modulId = $r->cModulId ?? null;

        return \is_string($modulId) && $modulId !== '';
    }

    /**
     * @param array<mixed> $spec
     * @param list<string> $allowed
     */
    private static function assertOnlyKeys(array $spec, array $allowed, string $where): void
    {
        foreach (array_keys($spec) as $k) {
            if (!\in_array($k, $allowed, true)) {
                throw new RuntimeException('settings-allowlist.yaml: unknown field "' . self::toStr($k) . '" in ' . $where);
            }
        }
    }

    private static function toStr(mixed $v): string
    {
        return \is_string($v) ? $v : (string)json_encode($v);
    }
}
