<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

use JTL\DB\DbInterface;
use Plugin\tnt_mcp\src\Mcp\Backend\AlertView;
use Plugin\tnt_mcp\src\Mcp\Backend\BackendActionInvoker;
use Plugin\tnt_mcp\src\Mcp\Backend\InvocationResult;
use Plugin\tnt_mcp\src\Mcp\Journal\ChangeJournal;
use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;
use Throwable;

/**
 * The write path's order of operations, in one place: catalog lookup → version gate → argument
 * binding → confirmation → dry-run policy → transaction → invocation → `verify:` read-back.
 *
 * {@see \Plugin\tnt_mcp\src\Mcp\Tools\InvokeAdminAction} is the MCP surface; this class is the
 * decision sequence behind it, separated so the sequence can be tested without a tool, a token or a
 * {@see \Plugin\tnt_mcp\src\Mcp\Server}.
 *
 * ## Every step is a refusal point, and every refusal is a sentence
 *
 * The caller is a language model with one shot per turn. "invalid" costs another round trip and, on
 * a write path, a round trip is where a model starts inventing a raw-SQL workaround. So each refusal
 * returns `ok: false` plus a `refused:` code plus prose that names the offending thing and what to do
 * about it — never an exception, except where the failure is ours rather than the caller's.
 *
 * ## Why `ok` is not the invocation's `ok`
 *
 * {@see InvocationResult::$ok} means "the controller reported no error alert", and
 * {@see InvocationResult} says at length why that proves nothing: several controllers write nothing
 * and alert nothing. This class therefore ANDs it with the entry's `verify:` read-back and commits
 * only on the conjunction. An entry whose verify clauses cannot be evaluated is treated as
 * unverified, i.e. rolled back — on the write path, "I could not prove it happened" and "it did not
 * happen" get the same treatment on purpose.
 *
 * ## The transaction is not a dry-run feature (§5 R2)
 *
 * `PaymentMethodsController` writes three tables in sequence (`:417`, `:435`, `:449-454`); a throw in
 * between leaves a half-saved method whether or not anybody asked for a dry run. So the wrapper is on
 * for every invocation of a `dryRunnable: true` entry. `dryRunnable: false` entries run **un-wrapped**
 * and say so (`transactional: false`) rather than being wrapped in a transaction the entry has
 * declared it may escape (DDL implicitly commits in MySQL).
 *
 * What the wrapper cannot reach is stated, not hidden: `cacheFlushed` is reported from the entry's
 * declared side effects on every path, dry runs included, because the flush is real and is not rolled
 * back (§4.1: "Never advertise dry-run as 'nothing happened'").
 */
final class ActionInvocation
{
    /** How many alerts survive into the response before the rest are counted rather than quoted. */
    public const ALERT_LIMIT = 10;

    /** How many `verify:` assertions are reported in full. Entries ship at most three today. */
    public const VERIFY_LIMIT = 10;

    /** Longest alert / error message quoted back. Backend alerts can be whole HTML blocks. */
    public const MESSAGE_LIMIT = 400;

    /** The side effects a `beginTransaction()`/`rollback()` pair can actually reach or excuse (Q4). */
    private const DRY_RUNNABLE_EFFECTS = [SideEffect::Db, SideEffect::CacheFlushTags, SideEffect::CacheFlushAll];

    /** Table and column names are interpolated into the id-resolution read; nothing else may be. */
    private const IDENTIFIER = '/^[A-Za-z_][A-Za-z0-9_]*$/';

    private readonly CatalogVersionGate $gate;

    /**
     * @param string $shopVersion the RUNNING shop version (`APPLICATION_VERSION`), injected for the
     *                            same reason {@see ActionDescriber} takes it as a parameter
     */
    public function __construct(
        private readonly ActionCatalog $catalog,
        private readonly string $shopVersion,
        private readonly DbInterface $db,
        private readonly BackendActionInvoker $invoker,
        private readonly ?ChangeJournal $journal = null,
    ) {
        $this->gate = new CatalogVersionGate($this->catalog, $this->shopVersion);
    }

    /**
     * The reversibility class the {@see \Plugin\tnt_mcp\src\Mcp\Mode\ModeGuard} has to judge for
     * THIS call (§9 Q5).
     *
     * Null for an unknown id and null for a version-withheld one — both fail closed in `limited`
     * mode, which is the shipped default. See {@see \Plugin\tnt_mcp\src\Mcp\Contracts\PerCallEndpointClass}
     * for why null rather than a guess.
     */
    public function endpointClassFor(string $id): ?EndpointClass
    {
        $spec = $this->catalog->get($id);
        if ($spec === null || $this->gate->excludedRangeFor($id) !== null) {
            return null;
        }

        return $spec->endpointClass;
    }

    /**
     * @param array<string,mixed> $args         caller-supplied arguments, keyed by {@see FieldSpec::argumentName()}
     * @param string              $actingLogin  the `tadminlogin.cLogin` this invocation acts as —
     *                                          resolved from the token by the caller, never guessed here
     * @return array<string,mixed> the tool response
     */
    public function invoke(
        string $id,
        array $args,
        bool $dryRun,
        bool $confirm,
        string $actingLogin,
        string $tokenId = '',
    ): array {
        // 1. Unknown id — an answer, not an exception, mirroring describe_admin_action.
        $spec = $this->catalog->get($id);
        if ($spec === null) {
            return $this->refuse('unknown-id', \sprintf(
                'There is no admin action "%s" in this shop\'s catalog.',
                $id,
            ), $id, ['knownIds' => $this->gate->availableIds()]);
        }

        // 2. Q7 version gate — the same wording describe_admin_action already refuses with, because
        //    the repair is the same one and the caller has probably just read it there.
        $declared = $this->gate->excludedRangeFor($id);
        if ($declared !== null) {
            return $this->refuse('version-incompatible', \sprintf(
                'Admin action "%s" is not available on this shop: its catalog file declares '
                . 'compatibleShopVersions "%s" and this shop runs %s. It is refused rather than '
                . 'invoked, because its field list was verified against the declared versions only.',
                $id,
                $declared,
                $this->shopVersion,
            ), $id, [
                'compatibleShopVersions' => $declared,
                'shopVersion'            => $this->shopVersion,
                'knownIds'               => $this->gate->availableIds(),
            ]);
        }

        // 3. Bind — EVERY error, never the first: five errors reported one per attempt is five
        //    model round trips and five chances to give up and invent a different call.
        try {
            $bound = $spec->isAdminIo()
                ? (new ArgumentBinder())->bindAdminIo($spec, $args)
                : (new ArgumentBinder())->bind($spec, $args);
        } catch (ArgumentBindingException $e) {
            return $this->refuse('argument-binding', $e->getMessage(), $id, [
                'fields' => $e->fields(),
                'errors' => \array_map(
                    static fn (ArgumentBindingError $error): array => [
                        'field'   => $error->field,
                        'message' => $error->message,
                    ],
                    $e->errors(),
                ),
                'notes' => ['Call describe_admin_action({id: "' . $id . '"}) for every field\'s type and doc.'],
            ]);
        }

        // 4. The last stop before an irreversible write. Note the ordering: this precedes the
        //    dry-run check on purpose, so a Destructive entry needs `confirm` even to be dry-run —
        //    a dry run rolls back the rows, and rolls back nothing else the entry declares.
        if ($spec->endpointClass === EndpointClass::Destructive && !$confirm) {
            return $this->refuse('confirm-required', \sprintf(
                'Admin action "%s" (%s) is classified Destructive and needs an explicit '
                . 'confirm: true. Nothing has been done. What it would do: %s',
                $id,
                self::title($spec),
                self::consequences($spec),
            ), $id, [
                'endpointClass' => $spec->endpointClass->name,
                'sideEffects'   => self::sideEffectNames($spec),
                'undo'          => self::undoSummary($spec),
                'verify'        => self::verifySummary($spec),
                'notes'         => ['Re-send the identical call with confirm: true to perform it.'],
            ]);
        }

        // 5. Q4's dry-run policy, verbatim.
        if ($dryRun) {
            $refusal = $this->dryRunRefusal($spec);
            if ($refusal !== null) {
                return $this->refuse('dryRun-unsupported', $refusal, $id, [
                    'dryRunnable' => $spec->dryRunnable,
                    'sideEffects' => self::sideEffectNames($spec),
                    'notes'       => [
                        'Re-send without dryRun to perform it for real, or use run_sql_read to '
                        . 'inspect the current state first.',
                    ],
                ]);
            }
        }

        return $this->perform($spec, $args, $bound, $dryRun, $actingLogin, $tokenId);
    }

    // -----------------------------------------------------------------------------------------
    // 6-8. Transaction wrapper, invocation, read-back
    // -----------------------------------------------------------------------------------------

    /**
     * @param array<string,mixed>              $args
     * @param array<string,mixed>|list<mixed>  $bound
     * @return array<string,mixed>
     */
    private function perform(
        ActionSpec $spec,
        array $args,
        array $bound,
        bool $dryRun,
        string $actingLogin,
        string $tokenId = '',
    ): array {
        // R2: an entry that declares it may escape a transaction (DDL implicitly commits) is run
        // un-wrapped and SAYS so, rather than being given a guarantee it cannot keep.
        $transactional = $spec->dryRunnable;
        $idBefore      = $this->maxId($spec);

        // D29b, §4.4: the before-snapshot is taken OUTSIDE the wrapper, on the primary connection,
        // because a snapshot a rollback erased would leave a failed invocation with no evidence. A
        // dry run journals nothing at all — it commits nothing, so there is nothing to undo.
        $draft = $dryRun ? null : $this->journal?->open($spec, $args, $tokenId, $actingLogin);

        if ($transactional) {
            $this->db->beginTransaction();
        }
        try {
            $result = $this->call($spec, $bound, $actingLogin);
            $verify = $this->verify($spec, $args, $this->resolveId($spec, $args, $idBefore));
            $ok     = $result->ok && ($verify === null || $verify['ok'] === true);

            if ($transactional) {
                // A dry run ALWAYS rolls back, including the successful one — that is what makes it
                // a dry run. Everything else commits only on the conjunction above.
                if ($dryRun || !$ok) {
                    $this->db->rollback();
                } else {
                    $this->db->commit();
                }
            }
        } catch (Throwable $e) {
            if ($transactional) {
                $this->db->rollback();
            }
            // An un-wrapped entry that threw may have written; the journal is the only record of
            // what, so it is closed on this path too rather than left `incomplete`.
            $undo = $draft === null
                ? null
                : $this->journal->close($draft, $spec, $args, 'threw', !$transactional, self::flushesCache($spec));

            return $this->refuse('invocation-failed', \sprintf(
                'Admin action "%s" threw and %s: %s',
                $spec->id,
                $transactional
                    ? 'the transaction was rolled back, so its database writes are undone'
                    : 'is declared dryRunnable: false, so it ran UN-WRAPPED — its database writes '
                        . 'may be partially applied and must be inspected',
                $e->getMessage(),
            ), $spec->id, [
                'transactional' => $transactional,
                'cacheFlushed'  => self::flushesCache($spec),
                'undo'          => $undo,
                'notes'         => self::flushesCache($spec)
                    ? ['The entry declares a cache flush; a rollback does not undo one.']
                    : [],
            ]);
        }

        // §4.4: the after-snapshot and the verdict are written after the commit, outside the
        // wrapper. A non-transactional entry wrote whatever it wrote regardless of `ok`, so it is
        // journalled either way — that is exactly the case an undo is most needed for.
        $undo = $draft === null ? null : $this->journal->close(
            $draft,
            $spec,
            $args,
            self::verdict($result, $ok),
            !$transactional || $ok,
            self::flushesCache($spec),
        );

        return $this->shape($spec, $result, $verify, $ok, $dryRun, $transactional, $actingLogin, $undo);
    }

    /** The journal's one-word account of how the invocation ended. */
    private static function verdict(InvocationResult $result, bool $ok): string
    {
        if ($ok) {
            return 'ok';
        }

        return $result->ok ? 'verify-failed' : 'controller-rejected';
    }

    /**
     * @param array<string,mixed>|list<mixed> $bound
     */
    private function call(ActionSpec $spec, array $bound, string $actingLogin): InvocationResult
    {
        if ($spec->isAdminIo()) {
            /** @var list<mixed> $bound */
            return $this->invoker->invokeAdminIo(
                $actingLogin,
                (string)$spec->controller,
                (string)$spec->method,
                $bound,
                $spec->permission,
            );
        }

        /** @var array<string,mixed> $bound */
        return $this->invoker->invokeFormPost($actingLogin, $spec->route, $bound);
    }

    /**
     * The response shape: additive over {@see InvocationResult::toArray()}, no renames.
     *
     * @param ?array<string,mixed> $verify
     * @param ?array<string,mixed> $undo   the journal's summary; NEVER a bare changeId (§4.7)
     * @return array<string,mixed>
     */
    private function shape(
        ActionSpec $spec,
        InvocationResult $result,
        ?array $verify,
        bool $ok,
        bool $dryRun,
        bool $transactional,
        string $actingLogin,
        ?array $undo = null,
    ): array {
        $notes = [];
        if ($dryRun) {
            $notes[] = 'dryRun: the database writes were rolled back. Nothing else was.';
        }
        if (self::flushesCache($spec)) {
            $notes[] = 'The shop cache was flushed for real — a rollback cannot undo a cache flush.';
        }
        if (!$transactional) {
            $notes[] = 'This entry declares dryRunnable: false, so it ran outside a transaction '
                . '(transactional: false) and a partial write would not have been rolled back.';
        }
        if ($undo !== null && ($undo['supported'] ?? false) !== true && !$dryRun) {
            $notes[] = 'This change is NOT undoable through undo_admin_action: '
                . (string)($undo['reason'] ?? 'no reason recorded') . '.';
        }
        if ($result->ok && $verify !== null && $verify['ok'] !== true) {
            $notes[] = 'The controller reported no error but the entry\'s verify: read-back did not '
                . 'hold, so this is reported as ok: false and was rolled back. Several backend '
                . 'controllers write nothing and alert nothing; the read-back is the only proof.';
        }

        $payload = [
            'ok'               => $ok,
            'actionId'         => $spec->id,
            'status'           => $result->status,
            'redirect'         => $result->redirect,
            'alerts'           => $this->alerts($result, $notes),
            'data'             => $result->data,
            'renderedTemplate' => $result->renderedTemplate,
            'verify'           => $verify,
            'undo'             => $undo,
            'changeId'         => $undo === null ? null : ($undo['changeId'] ?? null),
            'dryRun'           => $dryRun,
            'transactional'    => $transactional,
            'cacheFlushed'     => self::flushesCache($spec),
            'actingLogin'      => $actingLogin,
            'permission'       => $spec->permission,
            'durationMs'       => \round($result->durationMs, 1),
        ];
        $payload['notes'] = $notes;

        return $payload;
    }

    /**
     * §5.1's discipline applied to the write response: cap the list, and SAY that you capped it.
     * A silently shortened alert list is how a caller concludes an error was not reported.
     *
     * @param list<string> $notes
     * @return list<array<string,mixed>>
     */
    private function alerts(InvocationResult $result, array &$notes): array
    {
        $alerts = $result->alerts;
        $total  = \count($alerts);
        if ($total > self::ALERT_LIMIT) {
            // Errors first, so truncation can never be what hides the failure.
            $errors    = $result->errors();
            $remaining = \array_values(\array_filter(
                $alerts,
                static fn (AlertView $a): bool => !\in_array($a, $errors, true),
            ));
            $alerts  = \array_slice(\array_merge($errors, $remaining), 0, self::ALERT_LIMIT);
            $notes[] = \sprintf(
                '%d of %d alerts shown (errors first); %d were truncated.',
                \count($alerts),
                $total,
                $total - \count($alerts),
            );
        }

        return \array_map(
            static fn (AlertView $a): array => [
                'type'    => $a->type,
                'key'     => $a->key,
                'message' => self::clip($a->message),
            ],
            $alerts,
        );
    }

    // -----------------------------------------------------------------------------------------
    // The `verify:` read-back
    // -----------------------------------------------------------------------------------------

    /**
     * Run the entry's declared read-backs and return them as evidence.
     *
     * Executed INSIDE the transaction on purpose: on a dry run that is the only moment the write is
     * observable, and observing it is the entire value of the dry run.
     *
     * Null when the entry declares no `verify:` clauses at all (the validator permits that only for
     * Destructive entries) — the caller then has `ok` and nothing else, which the notes say.
     *
     * @param array<string,mixed> $args
     * @return ?array{ok:bool,assertions:list<array<string,mixed>>,notes:list<string>}
     */
    private function verify(ActionSpec $spec, array $args, ?int $resolvedId): ?array
    {
        if ($spec->verify === []) {
            return null;
        }

        $assertions = [];
        $notes      = [];
        $ok         = true;
        $clauses    = \array_slice($spec->verify, 0, self::VERIFY_LIMIT);
        if (\count($spec->verify) > \count($clauses)) {
            $notes[] = \sprintf(
                '%d of %d verify clauses evaluated; the rest were truncated for size.',
                \count($clauses),
                \count($spec->verify),
            );
            $ok = false;
        }

        foreach ($clauses as $clause) {
            $assertion = $this->assert($spec, $clause, $args, $resolvedId);
            if ($assertion['ok'] !== true) {
                $ok = false;
            }
            $assertions[] = $assertion;
        }
        if ($resolvedId !== null && $spec->idResolution !== null) {
            $notes[] = \sprintf('%s resolved to %d.', $spec->idResolution->key, $resolvedId);
        }

        return ['ok' => $ok, 'assertions' => $assertions, 'notes' => $notes];
    }

    /**
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    private function assert(
        ActionSpec $spec,
        VerifyAssertion $clause,
        array $args,
        ?int $resolvedId,
    ): array {
        $out = ['kind' => $clause->kind->value, 'query' => self::clip($clause->query), 'ok' => false];

        $params = [];
        foreach (self::namedParameters($clause->query) as $name) {
            $value = $this->parameter($spec, $name, $args, $resolvedId);
            if ($value === null) {
                // Unresolvable is UNVERIFIED, and unverified rolls back. Saying which parameter
                // could not be bound is the difference between a fixable and a mysterious refusal.
                $out['reason'] = \sprintf(
                    'could not bind :%s — no such argument was supplied and the entry\'s '
                    . 'idResolution did not produce one',
                    $name,
                );

                return $out;
            }
            $params[$name] = $value;
        }

        try {
            $row = $this->db->getSingleObject($clause->query, $params);
        } catch (Throwable $e) {
            $out['reason'] = 'the read-back query failed: ' . self::clip($e->getMessage());

            return $out;
        }

        $out['params'] = $params;
        $out['row']    = $row === null ? null : \array_map(
            static fn (mixed $v): mixed => \is_scalar($v) || $v === null ? $v : (string)\gettype($v),
            (array)$row,
        );

        // array_merge, NOT `+`: `$out` starts with the fail-closed `ok => false` and `+` keeps the
        // LEFT operand's keys, so the verdict would never be able to become true.
        return \array_merge($out, $this->evaluate($clause, $args, $row === null ? null : (array)$row));
    }

    /**
     * The three assertion shapes {@see VerifyKind} allows, and deliberately no fourth.
     *
     * @param array<string,mixed>  $args
     * @param ?array<string,mixed> $row
     * @return array{ok:bool,reason?:string}
     */
    private function evaluate(VerifyAssertion $clause, array $args, ?array $row): array
    {
        if ($clause->kind === VerifyKind::RowCount) {
            $actual = $row === null ? 0 : (int)(\array_values($row)[0] ?? 0);
            if (\array_key_exists('minimum', $clause->expect)) {
                $minimum = (int)self::expected($clause->expect['minimum'], $args);

                return $actual >= $minimum
                    ? ['ok' => true]
                    : ['ok' => false, 'reason' => \sprintf('expected at least %d row(s), found %d', $minimum, $actual)];
            }
            $expected = (int)self::expected($clause->expect['n'] ?? 0, $args);

            return $actual === $expected
                ? ['ok' => true]
                : ['ok' => false, 'reason' => \sprintf('expected %d row(s), found %d', $expected, $actual)];
        }

        if ($row === null) {
            return ['ok' => false, 'reason' => 'the read-back found no row'];
        }
        foreach ($clause->expect as $column => $raw) {
            $expected = self::expected($raw, $args);
            if ($expected === null) {
                return ['ok' => false, 'reason' => 'expected value for "' . $column . '" is a placeholder '
                    . 'pointing at an argument that was not supplied'];
            }
            if (!\array_key_exists($column, $row)) {
                return ['ok' => false, 'reason' => 'the read-back row has no column "' . $column . '"'];
            }
            if (!self::equal($row[$column], $expected)) {
                return ['ok' => false, 'reason' => \sprintf(
                    '%s is "%s", expected "%s"',
                    $column,
                    self::clip((string)$row[$column]),
                    self::clip((string)$expected),
                )];
            }
        }

        return ['ok' => true];
    }

    /**
     * Resolve one `:name` of a verify query. Order matters: the entry's `idResolution` key wins,
     * because a create is called with id `0` and the row it produced is the one being verified.
     *
     * @param array<string,mixed> $args
     */
    private function parameter(ActionSpec $spec, string $name, array $args, ?int $resolvedId): int|string|null
    {
        if ($resolvedId !== null && $spec->idResolution !== null && $spec->idResolution->key === $name) {
            return $resolvedId;
        }
        $value = $args[$name] ?? null;
        if (\is_bool($value)) {
            return $value ? '1' : '0';
        }
        if (\is_int($value)) {
            return $value;
        }
        if (\is_string($value) || \is_float($value)) {
            return (string)$value;
        }

        return null;
    }

    /**
     * `MAX(<key>)` before and after, which is exact under our exclusive transaction — the reason
     * {@see IdResolutionMode::MaxIdDelta} exists at all.
     *
     * ⚠️ {@see IdResolutionMode::LastInsertId} is resolved the SAME way, and that is a deliberate
     * narrowing rather than an oversight: `DbInterface` exposes no bare `lastInsertId()` — its
     * `getLastInsertedID(string $stmt, array $params)` (`DbInterface.php:361`) performs an insert
     * itself. Under the wrapping transaction the new maximum IS the row the controller just wrote,
     * so the two modes coincide here; the distinction stays in the catalog for the day a caller
     * needs it outside a transaction.
     */
    private function maxId(ActionSpec $spec): ?int
    {
        $resolution = $spec->idResolution;
        if (
            $resolution === null
            || \preg_match(self::IDENTIFIER, $resolution->table) !== 1
            || \preg_match(self::IDENTIFIER, $resolution->key) !== 1
        ) {
            return null;
        }
        try {
            $row = $this->db->getSingleObject(
                'SELECT MAX(`' . $resolution->key . '`) AS m FROM `' . $resolution->table . '`'
            );
        } catch (Throwable) {
            return null;
        }

        return (int)($row->m ?? 0);
    }

    /**
     * The id this invocation addressed: the newly created one when the maximum moved, otherwise the
     * one the caller named. Null when the entry declares no `idResolution` — its verify clauses then
     * bind from `$args` alone, which is what the shipped ZIP and delete entries do.
     *
     * @param array<string,mixed> $args
     */
    private function resolveId(ActionSpec $spec, array $args, ?int $before): ?int
    {
        if ($spec->idResolution === null) {
            return null;
        }
        $after = $this->maxId($spec);
        if ($after !== null && $before !== null && $after > $before) {
            return $after;
        }
        $named = $args[$spec->idResolution->key] ?? null;
        $named = \is_int($named) || \is_string($named) ? (int)$named : 0;

        return $named > 0 ? $named : null;
    }

    // -----------------------------------------------------------------------------------------
    // Refusal helpers
    // -----------------------------------------------------------------------------------------

    /**
     * Q4 §4.1, implemented verbatim. Returns the refusal message, or null when the dry run may run.
     */
    private function dryRunRefusal(ActionSpec $spec): ?string
    {
        if (!$spec->dryRunnable) {
            return \sprintf(
                'Admin action "%s" declares dryRunnable: false, so a dry run is refused rather than '
                . 'faked. Nothing has been done. Its writes are not known to be confined to a '
                . 'transaction (DDL implicitly commits in MySQL), so a rollback would not be a '
                . 'reliable undo.',
                $spec->id,
            );
        }

        $offending = [];
        foreach ($spec->sideEffects as $effect) {
            if (!\in_array($effect, self::DRY_RUNNABLE_EFFECTS, true)) {
                $offending[] = $effect->value;
            }
        }
        if ($offending === []) {
            return null;
        }

        return \sprintf(
            'A dry run of "%s" is refused: it declares the side effect(s) %s, which a database '
            . 'rollback cannot undo. Nothing has been done. Only db, cacheFlushTags and '
            . 'cacheFlushAll can be dry-run, and cache flushes are reported as really having '
            . 'happened rather than pretended away.',
            $spec->id,
            \implode(', ', $offending),
        );
    }

    /**
     * @param array<string,mixed> $extra
     * @return array<string,mixed>
     */
    private function refuse(string $code, string $message, string $id, array $extra = []): array
    {
        return [
            'ok'       => false,
            'refused'  => $code,
            'error'    => $message,
            'actionId' => $id,
        ] + $extra;
    }

    /** The prose a Destructive refusal owes the caller: what it would do, and what an undo cannot reach. */
    private static function consequences(ActionSpec $spec): string
    {
        $parts = [];
        if ($spec->sideEffectNote !== null) {
            $parts[] = $spec->sideEffectNote;
        }
        $parts[] = 'Side effects: ' . \implode(', ', self::sideEffectNames($spec)) . '.';
        if ($spec->undo !== null) {
            $parts[] = $spec->undo->supported
                ? 'An undo is supported for ' . \implode(', ', $spec->undo->tables) . '.'
                : 'No undo is supported.';
            if ($spec->undo->cannotUndo !== []) {
                $parts[] = 'An undo CANNOT reverse: ' . \implode('; ', $spec->undo->cannotUndo) . '.';
            }
        }
        if ($spec->verify !== []) {
            $parts[] = 'Afterwards this is proven by reading back: '
                . \implode(' ', self::verifySummary($spec)) . '';
        }

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

    /** @return list<string> */
    private static function verifySummary(ActionSpec $spec): array
    {
        return \array_map(
            static fn (VerifyAssertion $v): string => $v->kind->value . ' — ' . self::clip($v->query),
            $spec->verify,
        );
    }

    /** @return array<string,mixed> */
    private static function undoSummary(ActionSpec $spec): array
    {
        if ($spec->undo === null) {
            return ['supported' => false, 'tables' => [], 'cannotUndo' => []];
        }

        return [
            'supported'  => $spec->undo->supported,
            'tables'     => $spec->undo->tables,
            'cannotUndo' => $spec->undo->cannotUndo,
        ];
    }

    /** @return list<string> */
    private static function sideEffectNames(ActionSpec $spec): array
    {
        return \array_map(static fn (SideEffect $e): string => $e->value, $spec->sideEffects);
    }

    private static function flushesCache(ActionSpec $spec): bool
    {
        return $spec->hasSideEffect(SideEffect::CacheFlushTags) || $spec->hasSideEffect(SideEffect::CacheFlushAll);
    }

    private static function title(ActionSpec $spec): string
    {
        return $spec->titleFor('en') ?? $spec->titleFor('de') ?? $spec->id;
    }

    /** @return list<string> the `:name` parameters of $query, in order, deduplicated */
    private static function namedParameters(string $query): array
    {
        $matches = [];
        \preg_match_all('/:([A-Za-z_][A-Za-z0-9_]*)/', $query, $matches);
        /** @var list<string> $names */
        $names = \array_values(\array_unique($matches[1]));

        return $names;
    }

    /**
     * A `verify.expect` value: a literal, or the {@see Placeholder} grammar resolved against the
     * caller's arguments. Null means "the placeholder points at an argument nobody supplied".
     *
     * @param array<string,mixed> $args
     */
    private static function expected(mixed $raw, array $args): int|string|null
    {
        if (!\is_string($raw)) {
            return \is_int($raw) || \is_float($raw) || \is_bool($raw)
                ? (\is_bool($raw) ? ($raw ? '1' : '0') : (\is_int($raw) ? $raw : (string)$raw))
                : null;
        }
        $placeholder = Placeholder::parse($raw);
        if ($placeholder === null) {
            return $raw;
        }
        $value = $args[$placeholder->field] ?? null;
        if ($placeholder->countsElements) {
            return \is_array($value) ? \count($value) : null;
        }
        if (\is_bool($value)) {
            return $value ? '1' : '0';
        }

        return \is_int($value) ? $value : (\is_string($value) || \is_float($value) ? (string)$value : null);
    }

    /**
     * Numeric on both sides compares numerically — `fZuschlag` comes back from MySQL as `4.50` and
     * was sent as `4,50`/`4.5`, and a string comparison would call that a failed write.
     */
    private static function equal(mixed $actual, int|string $expected): bool
    {
        if ($actual === null) {
            return false;
        }
        if (\is_numeric($actual) && \is_numeric($expected)) {
            return (float)$actual === (float)$expected;
        }

        return (string)(\is_scalar($actual) ? $actual : '') === (string)$expected;
    }

    private static function clip(string $value): string
    {
        return \mb_strlen($value) <= self::MESSAGE_LIMIT
            ? $value
            : \mb_substr($value, 0, self::MESSAGE_LIMIT) . '… [truncated]';
    }
}
