<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\adminmenu\Controller;

use JTL\Helpers\Form;
use JTL\Helpers\Request;
use JTL\Shop;
use Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog;
use Plugin\tnt_mcp\src\Mcp\Admin\ChangeJournalReport;
use Plugin\tnt_mcp\src\Mcp\Backend\AdminIdentity;
use Plugin\tnt_mcp\src\Mcp\Backend\BackendIdentityFactory;
use Plugin\tnt_mcp\src\Mcp\Journal\ChangeJournal;
use Plugin\tnt_mcp\src\Mcp\Journal\UndoService;
use Throwable;

/**
 * "Änderungen" tab: the change journal every `invoke_admin_action` writes, and the undo button over
 * it (design round 3 §4, step 28).
 *
 * Until this tab existed the journal was only reachable through `list_recent_changes` — i.e. only by
 * the LLM that made the change. The merchant is the one who carries the risk, so they get the same
 * list, the same per-row diff, and the same undo.
 *
 * The undo is POST-only and gated on the JTL backend XSRF token. It goes through the very same
 * {@see UndoService} the MCP tool uses, with `confirm: true` — the plan/confirm split that protects
 * the LLM is replaced here by the two-step UI (open the change, read the diff, press the button),
 * and the service still refuses on its own when the rows have been superseded since.
 */
final class Changes extends BaseAdminController
{
    private const LIMIT = 50;

    public function render(): void
    {
        if (!$this->isAdmin()) {
            $this->notLoggedInNotice();

            return;
        }

        echo $this->fetch('changes.tpl', $this->data());
    }

    /**
     * The template vars, separated from rendering so the "Aktivität" tab can show this view beside
     * the request log without this controller having to know it is being composed — and, more to
     * the point, without the undo POST handling being duplicated into a second controller.
     *
     * ⚠️ NOT side-effect free: calling it processes a pending undo POST. That is deliberate — the
     * flash it returns is part of the view — but it means it must be called exactly once per
     * request.
     *
     * @return array<string, mixed>
     */
    public function data(): array
    {
        $flash = $this->handlePostAction();

        // Guarantee the backend XSRF token exists so the undo form can carry it.
        Form::getTokenInput();

        $report   = $this->report();
        $changeId = \trim((string)Request::verifyGPDataString('tntMcpChange'));
        $detail   = $changeId === '' ? null : $report->detail($changeId);

        $changes = [];
        foreach ($report->recent(self::LIMIT) as $change) {
            $change['detailUrl'] = $this->url(['tntMcpChange' => (string)$change['changeId']]);
            $changes[]           = $change;
        }

        return [
            'changes'  => $changes,
            'listUrl'  => $this->url([]),
            'detail'   => $detail,
            'notFound' => $changeId !== '' && $detail === null ? $changeId : '',
            'limit'    => self::LIMIT,
            'flash'    => $flash,
            'jtlToken' => $_SESSION['jtl_token'] ?? '',
        ];
    }

    /**
     * A link back to THIS tab. {@see BaseAdminController::selfUrl()} carries `cPluginTab`, without
     * which "Details" reloads the plugin page on its first tab and the detail card — rendered, but
     * in a hidden pane — is never seen.
     *
     * @param array<string,string> $overrides
     */
    private function url(array $overrides): string
    {
        return $this->selfUrl($overrides + ['tntMcpChange' => '']);
    }

    private function report(): ChangeJournalReport
    {
        return new ChangeJournalReport($this->journal(), $this->catalog(), $this->language());
    }

    private function journal(): ChangeJournal
    {
        return new ChangeJournal($this->db());
    }

    /** A catalog that fails to load only costs the human-readable titles, never the list itself. */
    private function catalog(): ?ActionCatalog
    {
        try {
            return ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions');
        } catch (Throwable) {
            return null;
        }
    }

    /**
     * Process the undo POST, if present.
     *
     * @return array{type: string, text: string}|null
     */
    private function handlePostAction(): ?array
    {
        if ((string)Request::postVar('tntMcpAction', '') !== 'undo') {
            return null;
        }

        if (!Form::validateToken()) {
            return ['type' => 'danger', 'text' => $this->text([
                'de' => 'Sicherheits-Token ungültig. Bitte erneut versuchen.',
                'en' => 'Security token invalid. Please try again.',
            ])];
        }

        $changeId = \trim((string)Request::postVar('changeId', ''));
        if ($changeId === '') {
            return ['type' => 'danger', 'text' => $this->text([
                'de' => 'Keine Änderung angegeben.',
                'en' => 'No change specified.',
            ])];
        }

        try {
            $service = new UndoService(
                $this->db(),
                $this->journal(),
                ActionCatalog::fromDirectory(__DIR__ . '/../../catalog/actions'),
                static fn () => Shop::Container()->getCache(),
                // Same rights check the MCP path gets: this tab's undo button is POST-token-gated
                // and only reachable by a logged-in admin, but "logged in" is not "allowed to change
                // shipping methods". The acting login is the backend account's own.
                static fn (string $login): AdminIdentity => (new BackendIdentityFactory(
                    Shop::Container()->getDB()
                ))->forLogin($login),
            );
            $result = $service->undo($changeId, true, 'admin-ui', $this->actingLogin());
        } catch (Throwable $e) {
            return ['type' => 'danger', 'text' => $this->text([
                'de' => 'Rückgängig fehlgeschlagen: ',
                'en' => 'Undo failed: ',
            ]) . $e->getMessage()];
        }

        if (($result['ok'] ?? false) !== true) {
            // UndoService's own refusal reason wins — it names the superseded rows.
            return [
                'type' => 'danger',
                'text' => (string)($result['error'] ?? $this->text([
                    'de' => 'Rückgängig machen wurde abgelehnt.',
                    'en' => 'The undo was refused.',
                ])),
            ];
        }

        $text = \sprintf(
            $this->text([
                'de' => 'Änderung %s rückgängig gemacht — %d Datensätze wiederhergestellt.',
                'en' => 'Change %s undone — %d records restored.',
            ]),
            $changeId,
            (int)($result['rowsRestored'] ?? 0),
        );
        foreach ((array)($result['notes'] ?? []) as $note) {
            $text .= ' ' . (string)$note;
        }

        return ['type' => 'success', 'text' => $text];
    }

    /** The backend admin pressing the button — recorded as the actor of the undo. */
    private function actingLogin(): string
    {
        try {
            $account = Shop::Container()->getAdminAccount()->account();

            return $account === false ? 'admin' : (string)($account->cLogin ?? 'admin');
        } catch (Throwable) {
            return 'admin';
        }
    }
}
