<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Batch;

/**
 * The cheapest check there is: do these arguments even fit the tool's own input schema?
 *
 * ## Why a dry run needs it
 *
 * {@see \Plugin\tnt_mcp\src\Mcp\Contracts\CheckableTool} gives a deep check to the tools that can
 * offer one, but two kinds of batch entry get nothing from it: a tool with no write-free equivalent
 * (`set_schedule`, `delete_workflow`), and an entry whose arguments carry a `{{N.path}}` reference
 * to an object that a dry run never created. Reporting those as simply "not checked" would leave the
 * commonest model error of all — a mistyped or invented argument name — to be discovered halfway
 * through the real write, which is the exact failure `dryRun` exists to prevent.
 *
 * The schema is already on the wire in `tools/list`, so this costs nothing to obtain and nothing to
 * maintain: it is the same object the model was given, read back.
 *
 * ## What it deliberately does not do
 *
 * It is NOT a JSON-Schema validator. It reads `required`, `additionalProperties` and top-level
 * `type` — the three keywords that catch a wrong argument object — and ignores everything else
 * (`enum`, `minimum`, nested object schemas, `format`). A tool's own validator knows its values far
 * better than a generic walker ever will, so guessing further here would produce confident errors
 * about arguments that are in fact fine. A shape check that never cries wolf is worth having; one
 * that does would teach the model to ignore `dryRun`.
 *
 * A value holding a reference is exempt from the type check for the obvious reason: `"{{0.id}}"` is
 * a string today and an integer the moment the batch runs for real.
 */
final class ArgumentShape
{
    /**
     * @param array<string,mixed> $args   the entry's arguments, references NOT resolved
     * @param array<string,mixed> $schema the tool's own `inputSchema()`
     *
     * @return list<string> every problem found, empty when the shape is fine
     */
    public static function problems(array $args, array $schema): array
    {
        $properties = \is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
        $problems   = [];

        foreach (\is_array($schema['required'] ?? null) ? $schema['required'] : [] as $key) {
            if (\is_string($key) && !\array_key_exists($key, $args)) {
                $problems[] = 'missing required argument "' . $key . '"';
            }
        }

        $closed = ($schema['additionalProperties'] ?? null) === false;
        foreach ($args as $key => $value) {
            $key = (string)$key;
            $spec = $properties[$key] ?? null;
            if (!\is_array($spec)) {
                if ($closed) {
                    $problems[] = 'unknown argument "' . $key . '"' . self::didYouMean($key, $properties);
                }
                continue;
            }
            $wrong = self::mistyped($value, $spec['type'] ?? null);
            if ($wrong !== null) {
                $problems[] = '"' . $key . '" must be ' . $wrong['want'] . ', got ' . $wrong['got'];
            }
        }

        return $problems;
    }

    /**
     * @param mixed $type the schema's `type`; anything but a single string keyword is not judged
     *
     * @return array{want:string,got:string}|null
     */
    private static function mistyped(mixed $value, mixed $type): ?array
    {
        if (!\is_string($type) || self::holdsReference($value)) {
            return null;
        }
        $actual = match (true) {
            \is_bool($value)                        => 'boolean',
            \is_int($value)                         => 'integer',
            \is_float($value)                       => 'number',
            \is_string($value)                      => 'string',
            \is_array($value) && \array_is_list($value) => 'array',
            \is_array($value)                       => 'object',
            default                                 => 'null',
        };
        if ($actual === $type) {
            return null;
        }
        // An integer satisfies `number`, and an empty PHP array is both a list and an object.
        if ($type === 'number' && $actual === 'integer') {
            return null;
        }
        if ($value === [] && ($type === 'array' || $type === 'object')) {
            return null;
        }

        return ['want' => $type, 'got' => $actual];
    }

    /**
     * A reference anywhere inside the value makes its type unknowable until the batch runs. The
     * syntax is asked of {@see BatchReferences} rather than matched again here — one regex, one
     * place, or the day the syntax gains a form this class would silently stop exempting it.
     */
    private static function holdsReference(mixed $value): bool
    {
        return BatchReferences::areUsedIn(['value' => $value]);
    }

    /**
     * A misspelled argument is the whole reason this class exists, so when the intended name is
     * obvious, say it — "unknown argument" alone costs the model another turn to guess again.
     *
     * @param array<string,mixed> $properties
     */
    private static function didYouMean(string $key, array $properties): string
    {
        $best     = '';
        $distance = \PHP_INT_MAX;
        foreach (\array_keys($properties) as $candidate) {
            $candidate = (string)$candidate;
            $d         = \levenshtein(\strtolower($key), \strtolower($candidate));
            if ($d < $distance) {
                $distance = $d;
                $best     = $candidate;
            }
        }
        if ($best === '' || $distance > (int)\max(2, \floor(\strlen($key) / 3))) {
            return '';
        }

        return ' — did you mean "' . $best . '"?';
    }
}
