<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Journal;

use Closure;
use JTL\Cache\JTLCacheInterface;
use JTL\DB\DbInterface;
use JTL\DB\ReturnType;
use Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog;
use Plugin\tnt_mcp\src\Mcp\Action\SideEffect;
use Plugin\tnt_mcp\src\Mcp\Backend\AdminIdentity;
use Throwable;

/**
 * Replays one journalled change backwards (design §4.6).
 *
 * ## Plan first, apply only on confirm
 *
 * Without `confirm: true` this returns the plan and does nothing — the rows it would touch, and the
 * `cannotUndo` list again. An undo is the one call whose blast radius the caller cannot see from the
 * arguments (`changeId` is opaque), so the dry run is the default rather than an option.
 *
 * ## The supersession check is the important one
 *
 * Before anything is written, every journalled row's CURRENT state is compared against the `after`
 * image the journal recorded. A difference means somebody — the shop admin, Wawi, another MCP call —
 * has edited that row since, and replaying the old `before` over it would destroy work that has
 * nothing to do with this change. That is refused by name, listing which table and key drifted.
 *
 * ## What it cannot undo, and says so
 *
 * Rows in declared journal tables, and nothing else. A cache flush is not transactional and not
 * snapshotable, so the undo re-flushes the WHOLE cache when the original entry declared any cache
 * side effect and reports that it did — the exact tags the controller flushed are not recorded, and
 * flushing too much is the only honest option. Auto-increment ids, sent mail and filesystem writes
 * stay gone; they are listed in `cannotUndo` before the caller relies on the undo, not after.
 */
final class UndoService
{
    private const IDENTIFIER = TableKeys::IDENTIFIER;

    /**
     * @param Closure(): JTLCacheInterface        $cacheFactory     lazy for the usual reason: the
     *        tool is built by contexts that never boot a container
     * @param Closure(string): AdminIdentity|null $identityResolver `tadminlogin.cLogin` => that
     *        admin's rights. Required in production and wired by {@see
     *        \Plugin\tnt_mcp\src\Mcp\ToolFactory}; a null resolver means "no rights model available"
     *        and makes every undo refuse, so an un-wired caller fails closed rather than replaying
     *        writes unchecked.
     */
    public function __construct(
        private readonly DbInterface $db,
        private readonly ChangeJournal $journal,
        private readonly ActionCatalog $catalog,
        private readonly Closure $cacheFactory,
        private readonly ?Closure $identityResolver = null,
    ) {
    }

    /**
     * @return array<string,mixed> the tool response, plan or applied
     */
    public function undo(string $changeId, bool $confirm, string $tokenId, string $actingLogin): array
    {
        $header = $this->journal->header($changeId);
        if ($header === null) {
            return $this->refuse('unknown-change', \sprintf(
                'There is no recorded change "%s". Call list_recent_changes for the ids this server '
                . 'has journalled.',
                $changeId,
            ), $changeId);
        }

        $state = (string)($header['cUndoState'] ?? '');
        if ($state !== 'undoable') {
            return $this->refuse('not-undoable', \sprintf(
                'Change %s is in state "%s"%s, so it cannot be undone. Nothing has been done.',
                $changeId,
                $state,
                ((string)($header['cUndoReason'] ?? '')) === '' ? '' : ': ' . (string)$header['cUndoReason'],
            ), $changeId, ['undoState' => $state]);
        }

        $rows = $this->journal->rows((int)($header['kChange'] ?? 0));
        if ($rows === []) {
            return $this->refuse('not-undoable', \sprintf(
                'Change %s has no journalled rows — its snapshot has been pruned or it changed '
                . 'nothing. Nothing has been done.',
                $changeId,
            ), $changeId);
        }

        $actionId   = (string)($header['cActionId'] ?? '');
        $spec       = $this->catalog->get($actionId);

        // An undo issues raw DELETE/INSERT/UPDATE against the journalled tables — it never enters a
        // backend controller, so `checkPermissions()` never runs on this path. Without the check
        // below, a token held by a limited admin could revert a change to tversandart / tzahlungsart
        // / tkupon / tredirect that the same admin could not have made through the backend, and the
        // journal is not filtered by who made the change either. Demand exactly the right the
        // catalog declares for the action being reverted.
        $refusal = $this->refusePermission($spec === null ? '' : $spec->permission, $actingLogin, $changeId, $actionId);
        if ($refusal !== null) {
            return $refusal;
        }

        $cannotUndo = $spec?->undo->cannotUndo ?? [];
        $flushes    = $spec !== null
            && ($spec->hasSideEffect(SideEffect::CacheFlushTags) || $spec->hasSideEffect(SideEffect::CacheFlushAll));

        $drift = $this->drift($rows);
        if ($drift !== []) {
            return $this->refuse('superseded', \sprintf(
                'Change %s has been overwritten since it was made: %s no longer holds the value this '
                . 'change wrote. Undoing it now would discard that later edit, so nothing has been done.',
                $changeId,
                \implode('; ', \array_slice($drift, 0, 5)),
            ), $changeId, ['drifted' => $drift]);
        }

        $plan = $this->plan($rows);
        if (!$confirm) {
            return [
                'ok'         => true,
                'applied'    => false,
                'changeId'   => $changeId,
                'actionId'   => $actionId,
                'plan'       => $plan,
                'cannotUndo' => $cannotUndo,
                'cacheNote'  => $flushes
                    ? 'The original action flushed the shop cache. The undo will flush the entire '
                        . 'cache again, because the exact tags it flushed are not recorded.'
                    : null,
                'notes'      => [
                    'Nothing has been done. Re-send with confirm: true to apply this plan.',
                ],
            ];
        }

        return $this->apply($changeId, $header, $rows, $plan, $cannotUndo, $flushes, $tokenId, $actingLogin);
    }

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

    /**
     * @param list<array<string,mixed>> $rows
     * @param list<string>              $cannotUndo
     * @param array<string,mixed>       $header
     * @param list<array<string,mixed>> $plan
     * @return array<string,mixed>
     */
    private function apply(
        string $changeId,
        array $header,
        array $rows,
        array $plan,
        array $cannotUndo,
        bool $flushes,
        string $tokenId,
        string $actingLogin,
    ): array {
        $journalled = [];
        $applied    = 0;

        $this->db->beginTransaction();
        try {
            // Reverse order: the journal wrote parents before children, so the undo removes children
            // before parents. Nothing declares a foreign key here, but the order costs nothing and
            // is the one that stays correct if a shop ever adds one.
            foreach (\array_reverse($rows) as $row) {
                $applied += $this->replay($row, $journalled);
            }
            $this->db->commit();
        } catch (Throwable $e) {
            $this->db->rollback();

            return $this->refuse('undo-failed', \sprintf(
                'Undoing change %s failed and the transaction was rolled back, so the shop is exactly '
                . 'as it was before this call: %s',
                $changeId,
                $e->getMessage(),
            ), $changeId);
        }

        $cacheFlushed = false;
        if ($flushes) {
            try {
                ($this->cacheFactory)()->flushAll();
                $cacheFlushed = true;
            } catch (Throwable) {
                // A cache that refuses to flush does not un-apply the rows; it is reported, not thrown.
            }
        }

        $this->journal->markUndone((int)($header['kChange'] ?? 0));
        $undoChangeId = $this->journal->recordUndo(
            $changeId,
            (string)($header['cActionId'] ?? ''),
            $tokenId,
            $actingLogin,
            $journalled,
        );

        $notes = ['The change is marked undone. The undo itself is journalled, so it can be undone too.'];
        if ($flushes && !$cacheFlushed) {
            $notes[] = 'The shop cache could NOT be flushed. Stale cached values may still show the '
                . 'undone state until the next flush.';
        } elseif ($cacheFlushed) {
            $notes[] = 'The entire shop cache was flushed, because the exact tags the original action '
                . 'flushed are not recorded.';
        }

        return [
            'ok'           => true,
            'applied'      => true,
            'changeId'     => $changeId,
            'undoChangeId' => $undoChangeId,
            'actionId'     => (string)($header['cActionId'] ?? ''),
            'rowsRestored' => $applied,
            'plan'         => $plan,
            'cannotUndo'   => $cannotUndo,
            'cacheFlushed' => $cacheFlushed,
            'actingLogin'  => $actingLogin,
            'notes'        => $notes,
        ];
    }

    /**
     * One journalled row, replayed backwards. Appends what it did to `$journalled` so the undo is
     * itself undoable.
     *
     * @param array<string,mixed>       $row
     * @param list<array{table:string,key:array<string,mixed>,op:string,before:?array<string,mixed>,after:?array<string,mixed>}> $journalled
     */
    private function replay(array $row, array &$journalled): int
    {
        $table  = (string)($row['cTable'] ?? '');
        $key    = self::decode($row['cKeyJson'] ?? null) ?? [];
        $before = self::decode($row['cBefore'] ?? null);
        $after  = self::decode($row['cAfter'] ?? null);
        $op     = (string)($row['cOp'] ?? '');

        if (\preg_match(self::IDENTIFIER, $table) !== 1 || $key === []) {
            return 0;
        }

        if ($op === 'insert') {
            $this->deleteRow($table, $key);
            $journalled[] = ['table' => $table, 'key' => $key, 'op' => 'delete', 'before' => $after, 'after' => null];

            return 1;
        }
        if ($op === 'delete' && $before !== null) {
            $this->insertRow($table, $before);
            $journalled[] = ['table' => $table, 'key' => $key, 'op' => 'insert', 'before' => null, 'after' => $before];

            return 1;
        }
        if ($op === 'update' && $before !== null) {
            $this->updateRow($table, $key, $before);
            $journalled[] = ['table' => $table, 'key' => $key, 'op' => 'update', 'before' => $after, 'after' => $before];

            return 1;
        }

        return 0;
    }

    /** @param array<string,mixed> $key */
    private function deleteRow(string $table, array $key): void
    {
        [$clause, $params] = self::keyClause($key);
        $this->db->queryPrepared(
            'DELETE FROM `' . $table . '` WHERE ' . $clause,
            $params,
            ReturnType::AFFECTED_ROWS,
        );
    }

    /** @param array<string,mixed> $values */
    private function insertRow(string $table, array $values): void
    {
        $columns = [];
        $params  = [];
        $i       = 0;
        foreach ($values as $column => $value) {
            if (\preg_match(self::IDENTIFIER, (string)$column) !== 1) {
                continue;
            }
            $name            = 'v' . $i++;
            $columns[]       = ['`' . $column . '`', ':' . $name];
            $params[$name]   = $value;
        }
        if ($columns === []) {
            return;
        }
        $this->db->queryPrepared(
            'INSERT INTO `' . $table . '` (' . \implode(', ', \array_column($columns, 0)) . ')'
            . ' VALUES (' . \implode(', ', \array_column($columns, 1)) . ')',
            $params,
            ReturnType::AFFECTED_ROWS,
        );
    }

    /**
     * @param array<string,mixed> $key
     * @param array<string,mixed> $values
     */
    private function updateRow(string $table, array $key, array $values): void
    {
        $sets   = [];
        $params = [];
        $i      = 0;
        foreach ($values as $column => $value) {
            if (\preg_match(self::IDENTIFIER, (string)$column) !== 1 || \array_key_exists($column, $key)) {
                continue;
            }
            $name          = 's' . $i++;
            $sets[]        = '`' . $column . '` = :' . $name;
            $params[$name] = $value;
        }
        if ($sets === []) {
            return;
        }
        [$clause, $keyParams] = self::keyClause($key);

        $this->db->queryPrepared(
            'UPDATE `' . $table . '` SET ' . \implode(', ', $sets) . ' WHERE ' . $clause,
            [...$params, ...$keyParams],
            ReturnType::AFFECTED_ROWS,
        );
    }

    /**
     * Every journalled row's CURRENT state against the recorded `after` image.
     *
     * @param list<array<string,mixed>> $rows
     * @return list<string> human-readable descriptions of what drifted, empty when nothing did
     */
    private function drift(array $rows): array
    {
        $drift = [];
        foreach ($rows as $row) {
            $table = (string)($row['cTable'] ?? '');
            $key   = self::decode($row['cKeyJson'] ?? null) ?? [];
            $after = self::decode($row['cAfter'] ?? null);
            if (\preg_match(self::IDENTIFIER, $table) !== 1 || $key === []) {
                continue;
            }

            $current = $this->current($table, $key, $after);
            if ($after === null) {
                // The change deleted this row; anything present now is somebody's later insert.
                if ($current !== null) {
                    $drift[] = $table . ' ' . (string)\json_encode($key) . ' exists again';
                }
                continue;
            }
            if ($current === null) {
                $drift[] = $table . ' ' . (string)\json_encode($key) . ' has been deleted since';
                continue;
            }
            foreach ($after as $column => $value) {
                if (!\array_key_exists($column, $current)) {
                    continue;
                }
                if ((string)($value ?? '') !== (string)($current[$column] ?? '')) {
                    $drift[] = $table . ' ' . (string)\json_encode($key) . ' column ' . (string)$column;
                    break;
                }
            }
        }

        return $drift;
    }

    /**
     * @param array<string,mixed>  $key
     * @param ?array<string,mixed> $shape the columns to read back; null reads the key only
     * @return ?array<string,mixed>
     */
    private function current(string $table, array $key, ?array $shape): ?array
    {
        $columns = [];
        foreach (\array_keys($shape ?? $key) as $column) {
            if (\preg_match(self::IDENTIFIER, (string)$column) === 1) {
                $columns[] = '`' . (string)$column . '`';
            }
        }
        if ($columns === []) {
            return null;
        }
        [$clause, $params] = self::keyClause($key);

        return $this->db->getSingleArray(
            'SELECT ' . \implode(', ', $columns) . ' FROM `' . $table . '` WHERE ' . $clause . ' LIMIT 1',
            $params,
        );
    }

    /**
     * The plan, one line per row: what will happen to which table and key. Deliberately not the row
     * contents — a plan the caller has to scroll is a plan it will confirm without reading.
     *
     * @param list<array<string,mixed>> $rows
     * @return list<array<string,mixed>>
     */
    private function plan(array $rows): array
    {
        $out = [];
        foreach ($rows as $row) {
            $op    = (string)($row['cOp'] ?? '');
            $out[] = [
                'table'  => (string)($row['cTable'] ?? ''),
                'key'    => self::decode($row['cKeyJson'] ?? null) ?? [],
                'undoBy' => match ($op) {
                    'insert' => 'delete the row this change inserted',
                    'delete' => 're-insert the row this change deleted',
                    'update' => 'restore the column values this change overwrote',
                    default  => 'nothing — unrecognised operation "' . $op . '"',
                },
            ];
        }

        return $out;
    }

    /**
     * @param array<string,mixed> $key
     * @return array{0:string,1:array<string,mixed>}
     */
    private static function keyClause(array $key): array
    {
        $parts  = [];
        $params = [];
        $i      = 0;
        foreach ($key as $column => $value) {
            if (\preg_match(self::IDENTIFIER, (string)$column) !== 1) {
                continue;
            }
            $name          = 'k' . $i++;
            $parts[]       = '`' . (string)$column . '` = :' . $name;
            $params[$name] = $value;
        }

        return [$parts === [] ? '1 = 0' : \implode(' AND ', $parts), $params];
    }

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

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

    /**
     * @param array<string,mixed> $extra
     * @return array<string,mixed>
     */
    private function refuse(string $code, string $message, string $changeId, array $extra = []): array
    {
        return [
            'ok'       => false,
            'applied'  => false,
            'refused'  => $code,
            'error'    => $message,
            'changeId' => $changeId,
            ...$extra,
        ];
    }

    /**
     * The rights half of {@see self::undo()}. Returns a refusal payload, or null when the acting
     * admin may proceed.
     *
     * Fails closed in both directions: no resolver wired, an unresolvable login, or an action the
     * catalog no longer knows (so no permission can be named) all refuse. Reverting a write is at
     * least as consequential as making it, so "we cannot tell" must not mean "go ahead".
     *
     * @return array<string,mixed>|null
     */
    private function refusePermission(
        string $permission,
        string $actingLogin,
        string $changeId,
        string $actionId,
    ): ?array {
        $cannotVerify = static fn (string $why): string => \sprintf(
            'Change %s cannot be undone because this server cannot verify that %s is allowed to: %s. '
            . 'Nothing has been done.',
            $changeId,
            $actingLogin === '' ? 'the acting administrator' : $actingLogin,
            $why,
        );

        if ($this->identityResolver === null) {
            return $this->refuse('permission-unverifiable', $cannotVerify(
                'no backend rights model is available on this code path'
            ), $changeId);
        }
        if ($permission === '') {
            return $this->refuse('permission-unverifiable', $cannotVerify(\sprintf(
                'the action catalog declares no permission for "%s"',
                $actionId === '' ? '(unknown action)' : $actionId,
            )), $changeId);
        }

        try {
            $identity = ($this->identityResolver)($actingLogin);
        } catch (Throwable $e) {
            return $this->refuse('no-acting-admin', $cannotVerify($e->getMessage()), $changeId);
        }
        if (!$identity->has($permission)) {
            return $this->refuse('permission-denied', \sprintf(
                'Change %s was made by action "%s", which requires the backend permission "%s". '
                . '%s does not hold it, so nothing has been done.',
                $changeId,
                $actionId,
                $permission,
                $identity->login,
            ), $changeId, ['permission' => $permission]);
        }

        return null;
    }
}
