<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

/**
 * Turns {@see ActionSpec} declarations into the two guidance responses of
 * `read-logs-undo-guidance.md` §5 — the `list_admin_actions` index and the `describe_admin_action`
 * contract of §5.2 — and enforces the §5.1 budgets while doing it.
 *
 * Pure, in the same sense {@see ActionCatalog} is: no DB, no `Shop::` container, no service calls.
 * Two consequences the responses state rather than hide:
 *
 * - **Enum values are described, not resolved.** §4 of `backend-action-invoker.md` wants live
 *   values in the response; resolving `tzahlungsart` needs a `DbInterface`, and a describer that
 *   holds one is no longer loadable in a unit test or a build-time check. So the enum *source* is
 *   returned and the caller is told the read that lists it. A caller that has already resolved a
 *   list may hand it in via $enumValues — this class still resolves nothing itself, it only
 *   formats, filters and truncates what it was given (§5.1 stage 3).
 * - **Entries incompatible with the running shop are withheld** (§9 Q7, hard fail): a file whose
 *   `compatibleShopVersions` excludes `$shopVersion` contributes nothing to `list_admin_actions`,
 *   and `describe_admin_action` refuses its ids naming the declared range and the running version.
 *   The list response always states how many went and why — see {@see ShopVersionRange} for why the
 *   comparison ignores prerelease suffixes.
 * - **`permitted` is null.** §5.2 returns whether the acting admin holds the entry's permission;
 *   that is `AdminAccount::permission()`, which is container state. The key is kept, so the shape
 *   matches key for key, and the note says where the check actually happens.
 *
 * §5.1 truncation order, applied in order and only while over budget — stage 1 unconditionally:
 *   1. `evidence[]` is never returned at all.
 *   2. long-tail optional fields collapse to a single "+ N more optional fields" line.
 *   3. enum value lists over 25 entries truncate, with the `enumFilter` hint.
 *   4. `recipe` steps collapse from prose to imperative fragments.
 *   5. required fields, `coRequired`, `sideEffects`, `undo` and `prerequisites` are never dropped —
 *      an over-budget response says so instead of shedding them.
 */
final class ActionDescriber
{
    /** §5.1: `describe_admin_action` — the working budget for one action. */
    public const DESCRIBE_BUDGET = 1200;

    /** §5.1: `list_admin_actions` (filtered). */
    public const LIST_BUDGET = 800;

    /** §5.1 stage 3: how many enum values survive before the list is cut. */
    public const ENUM_VALUE_LIMIT = 25;

    /** `multipass-tools.md` §4: at most three "what comes next" lines, ~40 tokens. */
    public const NEXT_STEPS_LIMIT = 3;

    /** The §5.1 stage-2 line, `sprintf`-ready. Asserted verbatim by the tests. */
    private const COLLAPSED_FIELDS = '+ %d more optional fields — call describe_admin_action({id, verbose:true})';

    /** @var array<string,string>|null action id => the declared range that excludes this shop */
    private ?array $incompatible = null;

    /**
     * @param string                     $shopVersion the RUNNING shop version (`APPLICATION_VERSION`),
     *                                                passed in rather than read: this class stays
     *                                                pure, and the constant only exists in a booted
     *                                                shop. Required, not defaulted — a default would
     *                                                be a gate that is off by accident.
     * @param array<string,list<string>> $enumValues  enum table/service name => already-resolved
     *                                                values. Optional: nothing here resolves them.
     * @param array<string,string>       $guideTitles guide topic => its one-line title, for the map
     *                                                {@see self::summarise} returns when no area was
     *                                                named. Injected for the same reason as
     *                                                $enumValues: reading `catalog/guides/*.md` is
     *                                                {@see \Plugin\tnt_mcp\src\Mcp\Guide\GuideCatalog}'s
     *                                                job, and this class stays pure. Absent titles
     *                                                only cost the map its `about` column.
     */
    public function __construct(
        private readonly ActionCatalog $catalog,
        private readonly string $shopVersion,
        private readonly array $enumValues = [],
        private readonly array $guideTitles = [],
    ) {
    }

    /**
     * The `list_admin_actions` response: the index, and nothing that belongs in a contract.
     *
     * Two answers, not one. With NO area this returns the area map of {@see self::map()} — where to
     * look, one row per area, flat in the catalog size. With an area it returns that area's rows,
     * and only they are governed by the ladder below.
     *
     * The rows are the response that grows with the catalog, so {@see self::LIST_BUDGET} is a
     * wall a twelfth entry would have hit — and raising it just moves the wall. It degrades instead,
     * in the same only-while-over-budget spirit as §5.1 stage 2:
     *
     *   1. full rows — id, area, title, endpointClass, writes, sideEffects;
     *   2. `{id, area}` rows, ~4× cheaper, so ids stay discoverable and the dropped columns are one
     *      `list_admin_actions({area})` away;
     *   3. no rows at all — `byArea` counts plus a pointer at the area's guide.
     *
     * Ids are what the caller needs to reach `describe_admin_action`; a title it can guess at is
     * worth less than an id it cannot. One fat area must degrade rather than fail.
     *
     * @return array<string,mixed>
     */
    public function summarise(?string $area, bool $writesOnly): array
    {
        $area = $area === null || \trim($area) === '' ? null : \mb_convert_case(\trim($area), \MB_CASE_LOWER);
        if ($area === null) {
            return $this->map($writesOnly);
        }

        $actions = [];
        $skipped = 0;
        foreach ($this->visible() as $spec) {
            if (self::area($spec) !== $area) {
                continue;
            }
            if ($writesOnly && !self::writes($spec)) {
                $skipped++;
                continue;
            }
            $actions[] = [
                'id'            => $spec->id,
                'area'          => self::area($spec),
                'title'         => self::title($spec),
                'endpointClass' => $spec->endpointClass->name,
                'writes'        => self::writes($spec),
                'sideEffects'   => self::sideEffects($spec),
            ];
        }

        $notes = ['Call describe_admin_action({id}) for one action\'s full contract before invoking it.'];
        if ($actions === []) {
            $notes[] = \sprintf(
                'No action area "%s". Known areas: %s.',
                $area,
                \implode(', ', $this->areas())
            );
        }
        if ($writesOnly && $skipped === 0) {
            $notes[] = 'writesOnly changed nothing — every catalog action writes; none of them are reads.';
        }
        foreach ($this->incompatibilityNotes() as $note) {
            $notes[] = $note;
        }

        return self::withinListBudget([
            'actions' => $actions,
            'areas'   => $this->areas(),
            'count'   => \count($actions),
            'notes'   => $notes,
        ]);
    }

    /**
     * The unfiltered `list_admin_actions()` answer: a MAP of the areas, not the rows.
     *
     * Round 3 shipped rows for every entry and let the ladder in {@see self::withinListBudget} thin
     * them out. That is backwards for the call that is made first and knows least: at 22 entries the
     * full-row index costs ~944 tokens, the ladder degrades it to bare `{id, area}` — and an id list
     * with no titles is exactly the wall of text §5.1 rejects, only less useful.
     *
     * So the unfiltered call answers "what is there, and where do I look" in one flat row per area:
     * how many actions it holds, the guide topic that explains it, and that guide's title as the
     * one-line "what lives here". The cost is ~200 tokens and, unlike the row index, it does NOT
     * grow with the catalog — a 50th entry adds nothing until it opens a new area. The rows the
     * caller actually wants are one `list_admin_actions({area})` away (93–367 tokens each), and the
     * contract one `describe_admin_action({id})` after that.
     *
     * `about` is best-effort: an area whose guide title was not injected simply has no `about`. A
     * missing blurb is a thinner map, never a wrong one.
     *
     * @return array<string,mixed>
     */
    private function map(bool $writesOnly): array
    {
        $counts = [];
        $guides = [];
        foreach ($this->visible() as $spec) {
            $areaOf          = self::area($spec);
            $counts[$areaOf] = ($counts[$areaOf] ?? 0) + 1;
            if ($spec->guide !== null) {
                $guides[$areaOf][$spec->guide] = true;
            }
        }
        \ksort($counts);

        $map = [];
        foreach ($counts as $areaOf => $count) {
            $row    = ['area' => $areaOf, 'count' => $count];
            $topics = \array_keys($guides[$areaOf] ?? []);
            \sort($topics);
            if ($topics !== []) {
                $row['guides'] = $topics;
                // An area can hold more than one guide (shipping does). The blurb comes from the
                // topic named after the area when there is one, because that is the general guide
                // and the others are its corners — otherwise from the first, alphabetically.
                $about = $this->guideTitles[\in_array($areaOf, $topics, true) ? $areaOf : $topics[0]] ?? null;
                if ($about !== null && $about !== '') {
                    $row['about'] = $about;
                }
            }
            $map[] = $row;
        }

        $notes = [
            'This is the map, not the actions. Call list_admin_actions({area}) for one area\'s rows, '
            . 'then describe_admin_action({id}) for the full contract before invoking it.',
            'get_guide({topic}) explains an area before you touch it — the guide column names the topic.',
        ];
        if ($writesOnly) {
            $notes[] = 'writesOnly changed nothing — every catalog action writes; none of them are reads.';
        }
        foreach ($this->incompatibilityNotes() as $note) {
            $notes[] = $note;
        }

        return [
            'map'   => $map,
            'count' => \array_sum($counts),
            'notes' => $notes,
        ];
    }

    /**
     * The §5.1 truncation ladder for the index — see {@see self::summarise}. `count` is never
     * touched: it counts what matched, not what survived the budget, and a shrinking count would
     * read as a filter that dropped entries.
     *
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    private static function withinListBudget(array $payload): array
    {
        if (TokenBudget::fits($payload, self::LIST_BUDGET)) {
            return $payload;
        }

        /** @var list<array<string,mixed>> $rows */
        $rows               = $payload['actions'];
        $payload['actions'] = \array_map(
            static fn (array $row): array => ['id' => $row['id'], 'area' => $row['area']],
            $rows
        );
        $payload['notes'][] = \sprintf(
            'Index truncated to ids at the %d-token budget — call list_admin_actions({area}) for '
            . 'titles, side effects and reversibility.',
            self::LIST_BUDGET
        );
        if (TokenBudget::fits($payload, self::LIST_BUDGET)) {
            return $payload;
        }

        $byArea = [];
        foreach ($rows as $row) {
            $area          = (string)$row['area'];
            $byArea[$area] = ($byArea[$area] ?? 0) + 1;
        }
        \ksort($byArea);

        \array_pop($payload['notes']); // the stage-2 line promised ids; this stage returns none
        $payload['actions'] = [];
        $payload['byArea']  = $byArea;
        // Only ever reached through an area filter now — the unfiltered call answers with the map —
        // so "filter by area" is advice the caller has already taken. Say what is actually left:
        // the guide, which names the area's actions in prose the budget does not apply to.
        $payload['notes'][] = \sprintf(
            'Even the bare id list exceeds the %d-token budget, so no actions are returned — '
            . 'read get_guide({topic}) for this area, or name an id you already know with '
            . 'describe_admin_action({id}).',
            self::LIST_BUDGET
        );

        return $payload;
    }

    /**
     * The `describe_admin_action` response of §5.2, key for key, budgeted per §5.1.
     *
     * An unknown id is an answer, not an exception: the caller guessed, and the ids it should have
     * guessed from are cheap to hand back.
     *
     * @return array<string,mixed>
     */
    public function describe(string $id, bool $verbose = false, ?string $enumFilter = null): array
    {
        $declared = $this->incompatible()[$id] ?? null;
        if ($declared !== null) {
            // Q7 hard fail. Describing it would be handing an LLM a field list this shop's
            // controller is not known to accept — the contract would read as verified when it is
            // not. Refusing names both halves of the mismatch, because the repair is either a
            // re-verified catalog entry or a different shop, and the caller cannot tell which
            // from "unavailable".
            return [
                'error'                  => \sprintf(
                    'Admin action "%s" is not available on this shop: its catalog file declares '
                    . 'compatibleShopVersions "%s" and this shop runs %s. It is withheld rather '
                    . 'than described, because its field list was verified against the declared '
                    . 'versions only.',
                    $id,
                    $declared,
                    $this->shopVersion
                ),
                'id'                     => $id,
                'compatibleShopVersions' => $declared,
                'shopVersion'            => $this->shopVersion,
                'knownIds'               => $this->visibleIds(),
                'notes'                  => [
                    'Re-verify the entry against this shop\'s controller and widen '
                    . 'compatibleShopVersions in catalog/actions/*.yaml to make it available.',
                ],
            ];
        }

        $spec = $this->catalog->get($id);
        if ($spec === null) {
            return [
                'error'     => \sprintf('There is no admin action "%s" in this shop\'s catalog.', $id),
                'id'        => $id,
                'knownIds'  => $this->visibleIds(),
                'notes'     => ['Call list_admin_actions to browse the catalog by area.'],
            ];
        }

        $notes  = [];
        $fields = $this->fields($spec, $enumFilter, $notes);

        $payload = [
            'id'             => $spec->id,
            'title'          => self::title($spec),
            'route'          => $spec->route,
            'permission'     => $spec->permission,
            'permitted'      => null,
            'sideEffects'    => self::sideEffects($spec),
            'sideEffectNote' => $spec->sideEffectNote,
            'endpointClass'  => $spec->endpointClass->name,
            'dryRunnable'    => $spec->dryRunnable,
            'undo'           => self::undo($spec),
            'prerequisites'  => $spec->prerequisites,
            'recipe'         => $spec->recipe,
            'coRequired'     => self::coRequired($spec),
            'nextSteps'      => self::nextSteps($spec),
            'pitfalls'       => $spec->pitfalls,
            'fields'         => $fields,
            'guide'          => $spec->guide,
        ];

        // Step 8b: the recipe, the worked examples and the pitfalls now live in a guide rather than
        // in the entry, so the budget buys fields instead of prose. That is only an improvement if
        // the model is TOLD — an entry whose recipe silently vanished is strictly worse than the fat
        // entry it replaced. So the pointer is a note (which survives every truncation stage) and it
        // names the exact call, not merely the topic.
        if ($spec->guide !== null) {
            $notes[] = \sprintf(
                'Step-by-step recipe, worked examples and pitfalls for this action are not in this '
                . 'response — call get_guide({topic: "%s"}) for them.',
                $spec->guide,
            );
        }
        $notes[] = 'permitted is null here: whether the acting admin holds ' . $spec->permission
            . ' is checked by invoke_admin_action, which has the backend identity this tool does not.';
        $payload['notes'] = $notes;

        return $verbose ? $payload : $this->truncate($payload);
    }

    /** @return list<string> the areas present in the catalog, sorted, de-duplicated */
    public function areas(): array
    {
        $areas = [];
        foreach ($this->visible() as $spec) {
            $areas[self::area($spec)] = true;
        }
        $out = \array_keys($areas);
        \sort($out);

        return $out;
    }

    // ---------------------------------------------------------------------------------------------
    // The `compatibleShopVersions` gate (`backend-action-invoker.md` §9 Q7 — hard fail)
    // ---------------------------------------------------------------------------------------------

    /**
     * Entries whose catalog file excludes the running shop are DROPPED here, not marked. A field
     * list verified against 5.5–5.8 is not evidence about 5.9, and an entry offered with a caveat
     * gets invoked anyway. What the gate must never do is drop them quietly, which is why
     * {@see self::incompatibilityNotes} always says how many went and why.
     *
     * @return list<ActionSpec>
     */
    private function visible(): array
    {
        $excluded = $this->incompatible();
        if ($excluded === []) {
            return $this->catalog->all();
        }

        return \array_values(\array_filter(
            $this->catalog->all(),
            static fn (ActionSpec $spec): bool => !isset($excluded[$spec->id])
        ));
    }

    /** @return list<string> */
    private function visibleIds(): array
    {
        return \array_map(static fn (ActionSpec $spec): string => $spec->id, $this->visible());
    }

    /**
     * The declaration is per FILE, so the exclusion is too: `ActionSpec` carries no source path, and
     * teaching it one would put a build-time concern into the object the invoker binds against. The
     * excluded file is re-read to learn its ids — a second YAML parse, paid only on the branch where
     * the feature is being withheld anyway, and never in the all-compatible case.
     *
     * A file that declares no `compatibleShopVersions` at all is admitted: the validator allows the
     * key to be absent, and inventing a constraint for it would fail closed on a declaration nobody
     * made.
     *
     * @return array<string,string> action id => the declared range that excludes this shop
     *
     * @throws UnsupportedVersionRangeException
     */
    private function incompatible(): array
    {
        if ($this->incompatible !== null) {
            return $this->incompatible;
        }

        $excluded = [];
        foreach ($this->catalog->files() as $file) {
            $declared = (string)$this->catalog->compatibleShopVersions($file);
            if ($declared === '' || ShopVersionRange::parse($declared)->admits($this->shopVersion)) {
                continue;
            }
            foreach (ActionCatalog::fromFile($file)->ids() as $id) {
                $excluded[$id] = $declared;
            }
        }

        return $this->incompatible = $excluded;
    }

    /**
     * Q7's audible half: silence about a dropped entry is the failure this gate exists to prevent —
     * an LLM told "there is no such action" would go looking for a raw SQL workaround.
     *
     * @return list<string>
     */
    private function incompatibilityNotes(): array
    {
        $excluded = $this->incompatible();
        if ($excluded === []) {
            return [];
        }

        $byRange = [];
        foreach ($excluded as $id => $range) {
            $byRange[$range][] = $id;
        }
        $notes = [];
        foreach ($byRange as $range => $ids) {
            $notes[] = \sprintf(
                '%d action(s) are NOT listed: their catalog file declares compatibleShopVersions '
                . '"%s" and this shop runs %s. Withheld: %s. describe_admin_action refuses them for '
                . 'the same reason — their field lists were verified against the declared versions '
                . 'only.',
                \count($ids),
                (string)$range,
                $this->shopVersion,
                \implode(', ', $ids)
            );
        }

        return $notes;
    }

    /**
     * §5.1's ordered truncation. Each stage runs only while the response is still over budget, so a
     * small entry keeps its prose and a large one loses the cheapest thing first.
     *
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    private function truncate(array $payload): array
    {
        // Stage 1 (evidence) is unconditional and happened at build time: it was never put in.
        if (TokenBudget::fits($payload, self::DESCRIBE_BUDGET)) {
            return $payload;
        }

        // Stage 2 — the long tail of optional fields. Constants stay: they are dispatch keys the
        // caller must send, not optional extras.
        $kept      = [];
        $collapsed = 0;
        foreach ($payload['fields'] as $field) {
            if (\is_array($field) && $field['required'] === false && $field['type'] !== 'const') {
                $collapsed++;
                continue;
            }
            $kept[] = $field;
        }
        if ($collapsed > 0) {
            $kept[]            = \sprintf(self::COLLAPSED_FIELDS, $collapsed);
            $payload['fields'] = $kept;
        }
        if (TokenBudget::fits($payload, self::DESCRIBE_BUDGET)) {
            return $payload;
        }

        // Stage 3 — enum value lists. A no-op unless a caller supplied resolved values; when it did,
        // `land[]`'s ~250 ISO codes are the case this exists for.
        $payload['fields'] = self::capEnumValues($payload['fields']);
        if (TokenBudget::fits($payload, self::DESCRIBE_BUDGET)) {
            return $payload;
        }

        // Stage 4 — recipe prose down to imperative fragments.
        if ($payload['recipe'] !== []) {
            $payload['recipe'] = \array_map(self::fragment(...), $payload['recipe']);
            $payload['notes'][] = 'recipe steps were shortened to fit the '
                . self::DESCRIBE_BUDGET . '-token budget — call describe_admin_action({id, verbose:true}) '
                . 'for the full prose.';
        }
        if (TokenBudget::fits($payload, self::DESCRIBE_BUDGET)) {
            return $payload;
        }

        // Stage 5 — there is nothing left this may drop. Say so rather than shed a required field.
        $payload['notes'][] = \sprintf(
            'This response is ~%d tokens, over the %d-token budget. Nothing further may be dropped: '
            . 'required fields, coRequired, sideEffects, undo and prerequisites are never truncated.',
            TokenBudget::estimate($payload),
            self::DESCRIBE_BUDGET
        );

        return $payload;
    }

    /**
     * @param list<array<string,mixed>|string> $fields
     * @return list<array<string,mixed>|string>
     */
    private static function capEnumValues(array $fields): array
    {
        foreach ($fields as $index => $field) {
            if (!\is_array($field) || !isset($field['enum']) || !\is_array($field['enum'])) {
                continue;
            }
            $enum = $field['enum'];
            if (!isset($enum['values']) || !\is_array($enum['values'])) {
                continue;
            }
            $total = \count($enum['values']);
            if ($total <= self::ENUM_VALUE_LIMIT) {
                continue;
            }
            $enum['values']    = \array_slice($enum['values'], 0, self::ENUM_VALUE_LIMIT);
            $enum['moreValues'] = \sprintf(
                '…and %d more; filter with describe_admin_action({id, enumFilter:\'DE\'})',
                $total - self::ENUM_VALUE_LIMIT
            );
            $field['enum']  = $enum;
            $fields[$index] = $field;
        }

        return $fields;
    }

    /**
     * "1. Pick kVersandberechnung from the enum below. 1 = flat rate per country; 2 = …"
     * becomes "Pick kVersandberechnung from the enum below." — the step number is positional
     * information the array already carries, and the trailing clauses are the elaboration §5.1
     * says to shed before touching anything required.
     */
    private static function fragment(string $step): string
    {
        $step = (string)\preg_replace('/^\s*\d+[.)]\s*/u', '', \trim($step));
        if (\preg_match('/^(.*?[.!?])(\s|$)/u', $step, $match) === 1) {
            $step = $match[1];
        }

        return \trim($step);
    }

    /**
     * @param list<string> $notes
     * @return list<array<string,mixed>|string>
     */
    private function fields(ActionSpec $spec, ?string $enumFilter, array &$notes): array
    {
        $out       = [];
        $hasSource = false;
        foreach ($spec->fields as $field) {
            $entry = [
                'name'     => $field->name,
                'type'     => $field->type,
                'required' => $field->required,
            ];
            if ($field->value !== null) {
                $entry['value'] = $field->value;
            }
            if ($field->template !== null) {
                $entry['template'] = $field->template;
            }
            if ($field->doc !== null) {
                $entry['doc'] = $field->doc;
            }
            if ($field->enum !== null) {
                $hasSource     = true;
                $entry['enum'] = $this->enum($field->enum, $enumFilter);
            }
            $out[] = $entry;
        }
        if ($hasSource) {
            $notes[] = 'Enum values are described, not resolved — this tool reads no database. '
                . 'List a table enum with run_sql_read (SELECT <id>, <label> FROM <table>); '
                . 'a service enum is expanded by invoke_admin_action at call time.';
        }

        return $out;
    }

    /**
     * @return array<string,mixed>
     */
    private function enum(EnumSpec $enum, ?string $filter): array
    {
        $out = $enum->isService()
            ? ['service' => (string)$enum->service]
            : ['table' => (string)$enum->table, 'id' => (string)$enum->id, 'label' => (string)$enum->label];

        $key    = $enum->isService() ? (string)$enum->service : (string)$enum->table;
        $values = $this->enumValues[$key] ?? null;
        if ($values === null) {
            return $out;
        }
        if ($filter !== null && \trim($filter) !== '') {
            $needle = \mb_convert_case(\trim($filter), \MB_CASE_LOWER);
            $values = \array_values(\array_filter(
                $values,
                static fn (string $value): bool => \str_contains(\mb_convert_case($value, \MB_CASE_LOWER), $needle)
            ));
            $out['enumFilter'] = $filter;
        }
        $out['values'] = $values;

        return $out;
    }

    /**
     * §4.7 point 1 — the `undo:` block is returned BEFORE the action is invoked, `cannotUndo`
     * included. An entry that declares no undo says `supported: false` with a reason; a bare
     * "no" reads as an oversight, and this one is a fact about the controller.
     *
     * @return array<string,mixed>
     */
    private static function undo(ActionSpec $spec): array
    {
        if ($spec->undo === null) {
            return [
                'supported'  => false,
                'reason'     => 'This catalog entry declares no undo scope, so undo_admin_action '
                    . 'cannot reverse it. Anything it writes is permanent.',
                'tables'     => [],
                'cannotUndo' => [],
            ];
        }

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

    /**
     * `multipass-tools.md` §4 — G3, at most three lines and only in `describe_admin_action`.
     *
     * Flat strings rather than `{action, when}` maps: the caller does not filter or match on these,
     * it reads them, and the map costs roughly twice the tokens to say the same thing. The cap is
     * enforced by {@see ActionCatalogValidator} as well, so a fourth edge is a build error rather
     * than something silently cut here — this slice exists so that a catalog loaded by an older
     * validator still cannot blow the §5.1 budget.
     *
     * @return list<string>
     */
    private static function nextSteps(ActionSpec $spec): array
    {
        return \array_map(
            static fn (NextStep $step): string => $step->line(),
            \array_slice($spec->nextSteps, 0, self::NEXT_STEPS_LIMIT)
        );
    }

    /** @return list<array{fields:list<string>,rule:string}> */
    private static function coRequired(ActionSpec $spec): array
    {
        return \array_map(
            static fn (CoRequirement $rule): array => ['fields' => $rule->fields, 'rule' => $rule->rule],
            $spec->coRequired
        );
    }

    /**
     * §3.4 point 1 — the caller sees the side effects, and the one-line note about the ones a
     * rollback cannot reach, before it decides to invoke.
     *
     * @return list<string>
     */
    private static function sideEffects(ActionSpec $spec): array
    {
        return \array_map(static fn (SideEffect $effect): string => $effect->value, $spec->sideEffects);
    }

    /** The id's first segment: `shipping.method.save` lives in area `shipping`. */
    private static function area(ActionSpec $spec): string
    {
        $dot = \strpos($spec->id, '.');

        return $dot === false ? $spec->id : \substr($spec->id, 0, $dot);
    }

    /** Every catalog entry invokes a backend form or IO handler, so anything with an effect writes. */
    private static function writes(ActionSpec $spec): bool
    {
        return $spec->sideEffects !== [];
    }

    private static function title(ActionSpec $spec): string
    {
        $title = $spec->titleFor('en') ?? $spec->titleFor('de');
        if ($title !== null) {
            return $title;
        }
        foreach ($spec->title as $label) {
            return $label;
        }

        return $spec->id;
    }
}
