<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Journal;

use JTL\DB\DbInterface;
use JTL\DB\ReturnType;
use Plugin\tnt_mcp\src\Mcp\Action\ActionSpec;
use Plugin\tnt_mcp\src\Mcp\Action\JournalScope;
use Plugin\tnt_mcp\src\Mcp\Action\Placeholder;
use Plugin\tnt_mcp\src\Mcp\Support\ArgRedactor;
use stdClass;
use Throwable;

/**
 * The change journal (D29b): before/after images of every database row one `invoke_admin_action`
 * touched, so a change can still be undone minutes and several MCP calls later.
 *
 * ## Why this exists next to the transaction wrapper
 *
 * {@see \Plugin\tnt_mcp\src\Mcp\Action\ActionInvocation} makes ONE invocation atomic. It does
 * nothing for "add a shipping method, then three ZIP surcharges, then realise the whole thing was
 * wrong" — four committed invocations minutes apart. That is this class.
 *
 * ## Where it writes, relative to the transaction (§4.4)
 *
 * The before-snapshot is taken BEFORE `beginTransaction()` and on the primary connection, outside
 * the wrapper: a snapshot that a rollback erased would leave a failed invocation with no evidence.
 * The after-snapshot and the verdict are written after the commit. A hard fatal in between leaves a
 * header with a before-snapshot and `cVerdict = 'incomplete'` — the right failure mode, because the
 * rows are still there to undo.
 *
 * ## What a snapshot is
 *
 * Declared per catalog entry (`journal.scope`), never inferred. Each scope is re-read with the same
 * query before and after, and the two sets are diffed by primary key into insert/update/delete rows.
 * The diff, rather than the entry's `journal.undo:` strategy, is what the undo replays — a strategy
 * cannot tell "this table gained a row" from "this table lost one", and applying the wrong one
 * silently multiplies or erases rows.
 *
 * A scope whose `by:` resolves to nothing (`kVersandart = 0`, i.e. a create) is snapshotted WHOLE,
 * because the row that is about to appear has no id to filter on yet. `maxRows` is what keeps that
 * affordable, and exceeding it costs the undo promise, not the action.
 *
 * ## Never fatal
 *
 * Every public method swallows its own failures like {@see \Plugin\tnt_mcp\src\Mcp\Audit\AuditLogger}
 * does. A journal that cannot write must not take down the write path it is journalling — it must
 * only stop claiming the change can be undone.
 */
final class ChangeJournal
{
    /** Snapshots older than this lose their rows; the header survives as `expired`. */
    public const RETENTION_DAYS = 90;

    /** The newest N entries keep their snapshots regardless of age or size. */
    public const KEEP_NEWEST = 200;

    /** Hard cap across all stored snapshots. */
    public const MAX_SNAPSHOT_BYTES = 200 * 1024 * 1024;

    private readonly TableKeys $keys;

    public function __construct(private readonly DbInterface $db)
    {
        $this->keys = new TableKeys($this->db);
    }

    /**
     * Header row + before-snapshot, in that order. Null when the entry declares no `journal:` block
     * at all, or when the journal could not write — both mean "no undo is being promised", and the
     * caller reports that rather than pretending.
     *
     * @param array<string,mixed> $args
     */
    public function open(ActionSpec $spec, array $args, string $tokenId, string $actingLogin): ?ChangeDraft
    {
        if ($spec->journal === null || $spec->journal->scope === []) {
            return null;
        }

        try {
            $changeId = self::uuid();
            $row      = new stdClass();

            $row->cChangeId    = $changeId;
            $row->cActionId    = \mb_substr($spec->id, 0, 191);
            $row->cTokenId     = \mb_substr($tokenId, 0, 191);
            $row->cActingLogin = \mb_substr($actingLogin, 0, 191);
            $row->cArgs        = (string)\json_encode(self::redact($args));
            $row->cVerdict     = 'incomplete';
            $row->cUndoState   = 'undoable';
            $row->cUndoReason  = '';
            $row->cCacheFlushed = 'none';
            $row->cUndoOf      = '';
            $row->nSnapshotBytes = 0;
            $row->dCreated     = \date('Y-m-d H:i:s');

            $kChange = $this->db->insertRow('tnt_mcp_change', $row);
            if ($kChange < 1) {
                return null;
            }

            $draft = new ChangeDraft($changeId, $kChange);
            foreach ($spec->journal->scope as $scope) {
                $this->snapshot($draft, $scope, $args, $spec->journal->maxRows);
            }

            return $draft;
        } catch (Throwable) {
            return null;
        }
    }

    /**
     * After-snapshot, diff, verdict — and the undo summary `invoke_admin_action` must return
     * ALONGSIDE the changeId (§4.7: never a bare changeId).
     *
     * @param array<string,mixed> $args
     * @return array<string,mixed> `{changeId, supported, reason?, expiresAt?, cannotUndo, rows}`
     */
    public function close(
        ChangeDraft $draft,
        ActionSpec $spec,
        array $args,
        string $verdict,
        bool $committed,
        bool $cacheFlushed,
    ): array {
        try {
            $written = 0;
            if ($committed) {
                $written = $this->diff($draft, $spec, $args);
            } else {
                $draft->refusePromise(
                    'the invocation was rolled back, so there is nothing to undo — no row was committed'
                );
            }

            $bytes = $this->bytesOf($draft->kChange);
            $state = $draft->notUndoable !== null ? 'not-undoable' : ($written > 0 ? 'undoable' : 'not-undoable');
            if ($written === 0 && $draft->notUndoable === null && $committed) {
                $draft->refusePromise('the action committed but changed no row in any declared journal table');
            }

            $this->db->queryPrepared(
                'UPDATE tnt_mcp_change
                    SET cVerdict = :v, cUndoState = :s, cUndoReason = :r, cCacheFlushed = :c,
                        nSnapshotBytes = :b
                    WHERE kChange = :k',
                [
                    'v' => \mb_substr($verdict, 0, 32),
                    's' => $state,
                    'r' => \mb_substr((string)$draft->notUndoable, 0, 500),
                    'c' => $cacheFlushed ? 'declared' : 'none',
                    'b' => $bytes,
                    'k' => $draft->kChange,
                ],
                ReturnType::AFFECTED_ROWS,
            );

            $this->prune();

            return $this->summary($draft, $spec, $state, $written);
        } catch (Throwable $e) {
            return [
                'changeId'   => $draft->changeId,
                'supported'  => false,
                'reason'     => 'the journal entry could not be completed: ' . $e->getMessage(),
                'cannotUndo' => $spec->undo->cannotUndo ?? [],
            ];
        }
    }

    // -----------------------------------------------------------------------------------------
    // Reads — list_recent_changes / undo_admin_action
    // -----------------------------------------------------------------------------------------

    /**
     * @return list<array<string,mixed>> newest first
     */
    public function recent(int $limit, ?string $actionId = null, ?string $undoState = null): array
    {
        $where  = [];
        $params = [];
        if ($actionId !== null && $actionId !== '') {
            $where[]            = 'cActionId = :a';
            $params['a']        = $actionId;
        }
        if ($undoState !== null && $undoState !== '') {
            $where[]     = 'cUndoState = :u';
            $params['u'] = $undoState;
        }
        // MySQL will not take a placeholder in LIMIT under emulated prepares, so it is interpolated
        // as an int — the same discipline {@see \Plugin\tnt_mcp\src\Mcp\Log\LogReader} follows.
        $rows = $this->db->getArrays(
            'SELECT c.cChangeId, c.cActionId, c.cActingLogin, c.cVerdict, c.cUndoState, c.cUndoReason,
                    c.cCacheFlushed, c.cUndoOf, c.dCreated, c.dUndone,
                    (SELECT COUNT(*) FROM tnt_mcp_change_row r WHERE r.kChange = c.kChange) AS nRows
                FROM tnt_mcp_change c'
            . ($where === [] ? '' : ' WHERE ' . \implode(' AND ', $where))
            . ' ORDER BY c.kChange DESC LIMIT ' . \max(1, \min(200, $limit)),
            $params,
        );

        return \array_values(\array_map(static fn (array $row): array => [
            'changeId'     => (string)($row['cChangeId'] ?? ''),
            'actionId'     => (string)($row['cActionId'] ?? ''),
            'actingLogin'  => (string)($row['cActingLogin'] ?? ''),
            'verdict'      => (string)($row['cVerdict'] ?? ''),
            'undoState'    => (string)($row['cUndoState'] ?? ''),
            'undoReason'   => (string)($row['cUndoReason'] ?? ''),
            'cacheFlushed' => ((string)($row['cCacheFlushed'] ?? 'none')) !== 'none',
            'undoOf'       => ((string)($row['cUndoOf'] ?? '')) ?: null,
            'createdAt'    => (string)($row['dCreated'] ?? ''),
            'undoneAt'     => $row['dUndone'] === null ? null : (string)$row['dUndone'],
            'rowCount'     => (int)($row['nRows'] ?? 0),
        ], $rows));
    }

    /** @return ?array<string,mixed> the raw header row, or null for an unknown changeId */
    public function header(string $changeId): ?array
    {
        return $this->db->getSingleArray(
            'SELECT * FROM tnt_mcp_change WHERE cChangeId = :c',
            ['c' => $changeId],
        );
    }

    /** @return list<array<string,mixed>> the journalled rows, in the order they were snapshotted */
    public function rows(int $kChange): array
    {
        /** @var list<array<string,mixed>> $rows getArrays() yields column-keyed rows, in order */
        $rows = \array_values($this->db->getArrays(
            'SELECT cTable, cKeyJson, cOp, cBefore, cAfter
                FROM tnt_mcp_change_row WHERE kChange = :k ORDER BY kChangeRow',
            ['k' => $kChange],
        ));

        return $rows;
    }

    public function markUndone(int $kChange): void
    {
        $this->db->queryPrepared(
            'UPDATE tnt_mcp_change SET cUndoState = \'undone\', dUndone = :d WHERE kChange = :k',
            ['d' => \date('Y-m-d H:i:s'), 'k' => $kChange],
            ReturnType::AFFECTED_ROWS,
        );
    }

    /**
     * The undo of an undo: a plain header row pointing back at what it reverted, so the history
     * reads as a chain rather than as an unexplained second edit.
     *
     * @param list<array{table:string,key:array<string,mixed>,op:string,before:?array<string,mixed>,after:?array<string,mixed>}> $rows
     */
    public function recordUndo(string $ofChangeId, string $actionId, string $tokenId, string $actingLogin, array $rows): ?string
    {
        try {
            $changeId = self::uuid();
            $row      = new stdClass();

            $row->cChangeId      = $changeId;
            $row->cActionId      = \mb_substr('undo:' . $actionId, 0, 191);
            $row->cTokenId       = \mb_substr($tokenId, 0, 191);
            $row->cActingLogin   = \mb_substr($actingLogin, 0, 191);
            $row->cArgs          = (string)\json_encode(['changeId' => $ofChangeId]);
            $row->cVerdict       = 'ok';
            $row->cUndoState     = $rows === [] ? 'not-undoable' : 'undoable';
            $row->cUndoReason    = $rows === [] ? 'the undo changed no row' : '';
            $row->cCacheFlushed  = 'declared';
            $row->cUndoOf        = $ofChangeId;
            $row->nSnapshotBytes = 0;
            $row->dCreated       = \date('Y-m-d H:i:s');

            $kChange = $this->db->insertRow('tnt_mcp_change', $row);
            if ($kChange < 1) {
                return null;
            }
            foreach ($rows as $entry) {
                $this->writeRow(
                    $kChange,
                    $entry['table'],
                    $entry['key'],
                    $entry['op'],
                    $entry['before'],
                    $entry['after'],
                );
            }
            $this->db->queryPrepared(
                'UPDATE tnt_mcp_change SET nSnapshotBytes = :b WHERE kChange = :k',
                ['b' => $this->bytesOf($kChange), 'k' => $kChange],
                ReturnType::AFFECTED_ROWS,
            );

            return $changeId;
        } catch (Throwable) {
            return null;
        }
    }

    // -----------------------------------------------------------------------------------------
    // Snapshotting
    // -----------------------------------------------------------------------------------------

    /**
     * @param array<string,mixed> $args
     */
    private function snapshot(ChangeDraft $draft, JournalScope $scope, array $args, int $maxRows): void
    {
        $table = $scope->table;
        if (\preg_match(TableKeys::IDENTIFIER, $table) !== 1) {
            $draft->refusePromise(\sprintf('journal scope names an unusable table "%s"', $table));

            return;
        }
        $keys = $this->keys->of($table);
        if ($keys === []) {
            $draft->refusePromise(\sprintf(
                '%s has no primary key, so its rows cannot be addressed again for an undo',
                $table,
            ));

            return;
        }

        $columns = self::columns($scope, $keys, $draft, $table);
        if ($columns === null) {
            return;
        }
        $where = self::where($scope, $args, $draft, $table);
        if ($where === null) {
            return;
        }

        $rows = $this->read($table, $columns, $where, $maxRows + 1);
        if (\count($rows) > $maxRows) {
            $draft->refusePromise(\sprintf(
                'the before-snapshot of %s exceeded maxRows (%d); the action ran but is not undoable',
                $table,
                $maxRows,
            ));

            return;
        }

        $draft->snapshots[] = [
            'table'    => $table,
            'keys'     => $keys,
            'columns'  => $columns,
            'narrowed' => $scope->columns !== [],
            'where'    => $where,
            'rows'     => self::index($rows, $keys),
        ];
    }

    /**
     * The selected columns: the entry's narrowing plus, unconditionally, the primary key. See
     * {@see TableKeys} for why the key is not optional.
     *
     * @param list<string> $keys
     * @return ?list<string> null when the entry named a column that cannot be interpolated
     */
    private static function columns(JournalScope $scope, array $keys, ChangeDraft $draft, string $table): ?array
    {
        if ($scope->columns === []) {
            return ['*'];
        }
        $columns = $scope->columns;
        foreach ($columns as $column) {
            if (\preg_match(TableKeys::IDENTIFIER, $column) !== 1) {
                $draft->refusePromise(\sprintf('journal scope of %s names an unusable column', $table));

                return null;
            }
        }
        foreach ($keys as $key) {
            if (!\in_array($key, $columns, true)) {
                $columns[] = $key;
            }
        }

        return $columns;
    }

    /**
     * The `by:` filter with its placeholders resolved. An empty result is not an error: a `by:`
     * pointing at `kVersandart = 0` is a CREATE, whose row does not exist yet and therefore cannot
     * be filtered on — the scope is read whole instead, bounded by `maxRows`.
     *
     * @param array<string,mixed> $args
     * @return ?array<string,mixed> null when a column name is unusable
     */
    private static function where(JournalScope $scope, array $args, ChangeDraft $draft, string $table): ?array
    {
        $where = [];
        foreach ($scope->by as $column => $raw) {
            if (\preg_match(TableKeys::IDENTIFIER, $column) !== 1) {
                $draft->refusePromise(\sprintf('journal scope of %s filters on an unusable column', $table));

                return null;
            }
            $value = self::resolve($raw, $args);
            if ($value === null || $value === '' || $value === '0' || $value === 0) {
                // Create: nothing to filter on yet. Whole-table snapshot, bounded by maxRows.
                return [];
            }
            $where[$column] = $value;
        }

        return $where;
    }

    /**
     * The catalog's placeholder grammar, the only expression language the journal understands.
     *
     * @param array<string,mixed> $args
     */
    private static function resolve(mixed $raw, array $args): int|string|null
    {
        if (!\is_string($raw)) {
            return \is_int($raw) ? $raw : null;
        }
        $placeholder = Placeholder::parse($raw);
        if ($placeholder === null) {
            return Placeholder::looksLikeOne($raw) ? null : $raw;
        }
        $value = $args[$placeholder->field] ?? null;
        if ($placeholder->countsElements) {
            return \is_array($value) ? \count($value) : null;
        }

        return \is_scalar($value) ? (\is_bool($value) ? (int)$value : (\is_int($value) ? $value : (string)$value)) : null;
    }

    /**
     * @param list<string>        $columns
     * @param array<string,mixed> $where
     * @return list<array<string,mixed>>
     */
    private function read(string $table, array $columns, array $where, int $limit): array
    {
        $select = $columns === ['*'] ? '*' : '`' . \implode('`, `', $columns) . '`';
        $clause = '';
        $params = [];
        $i      = 0;
        foreach ($where as $column => $value) {
            $name             = 'w' . $i++;
            $clause          .= ($clause === '' ? ' WHERE ' : ' AND ') . '`' . $column . '` = :' . $name;
            $params[$name]    = $value;
        }

        /** @var list<array<string,mixed>> $rows getArrays() yields column-keyed rows, in order */
        $rows = \array_values($this->db->getArrays(
            'SELECT ' . $select . ' FROM `' . $table . '`' . $clause . ' LIMIT ' . \max(1, $limit),
            $params,
        ));

        return $rows;
    }

    /**
     * @param list<array<string,mixed>> $rows
     * @param list<string>              $keys
     * @return array<string,array<string,mixed>>
     */
    private static function index(array $rows, array $keys): array
    {
        $out = [];
        foreach ($rows as $row) {
            $key = [];
            foreach ($keys as $column) {
                $key[$column] = $row[$column] ?? null;
            }
            $out[(string)\json_encode($key)] = $row;
        }

        return $out;
    }

    // -----------------------------------------------------------------------------------------
    // The diff
    // -----------------------------------------------------------------------------------------

    /**
     * Re-read every scope and write one `tnt_mcp_change_row` per row that actually differs.
     *
     * @param array<string,mixed> $args
     * @return int rows written
     */
    private function diff(ChangeDraft $draft, ActionSpec $spec, array $args): int
    {
        $maxRows = $spec->journal->maxRows ?? 2000;
        $written = 0;

        foreach ($draft->snapshots as $snapshot) {
            $after = self::index(
                $this->read($snapshot['table'], $snapshot['columns'], $snapshot['where'], $maxRows + 1),
                $snapshot['keys'],
            );

            foreach ($snapshot['rows'] as $key => $before) {
                $now = $after[$key] ?? null;
                if ($now === null) {
                    if ($snapshot['narrowed']) {
                        $draft->refusePromise(\sprintf(
                            'a row disappeared from %s, whose journal scope only snapshots some of '
                            . 'its columns — the deleted row cannot be reconstructed',
                            $snapshot['table'],
                        ));

                        continue;
                    }
                    $this->writeRow($draft->kChange, $snapshot['table'], self::keyOf($key), 'delete', $before, null);
                    $written++;
                    continue;
                }
                if (self::differs($before, $now)) {
                    $this->writeRow($draft->kChange, $snapshot['table'], self::keyOf($key), 'update', $before, $now);
                    $written++;
                }
            }

            foreach ($after as $key => $now) {
                if (!\array_key_exists($key, $snapshot['rows'])) {
                    if ($snapshot['narrowed']) {
                        $draft->refusePromise(\sprintf(
                            'a row appeared in %s, whose journal scope only snapshots some of its '
                            . 'columns — removing it again could not be verified',
                            $snapshot['table'],
                        ));

                        continue;
                    }
                    $this->writeRow($draft->kChange, $snapshot['table'], self::keyOf($key), 'insert', null, $now);
                    $written++;
                }
            }
        }

        return $written;
    }

    /**
     * @param array<string,mixed>  $key
     * @param ?array<string,mixed> $before
     * @param ?array<string,mixed> $after
     */
    private function writeRow(int $kChange, string $table, array $key, string $op, ?array $before, ?array $after): void
    {
        $row = new stdClass();

        $row->kChange  = $kChange;
        $row->cTable   = \mb_substr($table, 0, 191);
        $row->cKeyJson = \mb_substr((string)\json_encode($key), 0, 500);
        $row->cOp      = $op;
        $row->cBefore  = $before === null ? null : (string)\json_encode($before);
        $row->cAfter   = $after === null ? null : (string)\json_encode($after);

        $this->db->insertRow('tnt_mcp_change_row', $row);
    }

    /** @return array<string,mixed> */
    private static function keyOf(string $json): array
    {
        $key = \json_decode($json, true);

        return \is_array($key) ? $key : [];
    }

    /**
     * Loose comparison on purpose: a column read back through PDO can change PHP type without the
     * row having changed (`'1'` vs `1`), and a journal that reports those as edits fills the undo
     * plan with rows nobody touched.
     *
     * @param array<string,mixed> $before
     * @param array<string,mixed> $after
     */
    private static function differs(array $before, array $after): bool
    {
        foreach ($before as $column => $value) {
            $now = $after[$column] ?? null;
            if ($value === null || $now === null) {
                if ($value !== $now) {
                    return true;
                }
                continue;
            }
            if ((string)$value !== (string)$now) {
                return true;
            }
        }

        return false;
    }

    // -----------------------------------------------------------------------------------------
    // Retention (§4.5) and reporting
    // -----------------------------------------------------------------------------------------

    /**
     * Opportunistic, at the end of an invocation rather than on a cron — one less moving part.
     * Snapshots expire; headers never do, so the history stays readable after the undo is gone.
     */
    public function prune(): void
    {
        try {
            $keep = (int)($this->db->getSingleArray(
                'SELECT kChange FROM tnt_mcp_change ORDER BY kChange DESC LIMIT 1 OFFSET '
                . (self::KEEP_NEWEST - 1),
            )['kChange'] ?? 0);
            if ($keep < 1) {
                return;
            }

            $this->expire(
                'SELECT kChange FROM tnt_mcp_change
                    WHERE kChange < :k AND dCreated < DATE_SUB(NOW(), INTERVAL ' . self::RETENTION_DAYS . ' DAY)
                      AND cUndoState <> \'expired\'
                    ORDER BY kChange LIMIT 100',
                ['k' => $keep],
                'the snapshot expired after ' . self::RETENTION_DAYS . ' days',
            );

            $bytes = (int)($this->db->getSingleArray(
                'SELECT COALESCE(SUM(LENGTH(COALESCE(cBefore, \'\')) + LENGTH(COALESCE(cAfter, \'\'))), 0) AS n
                    FROM tnt_mcp_change_row',
            )['n'] ?? 0);
            if ($bytes > self::MAX_SNAPSHOT_BYTES) {
                $this->expire(
                    'SELECT kChange FROM tnt_mcp_change
                        WHERE kChange < :k AND cUndoState <> \'expired\'
                        ORDER BY kChange LIMIT 100',
                    ['k' => $keep],
                    'the snapshot was evicted: stored snapshots exceeded '
                        . (int)(self::MAX_SNAPSHOT_BYTES / 1024 / 1024) . ' MB',
                );
            }
        } catch (Throwable) {
            // Pruning is housekeeping. It must never fail an invocation that already succeeded.
        }
    }

    /**
     * @param array<string,mixed> $params
     */
    private function expire(string $select, array $params, string $reason): void
    {
        foreach ($this->db->getArrays($select, $params) as $row) {
            $kChange = (int)($row['kChange'] ?? 0);
            if ($kChange < 1) {
                continue;
            }
            $this->db->queryPrepared(
                'DELETE FROM tnt_mcp_change_row WHERE kChange = :k',
                ['k' => $kChange],
                ReturnType::AFFECTED_ROWS,
            );
            $this->db->queryPrepared(
                'UPDATE tnt_mcp_change
                    SET cUndoState = \'expired\', cUndoReason = :r, nSnapshotBytes = 0
                    WHERE kChange = :k AND cUndoState <> \'undone\'',
                ['r' => \mb_substr($reason, 0, 500), 'k' => $kChange],
                ReturnType::AFFECTED_ROWS,
            );
        }
    }

    private function bytesOf(int $kChange): int
    {
        return (int)($this->db->getSingleArray(
            'SELECT COALESCE(SUM(LENGTH(COALESCE(cBefore, \'\')) + LENGTH(COALESCE(cAfter, \'\'))), 0) AS n
                FROM tnt_mcp_change_row WHERE kChange = :k',
            ['k' => $kChange],
        )['n'] ?? 0);
    }

    /**
     * §4.7's communication rule, second place: the changeId NEVER travels without what it cannot
     * undo. A bare changeId reads as a promise of full reversibility, and that is the one thing
     * this feature must not imply.
     *
     * @return array<string,mixed>
     */
    private function summary(ChangeDraft $draft, ActionSpec $spec, string $state, int $written): array
    {
        $cannotUndo = $spec->undo->cannotUndo ?? [];
        if ($state !== 'undoable') {
            return [
                'changeId'   => $draft->changeId,
                'supported'  => false,
                'reason'     => (string)$draft->notUndoable,
                'cannotUndo' => $cannotUndo,
                'rows'       => $written,
            ];
        }

        return [
            'changeId'   => $draft->changeId,
            'supported'  => true,
            'expiresAt'  => \date('Y-m-d', (int)\strtotime('+' . self::RETENTION_DAYS . ' days')),
            'cannotUndo' => $cannotUndo,
            'rows'       => $written,
            'notes'      => [
                'Call undo_admin_action({changeId: "' . $draft->changeId . '"}) for the undo plan; '
                . 'it applies nothing without confirm: true.',
            ],
        ];
    }

    /**
     * Argument values are echoed back by `list_recent_changes`, so anything password-shaped is
     * masked here rather than at read time — the journal must not become the one place a secret
     * survives in clear text.
     *
     * @param array<string,mixed> $args
     * @return array<string,mixed>
     */
    private static function redact(array $args): array
    {
        return ArgRedactor::redact($args);
    }

    private static function uuid(): string
    {
        $bytes    = \random_bytes(16);
        $bytes[6] = \chr((\ord($bytes[6]) & 0x0F) | 0x40);
        $bytes[8] = \chr((\ord($bytes[8]) & 0x3F) | 0x80);

        return \vsprintf('%s%s-%s-%s-%s-%s%s%s', \str_split(\bin2hex($bytes), 4));
    }
}
