<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

/**
 * The schema, enforced. Every rule here protects an invocation that would otherwise fail late and
 * silently: an unknown key is a typo that would be ignored at runtime, a `verify:` on a Reversible
 * entry is what makes the read-back cost something (D36), and a placeholder pointing at a field
 * that was never declared would bind to nothing on a live shop.
 *
 * It collects, it never stops: the catalog is hand-written and long, so reporting one error per
 * load turns one fix-up into a dozen round trips.
 *
 * Stateful across files on purpose — action ids must be unique over the WHOLE catalog, which no
 * single-file check can see. Use one instance per load.
 */
final class ActionCatalogValidator
{
    private const TOP_LEVEL_KEYS = ['version', 'compatibleShopVersions', 'actions'];

    private const ENTRY_REQUIRED = [
        'id', 'kind', 'route', 'permission', 'title', 'sideEffects', 'dryRunnable', 'endpointClass', 'fields',
    ];

    private const ENTRY_OPTIONAL = [
        'verify', 'journal', 'idResolution', 'recipe', 'prerequisites', 'coRequired', 'pitfalls', 'undo',
        'guide', 'sideEffectNote', 'evidence', 'controller', 'method', 'params', 'nextSteps',
    ];

    /**
     * §4: `describe_admin_action` renders at most three `nextSteps` lines. A fourth would be written,
     * never read — so it is a catalog error rather than a silent truncation. Taken FROM the describer
     * rather than repeated, so raising the render cap cannot leave the schema rejecting what the
     * response would happily show.
     */
    private const NEXT_STEPS_LIMIT = ActionDescriber::NEXT_STEPS_LIMIT;

    /** Optional in the key list above, but required together on `kind: adminIo` and forbidden on `formPost`. */
    private const ADMIN_IO_KEYS = ['controller', 'method', 'params'];

    /**
     * The only namespace an `adminIo` entry may name. Existence is deliberately NOT checked here —
     * see {@see self::controller()}.
     */
    private const CONTROLLER_NAMESPACE = 'JTL\\Router\\Controller\\Backend\\';

    /** A `\`-separated FQCN, no leading separator, at least two segments. */
    private const CONTROLLER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$/';

    private const METHOD_PATTERN = '/^[a-zA-Z_][a-zA-Z0-9_]*$/';

    private const FIELD_KEYS = ['name', 'type', 'required', 'value', 'doc', 'enum', 'template'];

    private const FIELD_TYPES = ['const', 'int', 'string', 'decimal', 'bool', 'int[]', 'string[]', 'decimal[]'];

    /** camelCase segments are allowed because the design's own ids use them (`payment.method.saveConfig`). */
    private const ID_PATTERN = '/^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/';

    private const PERMISSION_PATTERN = '/^[A-Z][A-Z0-9_]*$/';

    private const REQUIRED_TITLE_LANGUAGES = ['de', 'en'];

    /** @var array<string,string> action id => file that already declared it */
    private array $seenIds = [];

    /** @var list<CatalogValidationError> */
    private array $errors = [];

    private string $file = '';

    /**
     * @return list<CatalogValidationError> every problem in $parsed, in document order
     */
    public function validate(string $file, mixed $parsed): array
    {
        $this->file   = $file;
        $this->errors = [];

        if (!\is_array($parsed)) {
            $this->error('', 'expected a map at the top level');

            return $this->errors;
        }
        $this->keys('', $parsed, self::TOP_LEVEL_KEYS, self::TOP_LEVEL_KEYS);

        if (\array_key_exists('version', $parsed) && $parsed['version'] !== 1) {
            $this->error('version', 'expected int 1');
        }
        if (\array_key_exists('compatibleShopVersions', $parsed)) {
            $this->nonEmptyString('compatibleShopVersions', $parsed['compatibleShopVersions']);
        }
        if (\array_key_exists('actions', $parsed)) {
            $this->actions($parsed['actions']);
        }

        return $this->errors;
    }

    private function actions(mixed $actions): void
    {
        if (!$this->nonEmptyList('actions', $actions)) {
            return;
        }
        foreach (self::items($actions) as $index => $entry) {
            $path = 'actions[' . $index . ']';
            if (!\is_array($entry)) {
                $this->error($path, 'expected a map');
                continue;
            }
            $this->entry($path, $entry);
        }
    }

    /**
     * @param array<mixed> $entry
     */
    private function entry(string $path, array $entry): void
    {
        $this->keys($path, $entry, [...self::ENTRY_REQUIRED, ...self::ENTRY_OPTIONAL], self::ENTRY_REQUIRED);
        if (\array_key_exists('id', $entry)) {
            $this->id($path . '.id', $entry['id']);
        }
        if (\array_key_exists('kind', $entry)) {
            $this->oneOf($path . '.kind', $entry['kind'], ['formPost', 'adminIo']);
        }
        if (\array_key_exists('route', $entry)) {
            $this->route($path . '.route', $entry['route']);
        }
        if (\array_key_exists('permission', $entry)) {
            $this->pattern($path . '.permission', $entry['permission'], self::PERMISSION_PATTERN);
        }
        if (\array_key_exists('title', $entry)) {
            $this->title($path . '.title', $entry['title']);
        }
        if (\array_key_exists('sideEffects', $entry)) {
            $this->sideEffects($path . '.sideEffects', $entry['sideEffects']);
        }
        if (\array_key_exists('dryRunnable', $entry)) {
            $this->bool($path . '.dryRunnable', $entry['dryRunnable']);
        }
        if (\array_key_exists('endpointClass', $entry)) {
            $this->oneOf($path . '.endpointClass', $entry['endpointClass'], ['Reversible', 'Destructive']);
        }
        if (\array_key_exists('fields', $entry)) {
            $this->fields($path . '.fields', $entry['fields']);
        }
        $this->adminIo($path, $entry);

        $arguments = $this->declaredArguments($entry['fields'] ?? null);

        if (\array_key_exists('verify', $entry)) {
            $this->verify($path . '.verify', $entry['verify'], $arguments);
        }
        if (($entry['endpointClass'] ?? null) === 'Reversible' && !$this->isNonEmptyList($entry['verify'] ?? null)) {
            $this->error($path . '.verify', 'endpointClass "Reversible" requires a non-empty verify');
        }
        if (\array_key_exists('journal', $entry)) {
            $this->journal($path . '.journal', $entry['journal'], $arguments);
        }
        if (\array_key_exists('idResolution', $entry)) {
            $this->idResolution($path . '.idResolution', $entry['idResolution']);
        }
        foreach (['recipe', 'prerequisites', 'pitfalls'] as $key) {
            if (\array_key_exists($key, $entry)) {
                $this->stringList($path . '.' . $key, $entry[$key]);
            }
        }
        if (\array_key_exists('coRequired', $entry)) {
            $this->coRequired($path . '.coRequired', $entry['coRequired'], $arguments);
        }
        if (\array_key_exists('nextSteps', $entry)) {
            $this->nextSteps($path . '.nextSteps', $entry['nextSteps'], $entry['id'] ?? null);
        }
        if (\array_key_exists('undo', $entry)) {
            $this->undo($path . '.undo', $entry['undo']);
        }
        if (\array_key_exists('guide', $entry)) {
            $this->nonEmptyString($path . '.guide', $entry['guide']);
        }
        if (\array_key_exists('sideEffectNote', $entry) && !\is_string($entry['sideEffectNote'])) {
            $this->error($path . '.sideEffectNote', 'expected a string');
        }
        if (\array_key_exists('evidence', $entry)) {
            $this->evidence($path . '.evidence', $entry['evidence']);
        }
    }

    private function id(string $path, mixed $id): void
    {
        if (!$this->pattern($path, $id, self::ID_PATTERN)) {
            return;
        }
        /** @var string $id */
        if (isset($this->seenIds[$id])) {
            $this->error($path, \sprintf('duplicate action id "%s", already declared in %s', $id, $this->seenIds[$id]));

            return;
        }
        $this->seenIds[$id] = $this->file;
    }

    private function route(string $path, mixed $route): void
    {
        if (!$this->nonEmptyString($path, $route)) {
            return;
        }
        /** @var string $route */
        if ($route !== \mb_convert_case($route, \MB_CASE_LOWER)) {
            $this->error($path, 'expected a lowercase string');
        }
    }

    /**
     * The `adminIo` half of the schema: which controller method to call and what shape it wants.
     *
     * All three keys move together with `kind:` in BOTH directions. A `formPost` entry carrying a
     * `controller:` is not a harmless extra — it reads as an invocation target that nothing will
     * ever call, and the reader cannot tell which of the two halves is the mistake.
     *
     * @param array<mixed> $entry
     */
    private function adminIo(string $path, array $entry): void
    {
        $kind = $entry['kind'] ?? null;
        foreach (self::ADMIN_IO_KEYS as $key) {
            $present = \array_key_exists($key, $entry);
            if ($kind === ActionKind::AdminIo->value && !$present) {
                $this->error(
                    $path . '.' . $key,
                    \sprintf('missing required key "%s" for kind "adminIo"', $key)
                );
                continue;
            }
            if ($kind === ActionKind::FormPost->value && $present) {
                $this->error(
                    $path . '.' . $key,
                    \sprintf('key "%s" is only allowed on kind "adminIo"', $key)
                );
            }
        }
        if (\array_key_exists('controller', $entry)) {
            $this->controller($path . '.controller', $entry['controller']);
        }
        if (\array_key_exists('method', $entry)) {
            $this->pattern($path . '.method', $entry['method'], self::METHOD_PATTERN);
        }
        if (\array_key_exists('params', $entry)) {
            $this->oneOf(
                $path . '.params',
                $entry['params'],
                \array_map(static fn (AdminIoParams $shape): string => $shape->value, AdminIoParams::cases()),
            );
        }
        if ($kind !== ActionKind::AdminIo->value) {
            return;
        }
        $this->adminIoFields($path, $entry['params'] ?? null, $entry['fields'] ?? null);
    }

    /**
     * The field-shape rules each `params:` value implies. Both exist because the failure they
     * prevent is silent:
     *
     * - `formData` is flattened with `$post[$item['name']] = $item['value']`
     *   (`IOController.php:565`, `:685`), so of a repeated name only the LAST pair survives — an
     *   array field would arrive as its own final element and the rest would vanish with no error.
     * - `positional` arguments cannot be skipped, so an optional field would shift every later
     *   argument one place left and hand the method a value for the wrong parameter. A templated
     *   field expands to an unknown NUMBER of keys, which has no positional meaning at all.
     */
    private function adminIoFields(string $path, mixed $params, mixed $fields): void
    {
        if (!\is_string($params) || AdminIoParams::tryFrom($params) === null) {
            // `params:` is missing or unknown and already reported; the shape rules it implies
            // cannot be applied, and guessing one would report a second, misleading error.
            return;
        }
        foreach (self::items($fields) as $index => $field) {
            if (!\is_array($field)) {
                continue;
            }
            $fieldPath = $path . '.fields[' . $index . ']';
            $name      = $field['name'] ?? null;
            if (!\is_string($name)) {
                continue;
            }
            if (\str_ends_with($name, '[]')) {
                $this->error(
                    $fieldPath . '.name',
                    \sprintf('an array field is not allowed on params "%s"', $params)
                );
            }
            if ($params !== AdminIoParams::Positional->value) {
                continue;
            }
            if (\str_contains($name, '{')) {
                $this->error($fieldPath . '.name', 'a templated field is not allowed on params "positional"');
            }
            if (($field['type'] ?? null) !== 'const' && ($field['required'] ?? null) !== true) {
                $this->error(
                    $fieldPath . '.required',
                    'params "positional" requires every field to be required: true — an argument cannot be skipped'
                );
            }
        }
    }

    /**
     * Format only, on purpose. {@see ActionCatalog} is pure and loadable without a booted shop, and
     * that is what lets {@see \Plugin\tnt_mcp\src\Mcp\ToolFactory} build every tool cheaply.
     * A `class_exists()` here would need an autoloaded shop to say "valid", so whether the class is
     * really there is the invoker's business, and `CatalogEvidenceTest`'s.
     */
    private function controller(string $path, mixed $controller): void
    {
        if (!$this->pattern($path, $controller, self::CONTROLLER_PATTERN)) {
            return;
        }
        /** @var string $controller */
        if (!\str_starts_with($controller, self::CONTROLLER_NAMESPACE)) {
            $this->error($path, \sprintf('must start with "%s"', self::CONTROLLER_NAMESPACE));
        }
    }

    private function title(string $path, mixed $title): void
    {
        if (!\is_array($title) || $title === []) {
            $this->error($path, 'expected a non-empty map of language code => label');

            return;
        }
        foreach ($title as $language => $label) {
            $this->nonEmptyString($path . '.' . $language, $label);
        }
        foreach (self::REQUIRED_TITLE_LANGUAGES as $language) {
            if (!\array_key_exists($language, $title)) {
                $this->error($path, \sprintf('missing required language "%s"', $language));
            }
        }
    }

    private function sideEffects(string $path, mixed $effects): void
    {
        if (!$this->nonEmptyList($path, $effects)) {
            return;
        }
        $allowed = \array_map(static fn (SideEffect $effect): string => $effect->value, SideEffect::cases());
        $seen    = [];
        foreach (self::items($effects) as $index => $effect) {
            $itemPath = $path . '[' . $index . ']';
            if (!$this->oneOf($itemPath, $effect, $allowed)) {
                continue;
            }
            /** @var string $effect */
            if (\in_array($effect, $seen, true)) {
                $this->error($itemPath, \sprintf('duplicate side effect "%s"', $effect));
            }
            $seen[] = $effect;
        }
    }

    private function fields(string $path, mixed $fields): void
    {
        if (!$this->nonEmptyList($path, $fields)) {
            return;
        }
        $names = [];
        foreach (self::items($fields) as $index => $field) {
            $fieldPath = $path . '[' . $index . ']';
            if (!\is_array($field)) {
                $this->error($fieldPath, 'expected a map');
                continue;
            }
            $this->field($fieldPath, $field);
            $name = $field['name'] ?? null;
            if (\is_string($name)) {
                if (\in_array($name, $names, true)) {
                    $this->error($fieldPath . '.name', \sprintf('duplicate field name "%s"', $name));
                }
                $names[] = $name;
            }
        }
    }

    /**
     * @param array<mixed> $field
     */
    private function field(string $path, array $field): void
    {
        $this->keys($path, $field, self::FIELD_KEYS, ['name', 'type']);
        $placeholder = \array_key_exists('name', $field)
            ? $this->fieldName($path . '.name', $field['name'])
            : null;
        $type = $field['type'] ?? null;
        if (\array_key_exists('type', $field)) {
            $this->oneOf($path . '.type', $type, self::FIELD_TYPES);
        }

        if ($type === 'const') {
            $this->nonEmptyString($path . '.value', $field['value'] ?? null);
            if (\array_key_exists('required', $field)) {
                $this->error($path . '.required', 'type "const" must not set required');
            }
        } else {
            if (\array_key_exists('value', $field)) {
                $this->error($path . '.value', 'only type "const" may set value');
            }
            if (\array_key_exists('required', $field)) {
                $this->bool($path . '.required', $field['required']);
            }
        }
        if (\array_key_exists('doc', $field) && !\is_string($field['doc'])) {
            $this->error($path . '.doc', 'expected a string');
        }
        if (\array_key_exists('enum', $field)) {
            $this->enum($path . '.enum', $field['enum']);
        }
        $this->template($path, $field, $placeholder);
    }

    /**
     * @return string|null the text inside `{…}` when the name is templated
     */
    private function fieldName(string $path, mixed $name): ?string
    {
        if (!$this->nonEmptyString($path, $name)) {
            return null;
        }
        /** @var string $name */
        $bare = \preg_replace('/\[\]$/', '', $name) ?? $name;
        if (\str_contains($bare, '[') || \str_contains($bare, ']')) {
            $this->error($path, 'only a trailing "[]" may appear in a field name');

            return null;
        }
        $matches = [];
        $count   = \preg_match_all('/\{([^{}]*)\}/', $bare, $matches);
        if ($count === false || \substr_count($bare, '{') !== $count || \substr_count($bare, '}') !== $count) {
            $this->error($path, 'malformed "{placeholder}" segment');

            return null;
        }
        if ($count > 1) {
            $this->error($path, 'at most one "{placeholder}" segment is allowed');

            return null;
        }
        if ($count === 0) {
            return null;
        }
        $placeholder = $matches[1][0];
        if ($placeholder === '') {
            $this->error($path, 'empty "{placeholder}" segment');

            return null;
        }

        return $placeholder;
    }

    /**
     * @param array<mixed> $field
     */
    private function template(string $path, array $field, ?string $placeholder): void
    {
        $hasTemplate = \array_key_exists('template', $field);
        if ($placeholder === null) {
            if ($hasTemplate) {
                $this->error($path . '.template', 'template is only allowed on a name with a "{placeholder}" segment');
            }

            return;
        }
        if (!$hasTemplate) {
            $this->error($path . '.template', 'a name with a "{placeholder}" segment requires template');

            return;
        }
        if (!$this->nonEmptyString($path . '.template', $field['template'])) {
            return;
        }
        if ($field['template'] !== $placeholder) {
            $this->error(
                $path . '.template',
                \sprintf('expected "%s" to match the name placeholder', $placeholder),
            );
        }
    }

    private function enum(string $path, mixed $enum): void
    {
        if (!\is_array($enum) || $enum === []) {
            $this->error($path, 'expected a map of either {table,id,label} or {service}');

            return;
        }
        $isService = \array_key_exists('service', $enum);
        $isTable   = \array_key_exists('table', $enum) || \array_key_exists('id', $enum)
            || \array_key_exists('label', $enum);
        if ($isService && $isTable) {
            $this->error($path, 'expected either {table,id,label} or {service}, not both');

            return;
        }
        if ($isService) {
            $this->keys($path, $enum, ['service'], ['service']);
            $this->nonEmptyString($path . '.service', $enum['service']);

            return;
        }
        $this->keys($path, $enum, ['table', 'id', 'label'], ['table', 'id', 'label']);
        foreach (['table', 'id', 'label'] as $key) {
            if (\array_key_exists($key, $enum)) {
                $this->nonEmptyString($path . '.' . $key, $enum[$key]);
            }
        }
    }

    /**
     * @param list<string> $arguments
     */
    private function verify(string $path, mixed $verify, array $arguments): void
    {
        if (!$this->nonEmptyList($path, $verify)) {
            return;
        }
        foreach (self::items($verify) as $index => $assertion) {
            $assertionPath = $path . '[' . $index . ']';
            if (!\is_array($assertion)) {
                $this->error($assertionPath, 'expected a map');
                continue;
            }
            $this->keys($assertionPath, $assertion, ['kind', 'query', 'expect'], ['kind', 'query', 'expect']);
            $kind = $assertion['kind'] ?? null;
            if (\array_key_exists('kind', $assertion)) {
                $this->oneOf(
                    $assertionPath . '.kind',
                    $kind,
                    \array_map(static fn (VerifyKind $verifyKind): string => $verifyKind->value, VerifyKind::cases()),
                );
            }
            if (\array_key_exists('query', $assertion)) {
                $this->query($assertionPath . '.query', $assertion['query']);
            }
            if (\array_key_exists('expect', $assertion)) {
                $this->expect($assertionPath . '.expect', $assertion['expect'], $kind, $arguments);
            }
        }
    }

    private function query(string $path, mixed $query): void
    {
        if (!$this->nonEmptyString($path, $query)) {
            return;
        }
        /** @var string $query */
        if (\preg_match('/^\s*select\b/i', $query) !== 1) {
            $this->error($path, 'expected a SELECT statement');
        }
        if (\str_contains($query, '?')) {
            $this->error($path, 'expected named parameters (:name), positional "?" is not allowed');
        }
        // The silent half of the same trap: PDO and `ActionInvocation::namedParameters()` both stop
        // the name at the hyphen, so `:redirect-id` binds `:redirect` and the assertion is decided
        // by a parameter nobody declared. Caught here because nothing downstream can see it.
        $matches = [];
        if (\preg_match('/:[A-Za-z_][A-Za-z0-9_]*-[A-Za-z0-9_-]+/', $query, $matches) === 1) {
            $this->error($path, \sprintf(
                'named parameter "%s" contains "-", which ends the name — it would bind "%s"',
                $matches[0],
                \substr($matches[0], 0, (int)\strpos($matches[0], '-')),
            ));
        }
    }

    /**
     * @param list<string> $arguments
     */
    private function expect(string $path, mixed $expect, mixed $kind, array $arguments): void
    {
        if (!\is_array($expect) || $expect === []) {
            $this->error($path, 'expected a non-empty map');

            return;
        }
        if ($kind === VerifyKind::RowCount->value) {
            $this->keys($path, $expect, ['n', 'minimum'], []);
            if (!\array_key_exists('n', $expect) && !\array_key_exists('minimum', $expect)) {
                $this->error($path, 'expected at least one of "n" or "minimum"');
            }
            if (\array_key_exists('minimum', $expect) && (!\is_int($expect['minimum']) || $expect['minimum'] < 0)) {
                $this->error($path . '.minimum', 'expected an int >= 0');
            }
            if (\array_key_exists('n', $expect)) {
                $this->expectedValue($path . '.n', $expect['n'], $arguments);
            }

            return;
        }
        foreach ($expect as $column => $value) {
            $this->expectedValue($path . '.' . $column, $value, $arguments);
        }
    }

    /**
     * @param list<string> $arguments
     */
    private function expectedValue(string $path, mixed $value, array $arguments): void
    {
        if (\is_string($value) && Placeholder::looksLikeOne($value)) {
            $this->placeholder($path, $value, $arguments);

            return;
        }
        if (!\is_scalar($value)) {
            $this->error($path, 'expected a scalar or a placeholder string');
        }
    }

    /**
     * @param list<string> $arguments
     */
    private function placeholder(string $path, string $value, array $arguments): void
    {
        $placeholder = Placeholder::parse($value);
        if ($placeholder === null) {
            // A declared field that merely cannot be NAMED is the confusing case: the author did
            // everything right and the generic message points at the expression instead of at the
            // field name that is the actual dead end.
            $target = Placeholder::target($value);
            $this->error(
                $path,
                $target !== null && \in_array($target, $arguments, true) && !Placeholder::isReferenceable($target)
                    ? \sprintf(
                        'field "%s" cannot be referenced: an argument name must match '
                        . '[A-Za-z_][A-Za-z0-9_]* — "required: true" does not make it reachable',
                        $target,
                    )
                    : 'expected exactly "{{args.<field>}}" or "{{count(args.<field>)}}"',
            );

            return;
        }
        if (!\in_array($placeholder->field, $arguments, true)) {
            $this->error($path, \sprintf('placeholder references undeclared field "%s"', $placeholder->field));
        }
    }

    /**
     * @param list<string> $arguments
     */
    private function journal(string $path, mixed $journal, array $arguments): void
    {
        if (!\is_array($journal)) {
            $this->error($path, 'expected a map');

            return;
        }
        $this->keys($path, $journal, ['scope', 'undo', 'maxRows'], ['scope', 'undo', 'maxRows']);
        if (\array_key_exists('undo', $journal)) {
            $this->oneOf(
                $path . '.undo',
                $journal['undo'],
                \array_map(static fn (UndoStrategy $strategy): string => $strategy->value, UndoStrategy::cases()),
            );
        }
        if (\array_key_exists('maxRows', $journal)) {
            $maxRows = $journal['maxRows'];
            if (!\is_int($maxRows) || $maxRows < 1 || $maxRows > 100000) {
                $this->error($path . '.maxRows', 'expected an int between 1 and 100000');
            }
        }
        if (!\array_key_exists('scope', $journal) || !$this->nonEmptyList($path . '.scope', $journal['scope'])) {
            return;
        }
        foreach (self::items($journal['scope']) as $index => $scope) {
            $scopePath = $path . '.scope[' . $index . ']';
            if (!\is_array($scope)) {
                $this->error($scopePath, 'expected a map');
                continue;
            }
            $this->journalScope($scopePath, $scope, $arguments);
        }
    }

    /**
     * @param array<mixed>  $scope
     * @param list<string> $arguments
     */
    private function journalScope(string $path, array $scope, array $arguments): void
    {
        $this->keys($path, $scope, ['table', 'by', 'columns', 'note'], ['table']);
        if (\array_key_exists('table', $scope)) {
            $this->nonEmptyString($path . '.table', $scope['table']);
        }

        if (\array_key_exists('by', $scope)) {
            if (!\is_array($scope['by']) || $scope['by'] === []) {
                $this->error($path . '.by', 'expected a non-empty map');
            } else {
                foreach ($scope['by'] as $column => $value) {
                    $this->expectedValue($path . '.by.' . $column, $value, $arguments);
                }
            }
        }
        if (\array_key_exists('columns', $scope)) {
            $this->stringList($path . '.columns', $scope['columns']);
        }
        if (\array_key_exists('note', $scope) && !\is_string($scope['note'])) {
            $this->error($path . '.note', 'expected a string');
        }
    }

    private function idResolution(string $path, mixed $idResolution): void
    {
        if (!\is_array($idResolution)) {
            $this->error($path, 'expected a map');

            return;
        }
        $this->keys($path, $idResolution, ['table', 'key', 'mode'], ['table', 'key', 'mode']);
        foreach (['table', 'key'] as $key) {
            if (\array_key_exists($key, $idResolution)) {
                $this->nonEmptyString($path . '.' . $key, $idResolution[$key]);
            }
        }
        if (\array_key_exists('mode', $idResolution)) {
            $this->oneOf(
                $path . '.mode',
                $idResolution['mode'],
                \array_map(static fn (IdResolutionMode $mode): string => $mode->value, IdResolutionMode::cases()),
            );
        }
    }

    private function undo(string $path, mixed $undo): void
    {
        if (!\is_array($undo)) {
            $this->error($path, 'expected a map');

            return;
        }
        $this->keys($path, $undo, ['supported', 'tables', 'cannotUndo'], ['supported']);
        if (\array_key_exists('supported', $undo)) {
            $this->bool($path . '.supported', $undo['supported']);
        }

        if (\array_key_exists('tables', $undo)) {
            $this->optionalStringList($path . '.tables', $undo['tables']);
        }
        if (\array_key_exists('cannotUndo', $undo)) {
            $this->cannotUndo($path . '.cannotUndo', $undo['cannotUndo']);
        }
        if (($undo['supported'] ?? null) === true && !$this->isNonEmptyList($undo['tables'] ?? null)) {
            $this->error($path . '.tables', 'expected a non-empty list when supported is true');
        }
    }

    /**
     * `cannotUndo` takes either a plain list — served to every caller, which is what the shipped
     * entries use — or a `de`/`en` map, on the same terms as `title:`. Both languages are required
     * once the map form is chosen, so half-translating an entry is an authoring error rather than a
     * caller in the wrong language silently getting nothing.
     */
    private function cannotUndo(string $path, mixed $value): void
    {
        // `array_is_list([])` is true, so an empty `cannotUndo:` takes the plain-list branch below.
        if (\is_array($value) && !\array_is_list($value)) {
            foreach ($value as $language => $reasons) {
                $this->optionalStringList($path . '.' . $language, $reasons);
            }
            foreach (self::REQUIRED_TITLE_LANGUAGES as $language) {
                if (!\array_key_exists($language, $value)) {
                    $this->error($path, \sprintf('missing required language "%s"', $language));
                }
            }

            return;
        }

        $this->optionalStringList($path, $value);
    }

    /**
     * A list of strings that may legitimately be empty — unlike {@see self::stringList()}, which
     * rejects `[]`. `undo.tables` and `undo.cannotUndo` have always allowed it.
     */
    private function optionalStringList(string $path, mixed $value): void
    {
        if (!\is_array($value) || !\array_is_list($value)) {
            $this->error($path, 'expected a list of strings');

            return;
        }
        foreach (self::items($value) as $index => $item) {
            $this->nonEmptyString($path . '[' . $index . ']', $item);
        }
    }

    /**
     * @param list<string> $arguments the entry's declared field names, base-named
     */
    private function coRequired(string $path, mixed $coRequired, array $arguments): void
    {
        if (!$this->nonEmptyList($path, $coRequired)) {
            return;
        }
        foreach (self::items($coRequired) as $index => $rule) {
            $rulePath = $path . '[' . $index . ']';
            if (!\is_array($rule)) {
                $this->error($rulePath, 'expected a map');
                continue;
            }
            $this->keys($rulePath, $rule, ['fields', 'rule', 'check'], ['fields', 'rule']);
            if (\array_key_exists('fields', $rule)) {
                $this->stringList($rulePath . '.fields', $rule['fields']);
            }
            if (\array_key_exists('rule', $rule)) {
                $this->nonEmptyString($rulePath . '.rule', $rule['rule']);
            }
            if (\array_key_exists('check', $rule)) {
                $this->coRequirementCheck($rulePath, $rule, $arguments);
            }
        }
    }

    /**
     * D38: `check:` is what makes a co-requirement evaluable, so it is held to a stricter standard
     * than the prose next to it. `fields:` is otherwise free text — it names pairs such as
     * `bis[]/preis[]` — but a rule the validator will RUN must name declared arguments, or it would
     * silently check nothing and report a pass.
     *
     * @param array<mixed> $rule
     * @param list<string> $arguments
     */
    private function coRequirementCheck(string $path, array $rule, array $arguments): void
    {
        if (!$this->oneOf($path . '.check', $rule['check'], CoRequirementCheck::names())) {
            return;
        }
        $fields = \is_array($rule['fields'] ?? null) ? $rule['fields'] : [];
        if (\count($fields) < 2) {
            $this->error($path . '.fields', 'expected at least two fields when check is set');

            return;
        }
        foreach ($fields as $offset => $field) {
            if (!\is_string($field) || !\in_array(Placeholder::baseName($field), $arguments, true)) {
                $this->error(
                    $path . '.fields[' . $offset . ']',
                    'expected a declared field name when check is set; prose pairs such as '
                    . '"bis[]/preis[]" cannot be evaluated — drop check to keep the rule as prose',
                );
            }
        }
    }

    /**
     * §4 (G3). What is checkable HERE is shape: exactly one of `action:`/`guide:`, a `when:` that
     * says under which condition the edge applies, and no more edges than
     * {@see ActionDescriber} will render.
     *
     * What is deliberately NOT checked here is whether the target exists. Ids are unique over the
     * WHOLE catalog but declared file by file, so `shipping.yaml` naming `payment.method.saveConfig`
     * is a forward reference this validator would have to reject or ignore depending on load order —
     * and a rule that depends on load order is worse than no rule. Guides live in a different
     * directory this class never reads at all. Both directions are covered instead by
     * {@see \Plugin\tnt_mcp\Tests\Unit\NextStepsTest}, which sees the assembled catalog.
     *
     * @param mixed $ownId the entry's own id, to reject a self-loop
     */
    private function nextSteps(string $path, mixed $nextSteps, mixed $ownId): void
    {
        if (!$this->nonEmptyList($path, $nextSteps)) {
            return;
        }
        if (\count(self::items($nextSteps)) > self::NEXT_STEPS_LIMIT) {
            $this->error($path, \sprintf(
                'expected at most %d entries — describe_admin_action renders no more, so a further '
                . 'one would ship unread',
                self::NEXT_STEPS_LIMIT
            ));
        }
        foreach (self::items($nextSteps) as $index => $step) {
            $stepPath = $path . '[' . $index . ']';
            if (!\is_array($step)) {
                $this->error($stepPath, 'expected a map');
                continue;
            }
            $this->keys($stepPath, $step, ['action', 'guide', 'when'], ['when']);
            $this->nonEmptyString($stepPath . '.when', $step['when'] ?? null);

            $hasAction = \array_key_exists('action', $step);
            $hasGuide  = \array_key_exists('guide', $step);
            if ($hasAction === $hasGuide) {
                $this->error($stepPath, 'expected exactly one of "action" or "guide"');
                continue;
            }
            if ($hasGuide) {
                $this->nonEmptyString($stepPath . '.guide', $step['guide']);
                continue;
            }
            if (!$this->pattern($stepPath . '.action', $step['action'], self::ID_PATTERN)) {
                continue;
            }
            if ($step['action'] === $ownId) {
                $this->error($stepPath . '.action', 'an action cannot be its own next step');
            }
        }
    }

    private function evidence(string $path, mixed $evidence): void
    {
        if (!$this->nonEmptyList($path, $evidence)) {
            return;
        }
        foreach (self::items($evidence) as $index => $reference) {
            $referencePath = $path . '[' . $index . ']';
            if (!\is_array($reference)) {
                $this->error($referencePath, 'expected a map');
                continue;
            }
            $this->keys($referencePath, $reference, ['file', 'contains'], ['file', 'contains']);
            foreach (['file', 'contains'] as $key) {
                if (\array_key_exists($key, $reference)) {
                    $this->nonEmptyString($referencePath . '.' . $key, $reference[$key]);
                }
            }
        }
    }

    /**
     * The argument names a placeholder may point at: field names with `[]` and any `{…}` segment
     * stripped.
     *
     * @return list<string>
     */
    private function declaredArguments(mixed $fields): array
    {
        $arguments = [];
        foreach (\is_array($fields) ? $fields : [] as $field) {
            $name = \is_array($field) ? ($field['name'] ?? null) : null;
            if (\is_string($name) && $name !== '') {
                $arguments[] = Placeholder::baseName($name);
            }
        }

        return $arguments;
    }

    /**
     * @param array<mixed>  $map
     * @param list<string> $allowed
     * @param list<string> $required
     */
    private function keys(string $path, array $map, array $allowed, array $required): void
    {
        foreach (\array_keys($map) as $key) {
            if (!\in_array((string)$key, $allowed, true)) {
                $this->error($path === '' ? (string)$key : $path . '.' . $key, \sprintf('unknown key "%s"', $key));
            }
        }
        foreach ($required as $key) {
            if (!\array_key_exists($key, $map)) {
                $this->error($path === '' ? $key : $path . '.' . $key, \sprintf('missing required key "%s"', $key));
            }
        }
    }

    private function stringList(string $path, mixed $value): void
    {
        if (!$this->nonEmptyList($path, $value)) {
            return;
        }
        foreach (self::items($value) as $index => $item) {
            $this->nonEmptyString($path . '[' . $index . ']', $item);
        }
    }

    private function nonEmptyList(string $path, mixed $value): bool
    {
        if ($this->isNonEmptyList($value)) {
            return true;
        }
        $this->error($path, 'expected a non-empty list');

        return false;
    }

    private function isNonEmptyList(mixed $value): bool
    {
        return \is_array($value) && $value !== [] && \array_is_list($value);
    }

    private function nonEmptyString(string $path, mixed $value): bool
    {
        if (\is_string($value) && \trim($value) !== '') {
            return true;
        }
        $this->error($path, 'expected a non-empty string');

        return false;
    }

    private function bool(string $path, mixed $value): void
    {
        if (!\is_bool($value)) {
            $this->error($path, 'expected a bool');
        }
    }

    /**
     * @param list<string> $allowed
     */
    private function oneOf(string $path, mixed $value, array $allowed): bool
    {
        if (\is_string($value) && \in_array($value, $allowed, true)) {
            return true;
        }
        $this->error($path, \sprintf('expected one of: %s', \implode(', ', $allowed)));

        return false;
    }

    /**
     * @param non-empty-string $pattern
     */
    private function pattern(string $path, mixed $value, string $pattern): bool
    {
        if (!$this->nonEmptyString($path, $value)) {
            return false;
        }
        /** @var string $value */
        if (\preg_match($pattern, $value) !== 1) {
            $this->error($path, \sprintf('expected to match %s', $pattern));

            return false;
        }

        return true;
    }

    /**
     * Re-indexed so a reported index always matches the document position, even for a map that
     * only looked like a list.
     *
     * @return list<mixed>
     */
    private static function items(mixed $value): array
    {
        return \is_array($value) ? \array_values($value) : [];
    }

    private function error(string $path, string $message): void
    {
        $this->errors[] = CatalogValidationError::at($this->file, $path, $message);
    }
}
