<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

/**
 * Turns caller-supplied arguments into the `$_POST` array
 * {@see \Plugin\tnt_mcp\src\Mcp\Backend\BackendActionInvoker::invokeFormPost()} submits.
 *
 * The counterpart of {@see ActionCatalogValidator}: that one guards the hand-written half of the
 * contract, this one guards the caller's half. Like it, it collects every violation and reports
 * them together ({@see ArgumentBindingException}), and like {@see ActionCatalog} it is **pure** —
 * no DB, no `Shop::`, no superglobals — so it is testable without a booted shop.
 *
 * ## Everything is bound as a string
 *
 * A browser POST carries text, and the shop is written against that. The evidence, not the theory:
 *
 * - `ShippingMethodsController.php:68` runs `Text::filterXSS($_POST)` over the whole array, and
 *   `Helpers/Text.php:311` does `$input = (string)$input;` on every leaf. The shop has never seen a
 *   non-string `$_POST` value.
 * - `ShippingMethodsController.php:792` — `(float)\str_replace(',', '.', $postData['fPreis'] ?? '0')`;
 *   note that even the *default* is the string `'0'`.
 * - `ShippingMethodsController.php:829-830, :836` — `\mb_strlen($postData['bis'][0]) === 0`, a
 *   string-length test used to mean "the user left the box empty". A bound float `0.0` stringifies
 *   to `"0"` (length 1) and would silently pass a check that an empty form field fails.
 * - `PaymentMethodsController.php:442` — `if ($filteredPost['cName_' . $langCode])`, the truthiness
 *   of a string, where `"0"` and `""` are both falsy but `0.0` and `0` would arrive differently.
 *
 * So: scalars bind to `string`, array fields bind to `list<string>`. This class validates the shape
 * of what it is handed and then hands the shop text, exactly as a form would. It never casts on the
 * shop's behalf — the shop's own casts (`Request::pInt()`, `(float)\str_replace(...)`) stay the
 * single source of truth for what a value means.
 *
 * The ONE exception is {@see self::bindAdminIo()} with `params: positional`, and it is not a change
 * of mind: an IO method is called in PHP, not posted to, and `IOController` declares
 * `strict_types=1` (`IOController.php:3`), so `deleteShippingSurcharge(int $surchargeID)` (`:618`)
 * rejects `"5"` with a `TypeError`. The cast happens there, once, driven by the catalog's declared
 * `type:` — never here.
 *
 * ## Array field names lose the `[]`
 *
 * `land[]` binds as `$post['land']`, because that is what PHP itself produces from a form input
 * named `land[]` and therefore what the controller reads: `ShippingMethodsController.php:774`
 * (`$postData['land'] ?? []`), `:883` (`\count($postData['kZahlungsart'] ?? [])`) and
 * `PaymentMethodsController.php:392` (`\is_array($filteredPost['kKundengruppe'])`). The `[]` in the
 * catalog is the *form* spelling, kept because that is how the field is found in the template
 * (`versandarten_neue_Versandart.tpl:583`, `name="land[]"`); it is not part of the key.
 *
 * ## `bool` follows the checkbox, not PHP
 *
 * An HTML checkbox posts its `value` when checked and **nothing at all** when unchecked, and the
 * shop reads exactly that: `benutzer_bearbeiten.tpl:216-219` declares
 * `type="checkbox" name="dGueltigBisAktiv" value="1"` with no hidden `0` companion, and
 * `AdminAccountController.php:467` reads it as `Request::pInt('dGueltigBisAktiv') === 1`. So `true`
 * binds `"1"` and `false` **omits the key** — the state an unchecked box produces. The one
 * exception is a `required: true` bool, where omitting the key would trip the very unguarded
 * dereference `required` exists to prevent; there `false` binds `"0"`.
 *
 * (Flags the shop spells `Y`/`N` — `cSendConfirmationMail`, `bOpenEnd` at
 * `CouponsController.php:387` — are `type: string` in the catalog, not `bool`, and are unaffected.)
 *
 * ## What this class deliberately does NOT do
 *
 * - **`enum:` is not enforced.** {@see EnumSpec} resolves against the database (`tzahlungsart`,
 *   `tkundengruppe`, or a named service), and acquiring a connection here would cost this class the
 *   purity that lets it run in a unit test and in a build-time check. An id that does not exist is
 *   therefore *not* rejected here; enforcement belongs at invocation, next to the connection that
 *   can answer the question. Until then it fails the way the shop fails it — a mapping row pointing
 *   at nothing, caught by the entry's `verify:` clause.
 * - **`coRequired:` is not enforced.** Those rules are prose (`"same length, both non-empty"`),
 *   addressed to the model that is composing the call, not a machine-checkable grammar.
 * - **No defaulting.** An absent optional field is absent from `$_POST`, never an empty string:
 *   several controllers branch on presence rather than value (`kKundengruppe` defaults to `['-1']`
 *   only `if (!isset($postData['kKundengruppe']))`, `ShippingMethodsController.php:863-865`), so an
 *   empty string would take a different branch than sending nothing.
 */
final class ArgumentBinder
{
    /** @var list<ArgumentBindingError> */
    private array $errors = [];

    /**
     * @param LanguageCodes $languages the ONLY thing this class cannot answer from the catalog: the
     *                                 key set of a `{code}`/`{iso}` field. Injected, not fetched, so
     *                                 the no-DB rule above still holds — see {@see LanguageCodes}.
     */
    public function __construct(
        private readonly LanguageCodes $languages = new ShopLanguageCodes(),
    ) {
    }

    /**
     * @param array<string,mixed> $args caller-supplied arguments, keyed by {@see FieldSpec::argumentName()}
     * @return array<string,mixed> the `$_POST` array `invokeFormPost()` takes; values are `string`
     *                             or `list<string>`, keys in catalog declaration order
     * @throws ArgumentBindingException carrying EVERY violation, not just the first
     * @throws \LogicException on an `adminIo` entry — that is a wrong call, not a caller error
     */
    public function bind(ActionSpec $action, array $args): array
    {
        if ($action->isAdminIo()) {
            throw new \LogicException(\sprintf(
                '"%s" is an adminIo action and has no $_POST array — call bindAdminIo() instead.',
                $action->id
            ));
        }

        return $this->bindFields($action, $args);
    }

    /**
     * The `adminIo` counterpart: the same validation and the same string binding, reshaped into the
     * argument list an IO method is called with.
     *
     * The rules are NOT forked — required, unknown-argument, const-forbidden and collect-every-error
     * are one implementation, because two would drift and the second one would be the lenient one.
     * What differs is only the last step:
     *
     * - {@see AdminIoParams::FormData} → ONE argument, the `list<array{name,value}>` the browser's
     *   `serializeArray()` produces and `IOController.php:565`/`:685` flattens. Built from the bound
     *   `$_POST` array, so the pairs come out in catalog declaration order.
     * - {@see AdminIoParams::Positional} → one argument per field, in declaration order, cast to the
     *   PHP type the signature names. This is the ONE place the everything-is-a-string rule does not
     *   hold, and it does not hold because `IOController` declares `strict_types=1` (`:3`): `"5"`
     *   passed to `deleteShippingSurcharge(int $surchargeID)` (`:618`) is a `TypeError`.
     *
     * @param array<string,mixed> $args caller-supplied arguments, keyed by {@see FieldSpec::argumentName()}
     * @return list<mixed> the arguments to spread into the IO method call
     * @throws ArgumentBindingException carrying EVERY violation, not just the first
     */
    public function bindAdminIo(ActionSpec $action, array $args): array
    {
        if (!$action->isAdminIo() || $action->params === null) {
            throw new \LogicException(\sprintf(
                '"%s" is not an adminIo action with a declared params shape — call bind() instead.',
                $action->id
            ));
        }
        $post = $this->bindFields($action, $args);

        return $action->params === AdminIoParams::FormData
            ? [self::pairs($post)]
            : self::positional($action, $post);
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     * @throws ArgumentBindingException
     */
    private function bindFields(ActionSpec $action, array $args): array
    {
        $this->errors = [];

        $post = [];
        $this->rejectUnknown($action, $args);
        foreach ($action->fields as $field) {
            $this->field($action, $field, $args, $post);
        }
        if ($this->errors !== []) {
            throw ArgumentBindingException::fromErrors($action->id, $this->errors);
        }

        return $post;
    }

    /**
     * `$_POST` as the `{name, value}` pair list `serializeArray()` would have sent. Insertion order
     * of the bound array is catalog declaration order, and it is preserved here because the
     * flattening controller keeps the LAST pair of a repeated name — order is meaning, not cosmetics.
     *
     * A list value emits one pair per element, exactly as a browser would; the validator forbids
     * array fields on a `formData` entry precisely because that flatten would then keep only the
     * last one.
     *
     * @param array<string,mixed> $post
     * @return list<array{name:string,value:string}>
     */
    private static function pairs(array $post): array
    {
        $pairs = [];
        foreach ($post as $name => $value) {
            foreach (\is_array($value) ? $value : [$value] as $item) {
                $pairs[] = ['name' => (string)$name, 'value' => \is_scalar($item) ? (string)$item : ''];
            }
        }

        return $pairs;
    }

    /**
     * One argument per declared field, cast by {@see FieldSpec::$type}.
     *
     * An absent key here is a catalog defect, not a caller error: the validator requires every field
     * of a `positional` entry to be `required: true` for exactly this reason — silently skipping one
     * would shift every later argument one place left and hand the method a value for the wrong
     * parameter. So it throws {@see \LogicException}, the same way calling the wrong bind method does.
     *
     * @param array<string,mixed> $post
     * @return list<mixed>
     */
    private static function positional(ActionSpec $action, array $post): array
    {
        $arguments = [];
        foreach ($action->fields as $field) {
            $name = $field->argumentName();
            if (!\array_key_exists($name, $post)) {
                throw new \LogicException(\sprintf(
                    '"%s" declares params: positional but field "%s" bound nothing — a positional '
                    . 'argument cannot be skipped; the field must be required: true.',
                    $action->id,
                    $field->name
                ));
            }
            $value       = $post[$name];
            $text        = \is_scalar($value) ? (string)$value : '';
            $arguments[] = match ($field->type) {
                'int'     => (int)$text,
                'decimal' => (float)\str_replace(',', '.', $text),
                'bool'    => $text === '1',
                default   => $text,
            };
        }

        return $arguments;
    }

    /**
     * R1(d): a caller typo must be loud. A `const` gets its own message because it is not a typo but
     * an attempt to steer the dispatch switch (`neueVersandart`, `einstellungen_bearbeiten`) that
     * decides *which branch of the controller runs* — the one thing a caller must never choose.
     *
     * @param array<string,mixed> $args
     */
    private function rejectUnknown(ActionSpec $action, array $args): void
    {
        $bindable = [];
        $constant = [];
        foreach ($action->fields as $field) {
            if ($field->isConstant()) {
                $constant[] = $field->argumentName();
                continue;
            }
            $bindable[] = $field->argumentName();
        }
        foreach (\array_keys($args) as $name) {
            $name = (string)$name;
            if (\in_array($name, $constant, true)) {
                $this->error($name, 'is a constant set by the catalog and must not be supplied');
                continue;
            }
            if (!\in_array($name, $bindable, true)) {
                $this->error($name, \sprintf(
                    'unknown argument; expected one of: %s',
                    $bindable === [] ? '(none)' : \implode(', ', $bindable),
                ));
            }
        }
    }

    /**
     * @param array<string,mixed> $args
     * @param array<string,mixed> $post
     */
    private function field(ActionSpec $action, FieldSpec $field, array $args, array &$post): void
    {
        if ($field->isConstant()) {
            // Always emitted, never negotiable. The validator has already guaranteed a non-empty value.
            $post[$field->argumentName()] = (string)$field->value;

            return;
        }
        if ($field->isTemplated()) {
            $this->templated($action, $field, $args, $post);

            return;
        }
        $argument = $field->argumentName();
        if (!\array_key_exists($argument, $args)) {
            $this->missing($field, $argument);

            return;
        }
        if ($field->isArray()) {
            $this->arrayField($field, $argument, $args[$argument], $post);

            return;
        }
        if ($field->type === 'bool') {
            $this->boolField($field, $argument, $args[$argument], $post);

            return;
        }
        $bound = $this->scalar($field->type, $args[$argument], $argument);
        if ($bound !== null) {
            $post[$argument] = $bound;
        }
    }

    /**
     * R10: `required` marks the fields a controller dereferences with no `??` — `cAnzeigen`
     * (`ShippingMethodsController.php:783`), `cBild` (`PaymentMethodsController.php:415`) — plus the
     * four whose absence makes the save write nothing and report success anyway (`:879-884`).
     * `phpunit.xml.dist` sets `failOnWarning="true"`, so guessing here turns into a red test at
     * best and a half-written row at worst.
     */
    private function missing(FieldSpec $field, string $argument): void
    {
        if (!$field->required) {
            // Rule 5: omitted entirely. NOT an empty string — controllers branch on isset().
            return;
        }
        $this->error($argument, \sprintf(
            'missing required argument of type %s%s',
            $field->type,
            $field->doc === null ? '' : ' — ' . $field->doc,
        ));
    }

    /**
     * @param array<string,mixed> $post
     */
    private function arrayField(FieldSpec $field, string $argument, mixed $value, array &$post): void
    {
        if (!\is_array($value) || !\array_is_list($value)) {
            $this->error($argument, \sprintf(
                'expected a list of %s, got %s',
                \substr($field->type, 0, -2),
                self::typeOf($value),
            ));

            return;
        }
        if ($value === [] && $field->required) {
            $this->error($argument, 'expected a non-empty list');

            return;
        }
        $element = \substr($field->type, 0, -2);
        $bound   = [];
        foreach ($value as $index => $item) {
            $item = $this->scalar($element, $item, $argument . '[' . $index . ']');
            if ($item !== null) {
                $bound[] = $item;
            }
        }
        if (\count($bound) === \count($value)) {
            $post[$argument] = $bound;
        }
    }

    /**
     * @param array<string,mixed> $post
     */
    private function boolField(FieldSpec $field, string $argument, mixed $value, array &$post): void
    {
        if (!\is_bool($value)) {
            $this->error($argument, \sprintf(
                'expected a bool, got %s — this field is a checkbox, not a value',
                self::typeOf($value),
            ));

            return;
        }
        if ($value === true) {
            $post[$argument] = '1';

            return;
        }
        if ($field->required) {
            // The key has to exist or the unguarded dereference that made it `required` warns.
            $post[$argument] = '0';
        }
        // Optional + false: omitted, exactly like an unchecked box.
    }

    /**
     * A templated field is one `$_POST` key per value of the thing named by `template:` —
     * `fAufpreis_{kZahlungsart}` becomes `fAufpreis_3`, `fAufpreis_7`
     * (`ShippingMethodsController.php:1084`), `cName_{code}` becomes `cName_ger`
     * (`ShippingMethodsController.php:929`).
     *
     * **The caller supplies a MAP**, `{"fAufpreis_": {"3": "1.50", "7": "0"}}`, under
     * {@see FieldSpec::argumentName()} (which strips the `{…}` segment, leaving the trailing
     * underscore). Two reasons it is a map and not a list parallel to the driving field:
     *
     * 1. Half the templated fields in the shipped catalog are driven by something that is **not a
     *    catalog field at all** — `cName_{code}` and `cName_{iso}` are keyed by the shop's installed
     *    language codes, which come from `LanguageHelper` (`ShippingMethodsController.php:924-927`,
     *    `PaymentMethodsController.php:438-440`), i.e. from the database. A generation scheme that
     *    derived the keys from another *argument* could never bind them; the codes arrive through
     *    the injected {@see LanguageCodes} instead, so no query is issued from here.
     * 2. A parallel list re-introduces the silent-misalignment failure that `bis[]`/`preis[]`
     *    already demonstrates: shift by one and every surcharge lands on the wrong payment method,
     *    with no error anywhere.
     *
     * When the placeholder *does* name a declared field, the two are cross-checked: every map key
     * must be one of the values the caller sent for it. That is the "expands once per value of the
     * driving field" rule, enforced rather than assumed — a surcharge for a payment method that is
     * not in `kZahlungsart[]` is dropped on the floor by `:809-811` and would otherwise be a silent
     * no-op.
     *
     * ## Every key is checked against something — there is no "anything goes" branch
     *
     * A map key is not data, it is **half a `$_POST` key**: `$post[cName_ . $key]`. Leaving it
     * unconstrained when the placeholder is outside the catalog let a caller set an arbitrary key
     * under a fixed prefix, and a controller that reads `$_POST` by a name we did not anticipate
     * would then read caller-chosen text. So the two cases are symmetric: a catalog placeholder is
     * checked against the driving argument, a `{code}`/`{iso}` placeholder against the shop's
     * language codes ({@see LanguageCodes}), and when *neither* set can be established the field
     * does not bind at all. Fail closed: an unbindable localised name is a visible error the caller
     * can act on, an unchecked key is not.
     *
     * @param array<string,mixed> $args
     * @param array<string,mixed> $post
     */
    private function templated(ActionSpec $action, FieldSpec $field, array $args, array &$post): void
    {
        $argument    = $field->argumentName();
        $placeholder = '{' . (string)$field->template . '}';
        if (!\array_key_exists($argument, $args)) {
            if ($field->required) {
                $this->error($argument, \sprintf(
                    'missing required argument: a map of %s value => %s, one entry per %s',
                    (string)$field->template,
                    $field->type,
                    (string)$field->template,
                ));
            }
            // Rule 7: absent expands to nothing.
            return;
        }
        $supplied = $args[$argument];
        // An empty array is indistinguishable from an empty list, so it is judged before the
        // list check — otherwise "{}" would be reported as "got list", which is not what the
        // caller sent and not a repair they can act on.
        if ($supplied === []) {
            if ($field->required) {
                $this->error($argument, 'expected a non-empty map');
            }

            return;
        }
        if (!\is_array($supplied) || \array_is_list($supplied)) {
            $this->error($argument, \sprintf(
                'expected a map of %s value => %s (e.g. {"1": …, "2": …}) for "%s", got %s',
                (string)$field->template,
                $field->type,
                $field->name,
                self::typeOf($supplied),
            ));

            return;
        }
        $driver  = $this->drivingField($action, $field);
        $driving = $driver === null ? null : ($args[$driver->argumentName()] ?? null);
        if ($driver !== null && (!\is_array($driving) || !\array_is_list($driving))) {
            // Reported once for the field, not once per key: the driving field has its own error
            // already, and what is not obvious is that this field could not be expanded at all.
            $this->error($argument, \sprintf(
                'cannot expand "%s": the argument "%s" it is keyed by was not supplied as a list',
                $field->name,
                (string)$field->template,
            ));

            return;
        }
        if ($driver !== null) {
            $allowed = self::values($driving);
        } else {
            // Outside the catalog the placeholder is a language code, and the shop is the only
            // authority on which ones exist. Unknown is not permission: bind nothing.
            $allowed = $this->languages->codes();
            if ($allowed === null) {
                $this->error($argument, \sprintf(
                    'cannot expand "%s": it is keyed by the shop\'s installed language codes '
                    . '("%s") and those could not be determined',
                    $field->name,
                    (string)$field->template,
                ));

                return;
            }
        }
        foreach ($supplied as $key => $value) {
            $key  = (string)$key;
            $path = $argument . '[' . $key . ']';
            if (\trim($key) === '') {
                $this->error($path, 'expected a non-empty key');
                continue;
            }
            if (!\in_array($key, $allowed, true)) {
                $this->error($path, $driver === null
                    ? \sprintf(
                        'no such language: "%s" is keyed by the shop\'s installed language codes, '
                        . 'which are [%s]',
                        (string)$field->template,
                        \implode(', ', $allowed),
                    )
                    : \sprintf(
                        'no such %s: the argument "%s" was sent as [%s], and a value outside it is ignored',
                        (string)$field->template,
                        (string)$field->template,
                        \implode(', ', $allowed),
                    ));
                continue;
            }
            $bound = $this->scalar($field->type, $value, $path);
            if ($bound !== null) {
                $post[\str_replace($placeholder, $key, $field->name)] = $bound;
            }
        }
    }

    /**
     * The declared field a placeholder names, or null when it names something outside the catalog
     * (`{code}`, `{iso}`), in which case the keys are checked against {@see LanguageCodes} instead.
     */
    private function drivingField(ActionSpec $action, FieldSpec $field): ?FieldSpec
    {
        foreach ($action->fields as $candidate) {
            if (!$candidate->isTemplated() && $candidate->argumentName() === $field->template) {
                return $candidate;
            }
        }

        return null;
    }

    /**
     * @return list<string> the string spellings of a supplied list, for comparison against map keys
     */
    private static function values(mixed $supplied): array
    {
        $values = [];
        foreach (\is_array($supplied) ? $supplied : [] as $value) {
            if (\is_string($value) || \is_int($value)) {
                $values[] = (string)$value;
            } elseif (\is_float($value)) {
                $values[] = self::floatToString($value);
            }
        }

        return $values;
    }

    /**
     * The one place a caller value becomes a `$_POST` string. Returns null — and records an error —
     * when the value cannot be one.
     *
     * Forgiving about the JSON type, strict about the shape: an LLM sending `5` where the catalog
     * says `int` and an LLM sending `"5"` mean the same thing and a form would have sent `"5"`
     * either way. What is refused is anything the *shop* could not have received: a bool where text
     * is expected, `null`, a nested array.
     */
    private function scalar(string $type, mixed $value, string $path): ?string
    {
        if ($type === 'bool') {
            if (!\is_bool($value)) {
                $this->error($path, \sprintf('expected a bool, got %s', self::typeOf($value)));

                return null;
            }

            return $value ? '1' : '0';
        }
        if (\is_bool($value) || $value === null || \is_array($value) || \is_object($value)) {
            $this->error($path, \sprintf('expected %s, got %s', $type, self::typeOf($value)));

            return null;
        }
        if (!\is_string($value) && !\is_int($value) && !\is_float($value)) {
            $this->error($path, \sprintf('expected %s, got %s', $type, self::typeOf($value)));

            return null;
        }
        $text = \is_float($value) ? self::floatToString($value) : (string)$value;

        if ($type === 'int' && \preg_match('/^[+-]?\d+$/', $text) !== 1) {
            $this->error($path, \sprintf('expected an integer, got "%s"', $text));

            return null;
        }
        // Comma or dot: the shop normalises (`(float)\str_replace(',', '.', …)`,
        // ShippingMethodsController.php:792, :840-841), so both spellings are legitimate input.
        if ($type === 'decimal' && \preg_match('/^[+-]?(\d+([.,]\d*)?|[.,]\d+)$/', $text) !== 1) {
            $this->error($path, \sprintf('expected a decimal number, got "%s"', $text));

            return null;
        }

        return $text;
    }

    /**
     * `(string)$float` honours `precision` and can produce `1.0E-5`, which the shop's
     * `(float)\str_replace(',', '.', …)` would read back as `1.0` — a wrong price, silently. `%.10F`
     * is fixed-notation and locale-independent (unlike `%f`'s lowercase sibling under some locales).
     */
    private static function floatToString(float $value): string
    {
        if (!\is_finite($value)) {
            return (string)$value;
        }
        $text = \sprintf('%.10F', $value);
        if (\str_contains($text, '.')) {
            $text = \rtrim(\rtrim($text, '0'), '.');
        }

        return $text === '' || $text === '-' ? '0' : $text;
    }

    private static function typeOf(mixed $value): string
    {
        if (\is_array($value)) {
            return \array_is_list($value) ? 'a list' : 'a map';
        }
        if (\is_bool($value)) {
            return $value ? 'true' : 'false';
        }
        if ($value === null) {
            return 'null';
        }
        if (\is_string($value)) {
            return 'a string';
        }

        return \get_debug_type($value);
    }

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