<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

use JTL\DB\DbInterface;
use Throwable;

/**
 * Pre-flight for `invoke_admin_action` (design round 4 §3, D37): "is this payload coherent?",
 * answered without touching the shop.
 *
 * ## Why this exists next to dryRun
 *
 * `dryRun` answers the same question far better — by running the real controller and rolling it
 * back — but only for entries with `dryRunnable: true` AND side effects limited to db+cache (D22).
 * Every payment entry fails that test, because saving a payment method calls `flushAll()`, which a
 * rollback cannot undo. This class works for every entry, because it invokes nothing at all.
 *
 * ## What it checks, and what it refuses to guess
 *
 * Three layers, in the order the caller hits them:
 *
 *  1. **Shape** — {@see ArgumentBinder}, reused rather than reimplemented, so the required/unknown/
 *     const rules have exactly one source and its collect-every-error behaviour carries over.
 *  2. **Enums** — the `enum:` sources the describer already declares, resolved against the DB. This
 *     is the layer the binder deliberately cannot do ({@see ArgumentBinder} "what this class does
 *     NOT do"), because it refuses a DB dependency.
 *  3. **Co-requirements** — only those carrying a {@see CoRequirementCheck}. The prose ones are
 *     reported as `unchecked`, never silently treated as satisfied.
 *
 * A `valid: true` therefore means "nothing the catalog can check is wrong" — not "the save will
 * succeed". The tool says so in the response, because an LLM will otherwise skip the read-back.
 */
final class ActionValidator
{
    /** Enum values listed back to the caller when one does not match. */
    private const ENUM_SAMPLE = 8;

    public function __construct(
        private readonly ActionCatalog $catalog,
        private readonly DbInterface $db,
        private readonly ArgumentBinder $binder = new ArgumentBinder(),
    ) {
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    public function validate(string $id, array $args): array
    {
        $spec = $this->catalog->get($id);
        if ($spec === null) {
            return [
                'id'    => $id,
                'valid' => false,
                'errors' => [[
                    'field'   => 'id',
                    'code'    => 'unknown-action',
                    'message' => \sprintf('There is no admin action "%s". Call list_admin_actions.', $id),
                ]],
                'notes' => ['Nothing has been done. This call never writes.'],
            ];
        }

        $errors    = $this->shapeErrors($spec, $args);
        $unchecked = [];
        // Enum and co-requirement checks run even when the shape is wrong: the caller gets every
        // problem in one turn instead of peeling them off one invocation at a time, which is the
        // same reason ArgumentBinder collects rather than throws on the first.
        foreach ($this->enumErrors($spec, $args, $unchecked) as $error) {
            $errors[] = $error;
        }
        foreach ($this->coRequirementErrors($spec, $args, $unchecked) as $error) {
            $errors[] = $error;
        }

        foreach ($spec->prerequisites as $prerequisite) {
            // Prerequisites are prose today; they are echoed so the caller can see WHAT was not
            // machine-checked rather than assuming everything was.
            $unchecked[] = $prerequisite;
        }

        return [
            'id'        => $spec->id,
            'valid'     => $errors === [],
            'errors'    => $errors,
            'unchecked' => $unchecked,
            'notes'     => [
                'Nothing has been done. This call never writes and never invokes the controller.',
                $errors === []
                    ? 'valid: true means nothing the catalog can check is wrong — not that the save '
                        . 'will succeed. Still check verify.ok on the invocation.'
                    : 'Fix the errors and re-validate, or call describe_admin_action for the field contract.',
            ],
        ];
    }

    // ---------------------------------------------------------------------------------------------

    /**
     * @param array<string,mixed> $args
     * @return list<array<string,string>>
     */
    private function shapeErrors(ActionSpec $spec, array $args): array
    {
        try {
            $spec->isAdminIo() ? $this->binder->bindAdminIo($spec, $args) : $this->binder->bind($spec, $args);

            return [];
        } catch (ArgumentBindingException $e) {
            return \array_map(static fn (ArgumentBindingError $error): array => [
                'field'   => $error->field,
                'code'    => 'shape',
                'message' => $error->message,
            ], $e->errors());
        }
    }

    /**
     * @param array<string,mixed> $args
     * @param list<string>        $unchecked
     * @return list<array<string,string>>
     */
    private function enumErrors(ActionSpec $spec, array $args, array &$unchecked): array
    {
        $errors = [];
        foreach ($spec->fields as $field) {
            if ($field->enum === null) {
                continue;
            }
            $values = $this->argumentValues($args, $field);
            if ($values === []) {
                continue;
            }
            if ($field->enum->isService()) {
                // A service enum (ISO codes, languages) is resolved by shop code this class does not
                // boot. Saying so beats a check that silently passes everything.
                $unchecked[] = \sprintf(
                    '%s is validated against the "%s" service, which is not read here.',
                    $field->name,
                    (string)$field->enum->service,
                );
                continue;
            }

            $known = $this->enumValues($field->enum);
            if ($known === null) {
                $unchecked[] = \sprintf(
                    '%s could not be checked: reading %s failed.',
                    $field->name,
                    (string)$field->enum->table,
                );
                continue;
            }
            foreach ($values as $value) {
                if (\array_key_exists($value, $known)) {
                    continue;
                }
                $errors[] = [
                    'field'   => $field->name,
                    'code'    => 'enum',
                    'message' => \sprintf(
                        '%s=%s does not exist in %s. Existing: %s',
                        $field->name,
                        $value,
                        (string)$field->enum->table,
                        $this->sample($known),
                    ),
                ];
            }
        }

        return $errors;
    }

    /**
     * @param array<string,mixed> $args
     * @param list<string>        $unchecked
     * @return list<array<string,string>>
     */
    private function coRequirementErrors(ActionSpec $spec, array $args, array &$unchecked): array
    {
        $errors = [];
        foreach ($spec->coRequired as $rule) {
            if ($rule->check === null) {
                $unchecked[] = \sprintf('%s: %s', \implode(' + ', $rule->fields), $rule->rule);
                continue;
            }
            $message = $this->evaluate($rule, $args);
            if ($message !== null) {
                $errors[] = [
                    'field'   => \implode('/', $rule->fields),
                    'code'    => 'coRequired',
                    'message' => $message . ' — ' . $rule->rule,
                ];
            }
        }

        return $errors;
    }

    /**
     * The predicate itself. Returns null when satisfied, or the sentence that says how it is not.
     *
     * @param array<string,mixed> $args
     */
    private function evaluate(CoRequirement $rule, array $args): ?string
    {
        $present = [];
        $lengths = [];
        foreach ($rule->fields as $field) {
            $value           = $args[Placeholder::baseName($field)] ?? null;
            $present[$field] = $value !== null && $value !== '' && $value !== [];
            $lengths[$field] = \is_array($value) ? \count($value) : null;
        }
        switch ($rule->check) {
            case CoRequirementCheck::SameLength:
                $counts = \array_filter($lengths, static fn (?int $count): bool => $count !== null);
                if (\count($counts) !== \count($rule->fields)) {
                    return \sprintf('%s must all be arrays', \implode(' and ', $rule->fields));
                }
                if (\count(\array_unique($counts)) > 1) {
                    $shape = [];
                    foreach ($counts as $field => $count) {
                        $shape[] = $field . ' has ' . $count;
                    }

                    return \implode(', ', $shape) . ' — they are parallel arrays and must be the same length';
                }
                if (\in_array(0, $counts, true)) {
                    return \sprintf('%s are empty', \implode(' and ', $rule->fields));
                }

                return null;

            case CoRequirementCheck::AllPresent:
                $missing = \array_keys(\array_filter($present, static fn (bool $ok): bool => !$ok));
                if ($missing === []) {
                    return null;
                }

                return \sprintf('missing: %s', \implode(', ', $missing));

            case CoRequirementCheck::AnyPresent:
                return \in_array(true, $present, true)
                    ? null
                    : \sprintf('at least one of %s must be sent', \implode(', ', $rule->fields));

            case CoRequirementCheck::RequiredWhenTruthy:
                $flag = $rule->fields[0];
                if (!$this->truthy($args[Placeholder::baseName($flag)] ?? null)) {
                    return null;
                }
                $dependants = \array_slice($rule->fields, 1);
                $missing    = [];
                foreach ($dependants as $field) {
                    if (!($present[$field] ?? false)) {
                        $missing[] = $field;
                    }
                }
                return $missing === []
                    ? null
                    : \sprintf('%s is on, so %s is required', $flag, \implode(', ', $missing));
        }

        // Unreachable: the enum is exhaustive above. Present because a new predicate must fail
        // OPEN — reported as satisfied-but-unevaluated is a lie; this simply says nothing.
        return null;
    }

    private function truthy(mixed $value): bool
    {
        return $value !== null && $value !== '' && $value !== [] && $value !== '0' && $value !== 0 && $value !== false;
    }

    /**
     * @param array<string,mixed> $args
     * @return list<string> every value the caller sent for this field, scalars and lists alike
     */
    private function argumentValues(array $args, FieldSpec $field): array
    {
        $value = $args[Placeholder::baseName($field->name)] ?? null;
        if ($value === null || $value === '' || $value === []) {
            return [];
        }

        $out = [];
        foreach (\is_array($value) ? $value : [$value] as $item) {
            if (\is_scalar($item)) {
                $out[] = (string)$item;
            }
        }

        return $out;
    }

    /**
     * @return ?array<string,string> id => label, or null when the table could not be read
     */
    private function enumValues(EnumSpec $enum): ?array
    {
        $table = (string)$enum->table;
        $id    = (string)$enum->id;
        $label = (string)$enum->label;
        // Identifiers come from the catalog, which the validator has already constrained — but they
        // are interpolated, so they are re-checked here rather than trusted twice.
        foreach ([$table, $id, $label] as $identifier) {
            if (\preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) {
                return null;
            }
        }

        try {
            $rows = $this->db->getArrays(
                \sprintf('SELECT `%s` AS id, `%s` AS label FROM `%s` LIMIT 500', $id, $label, $table),
            );
        } catch (Throwable) {
            return null;
        }

        $out = [];
        foreach ($rows as $row) {
            $out[(string)($row['id'] ?? '')] = (string)($row['label'] ?? '');
        }

        return $out;
    }

    /** @param array<string,string> $known */
    private function sample(array $known): string
    {
        $parts = [];
        foreach (\array_slice($known, 0, self::ENUM_SAMPLE, true) as $id => $label) {
            $parts[] = $label === '' ? $id : $id . ' (' . $label . ')';
        }
        if (\count($known) > self::ENUM_SAMPLE) {
            $parts[] = '… ' . (\count($known) - self::ENUM_SAMPLE) . ' more';
        }

        return \implode(', ', $parts);
    }
}
