<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Batch;

/**
 * The `batch` pseudo-tool: many tool calls in ONE MCP request.
 *
 * ## Why one generic tool instead of `items:[…]` on each write tool
 *
 * The obvious shape is a batch ARGUMENT on the write tools (`save_snippet {items:[…]}`). It was
 * built this way instead, for four reasons that all point the same direction:
 *
 * 1. **It would have cost the `required` keyword.** `save_snippet` declares `required:[name,code]`.
 *    A call carrying only `items` violates that schema, so the tools enforcing schemas client-side
 *    (OpenAI's strict function calling) would refuse to emit the batch at all. Keeping both forms
 *    legal needs a root-level `anyOf`, which strict mode does not accept either. Dropping `required`
 *    to buy batching would make every ordinary single call vaguer — a permanent tax for an
 *    occasional win.
 * 2. **It does not actually reduce calls very much.** Per-tool batching still needs one call per
 *    tool: save three snippets, create four workflows, attach two schedules = three requests. This
 *    is one. The benchmark is hundreds of INTERCONNECTED operations, and interconnected work is
 *    heterogeneous by nature.
 * 3. **It costs no schema the model does not already carry.** The inner `arguments` object is
 *    deliberately opaque — the model already has every tool's full schema from `tools/list`, so
 *    describing them a second time inside a batch schema would be paying twice for one fact. The
 *    whole feature is ~250 B on the tool list, once, instead of ~150 B on each of five tools.
 * 4. **Nothing is weakened.** Every entry goes back through {@see \Plugin\tnt_mcp\src\Mcp\Server::dispatch()},
 *    so the token scope check, the {@see \Plugin\tnt_mcp\src\Mcp\Mode\ModeGuard} check (including
 *    the per-call classification `invoke_admin_action` needs) and the audit row happen per entry,
 *    exactly as if the client had sent them one at a time. A batch is a transport shortcut, not a
 *    permission.
 *
 * This is NOT JSON-RPC batching, which {@see \Plugin\tnt_mcp\src\Mcp\Http\McpHttpEndpoint} still
 * refuses: clients do not emit JSON-RPC batches, and they cannot be taught to. They emit tool calls,
 * and this is a tool call.
 *
 * ## What it deliberately is not
 *
 * A transaction. Entries run in order and what landed stays landed; there is no rollback, because
 * snippets live on the filesystem and workflows in the database and no honest atomicity spans the
 * two. So a failing entry NEVER discards the entries that already succeeded — the result reports
 * per entry what happened, and the model repairs from there.
 *
 * ## `dryRun` — the answer to that, and why it is not a rollback
 *
 * The cost of the paragraph above is a plan that fails at entry seven and leaves six objects behind.
 * The fix is NOT to reverse the stance: rolling a batch back would mean deleting files and rows on
 * behalf of a model that may have been half right, and the two stores still cannot be undone
 * together, so a "rollback" would itself be able to fail halfway. `dryRun: true` moves the check to
 * BEFORE anything happens instead — every entry is examined, nothing at all is written, and the
 * model re-sends the identical batch without the flag once it comes back clean. Cheap, honest, and
 * it reverses nothing.
 *
 * It is not, and does not claim to be, a guarantee. Four things can be reported per entry:
 *
 * - `checked`   — the tool offered a write-free equivalent ({@see \Plugin\tnt_mcp\src\Mcp\Contracts\CheckableTool}),
 *                 it ran, and this is its verdict.
 * - `deferred`  — the entry uses `{{N.path}}`, and the object it points at was never created because
 *                 nothing was written. Its arguments are shape-checked, its values cannot be. The
 *                 references that caused it are named in `unresolved`, and the batch counts them in
 *                 `deferred` — a green dry run over a chained plan promises less than a green dry
 *                 run over an independent one, and has to say so.
 * - `unchecked` — the tool has no write-free equivalent (`delete_workflow` cannot rehearse).
 * - `shape`     — no deep check applied, but the arguments were read against the tool's own schema.
 *
 * Scope and mode ARE decided for every entry, deep check or not, because "the token may not do this"
 * is knowable without doing it and is the cheapest failure to learn about early. `stopOnError` is
 * ignored: with nothing being written there is no damage to stop, and a full report beats a partial
 * one when the point is to fix the plan in one pass.
 */
final class BatchRunner
{
    public const TOOL = 'batch';

    /**
     * A cap, not a suggestion: one HTTP request holds the whole batch, and a hundred writes behind
     * one PHP request timeout would fail in the middle with the client unsure of what landed.
     * Exceeding it is an error naming the count — never a silent truncation.
     */
    public const MAX_CALLS = 25;

    /**
     * The `tools/list` entry. Kept under the 140-character description budget every other tool pays.
     *
     * @return array<string,mixed>
     */
    public static function definition(): array
    {
        return [
            'name'        => self::TOOL,
            'description' => 'Run up to ' . self::MAX_CALLS . ' tool calls in one request, in order. '
                . 'Each is scope- and mode-checked as usual; later calls can reuse earlier results.',
            'inputSchema' => [
                'type'       => 'object',
                'properties' => [
                    'calls' => [
                        'type'        => 'array',
                        'description' => 'One entry per call: {"tool":"save_snippet","arguments":{…}}, '
                            . 'arguments per that tool\'s schema. A later entry may reuse an earlier '
                            . 'result: "{{0.workflow.id}}" (0 = first). Runs top to bottom.',
                        'items' => [
                            'type'       => 'object',
                            'properties' => [
                                'tool' => [
                                    'type'        => 'string',
                                    'description' => 'Tool name as advertised. "batch" itself is refused.',
                                ],
                                'arguments' => [
                                    'type'                 => 'object',
                                    'description'          => 'That tool\'s arguments, unchanged.',
                                    'additionalProperties' => true,
                                ],
                            ],
                            'required'             => ['tool'],
                            'additionalProperties' => false,
                        ],
                    ],
                    'stopOnError' => [
                        'type'        => 'boolean',
                        'description' => 'Default false: every entry is attempted. True stops at the '
                            . 'first failure and reports the rest as skipped, for dependent chains. '
                            . 'Ignored when dryRun is true.',
                    ],
                    'dryRun' => [
                        'type'        => 'boolean',
                        // The full account of what a dry run can and cannot check now rides on the
                        // RESULT (per-entry "unresolved", a "deferred" count and a hint), which is
                        // sent once instead of on every turn. Keep the caveat, drop the essay.
                        'description' => 'Check the whole plan and write NOTHING. An entry using '
                            . '"{{N.path}}" comes back "deferred" with its "unresolved" references, '
                            . 'so ok:true is not a promise. Send it again without dryRun to run it.',
                    ],
                ],
                'required'             => ['calls'],
                'additionalProperties' => false,
            ],
        ];
    }

    /**
     * @param array<string,mixed>                            $args
     * @param \Closure(string, array<string,mixed>): array<string,mixed> $dispatch the server's own
     *              dispatch, token already bound — so scope, mode and audit are the real ones
     * @param (\Closure(string, array<string,mixed>): array<string,mixed>)|null $inspect what the
     *              server can say about a call WITHOUT making it — see {@see check()}. Only `dryRun`
     *              needs it, so it stays optional and a batch that runs for real is unaffected.
     *
     * @return array<string,mixed>
     */
    public static function run(array $args, \Closure $dispatch, ?\Closure $inspect = null): array
    {
        $calls = self::calls($args);
        if (($args['dryRun'] ?? false) === true) {
            if ($inspect === null) {
                throw new \RuntimeException('dryRun is not available on this transport');
            }

            return self::check($calls, $dispatch, $inspect);
        }
        $stop = ($args['stopOnError'] ?? false) === true;

        $results = [];
        $done    = [];
        $failed  = 0;
        $halted  = false;

        foreach ($calls as $i => $call) {
            if ($halted) {
                $results[] = ['i' => $i, 'tool' => self::toolName($call), 'skipped' => true];
                continue;
            }

            $tool = self::toolName($call);
            try {
                if ($tool === '') {
                    throw new \InvalidArgumentException('each entry needs a "tool" name');
                }
                if ($tool === self::TOOL) {
                    throw new \InvalidArgumentException('a batch cannot contain a batch');
                }
                $inner  = \is_array($call['arguments'] ?? null) ? $call['arguments'] : [];
                $result = $dispatch($tool, BatchReferences::resolve($inner, $done, $i));
            } catch (\Throwable $e) {
                ++$failed;
                $results[] = ['i' => $i, 'tool' => $tool, 'ok' => false, 'error' => $e->getMessage()];
                $halted    = $stop;
                continue;
            }

            // A tool that RAN but refused (`ok:false`) counts as a failure here too: to the caller
            // "the schedule was not stored" and "the call blew up" are the same unfinished work.
            $ok      = ($result['ok'] ?? true) !== false;
            $done[$i] = $result;
            if (!$ok) {
                ++$failed;
                $halted = $stop;
            }
            $results[] = ['i' => $i, 'tool' => $tool, 'ok' => $ok, 'result' => self::compact($result)];
        }

        $count = \count($calls);
        $out   = [
            'batch'     => true,
            'ok'        => $failed === 0,
            'count'     => $count,
            'succeeded' => $count - $failed - self::skipped($results),
            'failed'    => $failed,
            'results'   => $results,
        ];
        if ($failed > 0) {
            $out['hint'] = 'Entries reported ok:true landed and were NOT undone. Fix only the failed '
                . 'ones and send those again.';
        }

        return $out;
    }

    /**
     * `dryRun: true` — decide every entry, write nothing.
     *
     * The entries are walked in the order they were written, exactly as a real run would, but no
     * result is carried forward: with nothing written there is nothing for `{{N.path}}` to point at,
     * and inventing a placeholder id would produce a confident "workflow 0 does not exist" about a
     * workflow the plan was going to create. Such an entry is reported `deferred` instead — an
     * honest "this one can only be judged for real", which the model can act on, where a fabricated
     * error would teach it to distrust the whole answer.
     *
     * @param list<array<string,mixed>>                                  $calls
     * @param \Closure(string, array<string,mixed>): array<string,mixed> $dispatch
     * @param \Closure(string, array<string,mixed>): array<string,mixed> $inspect
     *
     * @return array<string,mixed>
     */
    private static function check(array $calls, \Closure $dispatch, \Closure $inspect): array
    {
        $results  = [];
        $invalid  = 0;
        $checked  = 0;
        $deferred = 0;

        foreach ($calls as $i => $call) {
            $tool  = self::toolName($call);
            $inner = \is_array($call['arguments'] ?? null) ? $call['arguments'] : [];
            $entry = ['i' => $i, 'tool' => $tool];

            $about   = $tool === '' || $tool === self::TOOL ? [] : $inspect($tool, $inner);
            $problem = self::unusable($tool, $about);
            if ($problem !== null) {
                $results[] = $entry + ['ok' => false, 'status' => 'invalid', 'errors' => [$problem]];
                ++$invalid;
                continue;
            }

            $schema = \is_array($about['schema'] ?? null) ? $about['schema'] : [];
            $shape  = ArgumentShape::problems($inner, $schema);
            if ($shape !== []) {
                $results[] = $entry + ['ok' => false, 'status' => 'invalid', 'errors' => $shape];
                ++$invalid;
                continue;
            }

            $deep = self::checkCall($about);
            if (BatchReferences::areUsedIn($inner)) {
                $unresolved = ['unresolved' => BatchReferences::usedIn($inner)];
                // A reference does not always blind the deep check. `set_schedule` refers to the
                // workflow an earlier entry creates, but the check call the tool offers is about
                // the cron expression, which is written out in full — so it CAN be judged, and an
                // invalid expression is caught here instead of half way through the real run. Only
                // an entry whose check call itself carries a reference is genuinely undecidable.
                if ($deep !== null && !BatchReferences::areUsedIn($deep['arguments'])) {
                    ++$checked;
                    $results[] = $entry + self::verdict($deep, $dispatch, $invalid) + $unresolved;
                    continue;
                }
                ++$deferred;
                $results[] = $entry + ['ok' => true, 'status' => 'deferred'] + $unresolved;
                continue;
            }
            if ($deep === null) {
                $results[] = $entry + ['ok' => true, 'status' => 'unchecked'];
                continue;
            }

            ++$checked;
            $results[] = $entry + self::verdict($deep, $dispatch, $invalid);
        }

        $ok = $invalid === 0;
        // `deferred` is counted and reported next to `checked` because a green dry run over a
        // chained plan is a WEAKER promise than a green dry run over an independent one, and the
        // top-level `ok` alone cannot say so — it reads as "the plan is fine", and a plan whose
        // referenced values turn out wrong still half-writes. The number, and the hint that names
        // it, are what make the strength of the answer visible.
        $out = [
            'batch'    => true,
            'dryRun'   => true,
            'ok'       => $ok,
            'count'    => \count($calls),
            'checked'  => $checked,
            'deferred' => $deferred,
            'invalid'  => $invalid,
            'results'  => $results,
            'hint'     => self::dryRunHint($ok, $deferred),
        ];

        return $out;
    }

    /**
     * What to do next, and how much the answer is worth.
     *
     * A deferred entry is the one place a dry run cannot speak for the real run, so a green result
     * that contains one says so rather than inviting the plan to be sent as if fully judged.
     */
    private static function dryRunHint(bool $ok, int $deferred): string
    {
        if (!$ok) {
            return 'Nothing was written. Fix the entries reported ok:false and dry-run again.';
        }
        if ($deferred > 0) {
            return 'Nothing was written. ' . $deferred . ' entr' . ($deferred === 1 ? 'y' : 'ies')
                . ' could only be checked in shape: the value behind "{{N.path}}" exists only in a '
                . 'real run, so this is not a promise that they will succeed. Send the same batch '
                . 'without dryRun to run it.';
        }

        return 'Nothing was written. Send the same batch again without dryRun to run it.';
    }

    /**
     * The write-free call the server offered for this entry, read defensively: the tool that
     * declared it lives in another plugin, and a malformed declaration must cost the entry its deep
     * check, never the whole dry run.
     *
     * @param array<string,mixed> $about
     *
     * @return array{tool:string,arguments:array<string,mixed>}|null
     */
    private static function checkCall(array $about): ?array
    {
        $check = $about['check'] ?? null;
        if (!\is_array($check) || !\is_string($check['tool'] ?? null) || $check['tool'] === '') {
            return null;
        }

        return [
            'tool'      => $check['tool'],
            'arguments' => \is_array($check['arguments'] ?? null) ? $check['arguments'] : [],
        ];
    }

    /**
     * The verdict of one write-free check call, run through the ordinary dispatch so it is scoped,
     * mode-checked and audited like everything else.
     *
     * A validating tool answers `ok: true, valid: false` — it RAN fine and the thing it looked at is
     * wrong. Both readings have to end up as `ok: false` here, or a plan full of broken snippets
     * would dry-run green. {@see \Plugin\tnt_mcp\src\Mcp\Contracts\CheckableTool} therefore names
     * the three keys a check may answer with, and all three are read: `ok`, `valid`, `accepted`.
     *
     * @param array{tool:string,arguments:array<string,mixed>}           $deep
     * @param \Closure(string, array<string,mixed>): array<string,mixed> $dispatch
     *
     * @return array<string,mixed>
     */
    private static function verdict(array $deep, \Closure $dispatch, int &$invalid): array
    {
        try {
            $result = $dispatch($deep['tool'], $deep['arguments']);
        } catch (\Throwable $e) {
            ++$invalid;

            return ['ok' => false, 'status' => 'checked', 'errors' => [$e->getMessage()]];
        }

        $ok = ($result['ok'] ?? true) !== false
            && ($result['valid'] ?? true) !== false
            && ($result['accepted'] ?? true) !== false;
        if (!$ok) {
            ++$invalid;

            return ['ok' => false, 'status' => 'checked', 'result' => self::compact($result)];
        }
        $entry = ['ok' => true, 'status' => 'checked'];
        // Warnings survive a green check on purpose: "this workflow will send an empty mail" is
        // exactly what the model wants BEFORE it writes, and it is the only part of an ok result
        // worth the context.
        $warnings = $result['warnings'] ?? null;
        if (\is_array($warnings) && $warnings !== []) {
            $entry['warnings'] = $warnings;
        }

        return $entry;
    }

    /**
     * Why this entry cannot even be looked at: no tool name, a nested batch, a name the server does
     * not know, or one the token is not allowed to call. The last is the valuable one — a plan that
     * ends in `scope denied` is worth learning about before its first nine entries have landed.
     *
     * The server is asked about the entry's REAL arguments, never a blank object: the mode a call is
     * judged in can depend on them (`invoke_admin_action` fronts entries of both classes), and an
     * empty argument list would fail closed and report a refusal the real call would not get.
     *
     * @param array<string,mixed> $about what the server said about this call
     */
    private static function unusable(string $tool, array $about): ?string
    {
        if ($tool === '') {
            return 'each entry needs a "tool" name';
        }
        if ($tool === self::TOOL) {
            return 'a batch cannot contain a batch';
        }
        if (($about['known'] ?? false) !== true) {
            return 'unknown tool: ' . $tool;
        }
        $refusal = $about['refusal'] ?? null;

        return \is_string($refusal) && $refusal !== '' ? $refusal : null;
    }

    /**
     * The audit line for the batch row itself (design §5 R6): which tools, and how it ended. The
     * per-entry rows carry the arguments, so this one carries only the shape — writing every
     * argument twice would double the audit table for no fact it does not already hold.
     *
     * A dry run says so, because a row reading `batch:6/6` next to no writes at all would look like
     * six writes nobody can find.
     *
     * @param array<string,mixed> $result
     */
    public static function outcome(array $result): string
    {
        if (($result['dryRun'] ?? false) === true) {
            $count = (int)($result['count'] ?? 0);

            return 'batch:dryRun:' . ($count - (int)($result['invalid'] ?? 0)) . '/' . $count;
        }

        return 'batch:' . ($result['succeeded'] ?? 0) . '/' . ($result['count'] ?? 0);
    }

    /**
     * @param array<string,mixed> $args
     *
     * @return list<array<string,mixed>>
     */
    private static function calls(array $args): array
    {
        $calls = $args['calls'] ?? null;
        if (!\is_array($calls) || !\array_is_list($calls) || $calls === []) {
            throw new \InvalidArgumentException('calls must be a non-empty array of {"tool":…} entries');
        }
        if (\count($calls) > self::MAX_CALLS) {
            throw new \InvalidArgumentException(\sprintf(
                'a batch takes at most %d calls, got %d — split it, nothing was run',
                self::MAX_CALLS,
                \count($calls)
            ));
        }
        foreach ($calls as $call) {
            if (!\is_array($call)) {
                throw new \InvalidArgumentException('each entry must be an object like {"tool":"…","arguments":{…}}');
            }
        }

        /** @var list<array<string,mixed>> $calls */
        return $calls;
    }

    /** @param array<string,mixed> $call */
    private static function toolName(array $call): string
    {
        return \is_string($call['tool'] ?? null) ? $call['tool'] : '';
    }

    /**
     * Per-entry compaction: the single-call `hint` is a sentence about what to do next, and twenty
     * copies of it is twenty times the context for one fact. The batch says it once, at the end.
     *
     * @param array<string,mixed> $result
     *
     * @return array<string,mixed>
     */
    private static function compact(array $result): array
    {
        unset($result['hint']);

        return $result;
    }

    /** @param list<array<string,mixed>> $results */
    private static function skipped(array $results): int
    {
        return \count(\array_filter($results, static fn (array $r): bool => ($r['skipped'] ?? false) === true));
    }
}
