<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Admin;

use Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog;
use Plugin\tnt_mcp\src\Mcp\Action\ActionSpec;
use Plugin\tnt_mcp\src\Mcp\Journal\ChangeJournal;

/**
 * Read-only view model for the admin "Änderungen" tab (design round 3, step 28): the change journal
 * `invoke_admin_action` writes, rendered for a human instead of for an MCP client.
 *
 * The journal is otherwise only visible through `list_recent_changes`, i.e. only to the LLM that
 * caused the change. A merchant who wants to know what the connected AI did to their shop — and to
 * put it back — needs the same data in the backend, which is what this class shapes.
 *
 * Two shapes:
 *   - {@see self::recent()} the list: one row per journalled change, with the catalog title resolved,
 *     a badge TONE per verdict/state (the template owns the classes), and whether the undo button
 *     may be offered at all.
 *   - {@see self::detail()} one change: every snapshotted row, decoded, reduced to the columns that
 *     actually differ so the table shows a diff and not two JSON blobs.
 *
 * It never writes. Undoing is {@see \Plugin\tnt_mcp\src\Mcp\Journal\UndoService}'s job, called by the
 * controller — this class only decides whether the button is shown and, when it is not, why not.
 */
final class ChangeJournalReport
{
    /** Column values longer than this are cut in the diff — the full value stays in the journal. */
    private const VALUE_LIMIT = 160;

    /**
     * @param ?string $language {@see AdminLanguage::DE} or {@see AdminLanguage::EN}; `null` resolves
     *                          the admin session's language, which falls back to German off-session
     *                          (CLI, MCP request) — the labels below are the tab's only prose.
     */
    public function __construct(
        private readonly ChangeJournal $journal,
        private readonly ?ActionCatalog $catalog = null,
        private readonly ?string $language = null,
    ) {
    }

    /**
     * @param ?string $undoState optional `cUndoState` filter (`undoable`, `undone`, `not-undoable`,
     *                           `expired`)
     * @return list<array<string,mixed>> newest first
     */
    public function recent(int $limit = 50, ?string $undoState = null): array
    {
        $out = [];
        foreach ($this->journal->recent($limit, null, $undoState) as $row) {
            $state = (string)$row['undoState'];
            $spec  = $this->spec((string)$row['actionId']);

            $out[] = $row + [
                'title'        => $spec === null ? (string)$row['actionId'] : $this->title($spec),
                'undoable'     => $state === 'undoable',
                'stateLabel'   => $this->stateLabel($state),
                'stateBadge'   => self::stateBadge($state),
                'verdictBadge' => self::verdictBadge((string)$row['verdict']),
                'cannotUndo'   => $this->cannotUndo($spec),
            ];
        }

        return $out;
    }

    /**
     * One change with its snapshotted rows decoded into per-column diffs.
     *
     * @return ?array<string,mixed> null when the id is unknown
     */
    public function detail(string $changeId): ?array
    {
        $header = $this->journal->header($changeId);
        if ($header === null) {
            return null;
        }

        $spec = $this->spec((string)($header['cActionId'] ?? ''));
        $rows = [];
        foreach ($this->journal->rows((int)($header['kChange'] ?? 0)) as $row) {
            $before = self::decode($row['cBefore'] ?? null);
            $after  = self::decode($row['cAfter'] ?? null);

            $rows[] = [
                'table'   => (string)($row['cTable'] ?? ''),
                'op'      => (string)($row['cOp'] ?? ''),
                'key'     => self::describeKey(self::decode($row['cKeyJson'] ?? null)),
                'changes' => self::diff($before, $after),
            ];
        }

        $state = (string)($header['cUndoState'] ?? '');

        return [
            'changeId'     => (string)($header['cChangeId'] ?? ''),
            'actionId'     => (string)($header['cActionId'] ?? ''),
            'title'        => $spec === null ? (string)($header['cActionId'] ?? '') : $this->title($spec),
            'actingLogin'  => (string)($header['cActingLogin'] ?? ''),
            'verdict'      => (string)($header['cVerdict'] ?? ''),
            'verdictBadge' => self::verdictBadge((string)($header['cVerdict'] ?? '')),
            'undoState'    => $state,
            'stateLabel'   => $this->stateLabel($state),
            'stateBadge'   => self::stateBadge($state),
            'undoReason'   => (string)($header['cUndoReason'] ?? ''),
            'cacheFlushed' => ((string)($header['cCacheFlushed'] ?? 'none')) !== 'none',
            'undoOf'       => ((string)($header['cUndoOf'] ?? '')) ?: null,
            'createdAt'    => (string)($header['dCreated'] ?? ''),
            'undoneAt'     => ($header['dUndone'] ?? null) === null ? null : (string)$header['dUndone'],
            'undoable'     => $state === 'undoable',
            'cannotUndo'   => $this->cannotUndo($spec),
            'rows'         => $rows,
        ];
    }

    // ---------------------------------------------------------------------------------------------

    private function spec(string $actionId): ?ActionSpec
    {
        return $actionId === '' ? null : $this->catalog?->get($actionId);
    }

    /**
     * What the entry declares it cannot put back, in the tab's language — empty for an unknown
     * action. These are prose written by the catalog author, so they follow the same de/en rule as
     * the title; an entry that declared only one list is served to everyone unchanged.
     *
     * @return list<string>
     */
    private function cannotUndo(?ActionSpec $spec): array
    {
        $undo = $spec?->undo;

        return $undo === null ? [] : $undo->cannotUndoFor($this->language ?? AdminLanguage::current());
    }

    private function title(ActionSpec $spec): string
    {
        $language = $this->language ?? AdminLanguage::current();
        $other    = $language === AdminLanguage::EN ? AdminLanguage::DE : AdminLanguage::EN;
        $title    = $spec->titleFor($language) ?? $spec->titleFor($other);
        if ($title !== null) {
            return $title;
        }
        foreach ($spec->title as $label) {
            return $label;
        }

        return $spec->id;
    }

    /**
     * Only the columns that differ, so the table shows what changed rather than the whole row. An
     * insert has no `before` and a delete no `after`; both are rendered as a single-sided diff.
     *
     * @param ?array<string,mixed> $before
     * @param ?array<string,mixed> $after
     * @return list<array{column:string,before:?string,after:?string}>
     */
    private static function diff(?array $before, ?array $after): array
    {
        $columns = \array_keys(($before ?? []) + ($after ?? []));
        $out     = [];
        foreach ($columns as $column) {
            $old = \array_key_exists($column, $before ?? []) ? self::scalar($before[$column]) : null;
            $new = \array_key_exists($column, $after ?? []) ? self::scalar($after[$column]) : null;
            if ($old === $new) {
                continue;
            }
            $out[] = ['column' => (string)$column, 'before' => $old, 'after' => $new];
        }

        return $out;
    }

    /** @param array<string,mixed>|null $key */
    private static function describeKey(?array $key): string
    {
        if ($key === null || $key === []) {
            return '';
        }
        $parts = [];
        foreach ($key as $column => $value) {
            $parts[] = $column . '=' . (self::scalar($value) ?? 'NULL');
        }

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

    private static function scalar(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }
        if (\is_bool($value)) {
            return $value ? '1' : '0';
        }
        if (\is_scalar($value)) {
            $text = (string)$value;
        } else {
            $text = (string)\json_encode($value, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES);
        }

        return \mb_strlen($text) > self::VALUE_LIMIT
            ? \mb_substr($text, 0, self::VALUE_LIMIT) . '…'
            : $text;
    }

    /** @return ?array<string,mixed> */
    private static function decode(mixed $json): ?array
    {
        if (!\is_string($json) || $json === '') {
            return null;
        }
        $decoded = \json_decode($json, true);

        return \is_array($decoded) ? $decoded : null;
    }

    /**
     * Every `cUndoState` the journal can store, with both admin-UI languages. Public so a test can
     * assert the map stays complete — a state added to {@see ChangeJournal} without a label here
     * would otherwise silently leak the raw enum value into the tab.
     *
     * @return array<string, array{de: string, en: string}>
     */
    public static function stateLabels(): array
    {
        return [
            'undoable'     => ['de' => 'Rückgängig möglich', 'en' => 'Can be undone'],
            'undone'       => ['de' => 'Rückgängig gemacht', 'en' => 'Undone'],
            'not-undoable' => ['de' => 'Nicht rückgängig machbar', 'en' => 'Cannot be undone'],
            'expired'      => [
                'de' => 'Snapshot gelöscht (Aufbewahrung)',
                'en' => 'Snapshot deleted (retention)',
            ],
        ];
    }

    private function stateLabel(string $state): string
    {
        $texts = self::stateLabels()[$state] ?? null;

        return $texts === null ? $state : AdminLanguage::pick($texts, $this->language);
    }

    /**
     * A TONE, not a CSS class: the template maps it onto `$ui.chip` (adminmenu/Controller/Ui.php).
     * These used to be Bootstrap's `badge-*` names, which pinned the tab to the shop admin's CSS.
     */
    private static function stateBadge(string $state): string
    {
        return match ($state) {
            'undoable' => 'success',
            'undone'   => 'neutral',
            default    => 'warning',
        };
    }

    /** A tone — see {@see self::stateBadge()}. */
    private static function verdictBadge(string $verdict): string
    {
        return match ($verdict) {
            'ok'         => 'success',
            'incomplete' => 'warning',
            default      => 'danger',
        };
    }
}
