<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Backend;

use JTL\Alert\Alert;
use JTL\Backend\AdminAccount;
use JTL\Backend\Permissions;
use JTL\Cache\JTLCacheInterface;
use JTL\DB\DbInterface;
use JTL\IO\IOError;
use JTL\Mapper\AdminLoginStatusMessageMapper;
use JTL\Mapper\AdminLoginStatusToLogLevel;
use JTL\Router\Controller\Backend\AbstractBackendController;
use JTL\Services\JTL\AlertService;
use JTL\Shop;
use JTL\Smarty\ContextType;
use JTL\Smarty\JTLSmarty;
use Laminas\Diactoros\ServerRequestFactory;
use Psr\Http\Message\ResponseInterface;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
use RuntimeException;
use Throwable;

/**
 * Invokes a real JTL backend controller in-process, as a real admin, without an HTTP request —
 * design `backend-action-invoker.md` §1.
 *
 * ## What this actually does
 *
 * A backend POST to `/admin/<slug>` is, stripped of HTTP: an authenticated {@see AdminAccount} in
 * `$_SESSION`, a controller built with five services, `setRoute()`, and `getResponse()`. The
 * controllers themselves barely read the PSR-7 request — they reach for `$_POST` directly
 * (`ShippingMethodsController` runs `Text::filterXSS($_POST)`, `CronController` reads
 * `$_POST['reset']`). That is why faking the ambient state works at all, and equally why
 * {@see GlobalStateSandbox} has to be airtight: the same superglobals are how a leak would escape.
 *
 * ## The three rules this class exists to enforce
 *
 * 1. **`finally` is not optional.** Every path out of an invocation restores the sandbox. The one
 *    failure mode that matters here is R6 — an authenticated admin identity surviving into the rest
 *    of the PHP process, which for an MCP request means the *next* tool call inherits it.
 * 2. **The CSRF token is minted, not bypassed.** We create a real `$_SESSION['jtl_token']` the way
 *    `Form::getTokenInput()` does and submit it. `Form::validateToken()` is never patched and no
 *    "skip CSRF" flag exists. CSRF is a browser threat model; in-process there is no cross-origin
 *    attacker, and the real authorisation boundary is the MCP bearer token plus the acting admin's
 *    own rights. Anyone tempted to "optimise" the token away should change this comment first.
 * 3. **Nothing here decides what may be invoked.** Route blocking lives in
 *    {@see ControllerLocator}; identity lives in {@see BackendIdentityFactory}. This class is the
 *    mechanism, and a mechanism that also held policy would be a mechanism nobody could audit.
 *
 * ## The two invocation kinds
 *
 * {@see self::invokeFormPost()} replays a browser form post: seed `$_POST`, run the controller's
 * `getResponse()`, read the alerts back. {@see self::invokeAdminIo()} replays an admin XHR — and
 * pointedly does NOT go through `IOController::getResponse()`. That path would `ob_start()` and only
 * close the buffer on some branches (IOController.php:68, :103-105), and re-register every IO
 * function on a second call, which `IO::register()` answers with a throw (IO.php:59-61). One MCP
 * request making two IO calls would therefore fail the second — design R3/R4. So we construct the
 * controller, hand it a Smarty, and call the public method directly; the permission `AdminIO` would
 * have enforced is enforced here instead, by hand and against the same `AdminAccount`.
 *
 * ## Status: step 6 of round 2 — mechanism only
 *
 * There is no transaction wrapper and no dry run (step 7), and no MCP tool exposes this yet (step 7
 * ships `invoke_admin_action`). Nothing in the shipped tool list can reach this class today; it is
 * reachable only from tests and from the catalog machinery.
 */
final class BackendActionInvoker
{
    public function __construct(
        private readonly DbInterface $db,
        private readonly JTLCacheInterface $cache,
        private readonly BackendIdentityFactory $identities,
        private readonly ControllerLocator $locator,
    ) {
    }

    /**
     * Invoke one backend controller as `$adminLogin`, with `$post` as the submitted form.
     *
     * @param string              $route e.g. `shippingmethods` — resolved through {@see ControllerLocator},
     *                                   which also enforces the blocklist
     * @param array<string,mixed> $post  the form payload; the CSRF token is added here, not by the caller
     * @throws UnknownAdminException|UnknownRouteException|BlockedRouteException before anything is entered
     * @throws RuntimeException when the controller itself throws — the sandbox is restored first
     */
    public function invokeFormPost(string $adminLogin, string $route, array $post): InvocationResult
    {
        // Resolve BEFORE entering the sandbox: both of these can legitimately fail, and failing
        // while ambient state is swapped out is a strictly worse place to fail from.
        $identity = $this->identities->forLogin($adminLogin);
        $class    = $this->locator->classForRoute($route);
        $pattern  = $this->locator->patternForRoute($route);

        $token   = BackendIdentityFactory::mintCsrfToken();
        $started = \microtime(true);

        return $this->sandboxed(
            $identity,
            new SandboxState(
                post: $post + ['jtl_token' => $token],
                // Some screens read the GET spelling of the token (Form::validateToken()).
                get: ['token' => $token],
                server: $this->serverState($this->requestUri($pattern)),
                session: $this->sessionState($identity, $token),
            ),
            fn (
                AdminAccount $account,
                AlertService $alertService,
                NonRenderingBackendSmarty $smarty,
            ): InvocationResult => $this->dispatch($class, $pattern, $account, $alertService, $smarty, $started),
        );
    }

    /**
     * Invoke ONE public method of a backend controller directly, as `$adminLogin`, the way an admin
     * XHR would have reached it through `AdminIO` — minus `AdminIO`, for the reasons in the class
     * docblock (R3/R4).
     *
     * Everything the catalog decides has already been decided by the time we are called: the class,
     * the method, the arguments (`ArgumentBinder::bindAdminIo()` shaped and typed them — an IO method
     * is the one place the everything-is-a-string rule does not hold, because `IOController`'s
     * surcharge methods declare `int`/`array` under `strict_types=1`) and the permission name. What
     * is decided HERE is only what must never be delegated to a YAML file: that the class is
     * invocable at all, and that the acting admin actually holds the declared permission.
     *
     * @param string     $controllerClass FQCN, checked against {@see ControllerLocator::BLOCKED}
     * @param string     $method          a public method of that controller
     * @param list<mixed> $callArgs       spread into the call, already bound and cast
     * @param string     $permission      the {@see Permissions} CONSTANT NAME, e.g. `ORDER_SHIPMENT_VIEW`
     * @throws UnknownAdminException|BlockedControllerException|UnknownPermissionException before anything
     *         is entered
     * @throws PermissionDeniedException|RuntimeException with the sandbox already restored
     */
    public function invokeAdminIo(
        string $adminLogin,
        string $controllerClass,
        string $method,
        array $callArgs,
        string $permission,
    ): InvocationResult {
        // All four resolutions happen BEFORE the sandbox, for the same reason as in invokeFormPost:
        // each can legitimately fail, and failing with ambient state swapped out is strictly worse.
        $identity   = $this->identities->forLogin($adminLogin);
        $class      = $this->locator->assertInvocableClass($controllerClass);
        $permission = self::resolvePermission($permission);
        if (!\method_exists($class, $method)) {
            throw new BlockedControllerException(
                'Backend controller "' . $class . '" has no method "' . $method . '".',
            );
        }
        $reflection = new ReflectionMethod($class, $method);
        if (!$reflection->isPublic() || $reflection->isStatic()) {
            // A private/protected method is not part of the controller's contract, and a static one
            // would not see the injected services at all. Either means a wrong catalog entry.
            throw new BlockedControllerException(
                'Method "' . $class . '::' . $method . '" is not a public instance method.',
            );
        }

        $token   = BackendIdentityFactory::mintCsrfToken();
        $started = \microtime(true);

        return $this->sandboxed(
            $identity,
            new SandboxState(
                // An IO call is not a form post: there is no submitted form and nothing reads $_POST
                // on this path (we never enter IOController::getResponse(), which is the only thing
                // that would have validated a token). The session token is still minted and seeded,
                // because it is part of a well-formed backend session, not because anyone checks it.
                post: [],
                get: [],
                server: $this->serverState($this->requestUri('io')),
                session: $this->sessionState($identity, $token),
            ),
            fn (
                AdminAccount $account,
                AlertService $alertService,
                NonRenderingBackendSmarty $smarty,
            ): InvocationResult => $this->dispatchIo(
                $class,
                $method,
                $callArgs,
                $permission,
                $account,
                $alertService,
                $smarty,
                $started,
            ),
        );
    }

    /**
     * Enter the sandbox, build the account and the Smarty, run `$body`, restore — unconditionally.
     *
     * Both invocation kinds share this so there is exactly ONE `finally` in the class. A second copy
     * of this block would be a second chance to get R6 wrong, and R6 (an authenticated admin identity
     * surviving into the rest of the process) is the failure this whole file exists to prevent.
     *
     * @param \Closure(AdminAccount, AlertService, NonRenderingBackendSmarty): InvocationResult $body
     */
    private function sandboxed(AdminIdentity $identity, SandboxState $state, \Closure $body): InvocationResult
    {
        $restore    = GlobalStateSandbox::enter($state);
        $sessionID  = \session_id();
        $preExisted = $this->activeSessionRowExists($sessionID);

        try {
            $alertService = new AlertService();
            $account      = new AdminAccount(
                $this->db,
                Shop::Container()->getBackendLogService(),
                new AdminLoginStatusMessageMapper(),
                new AdminLoginStatusToLogLevel(),
                Shop::Container()->getGetText(),
                $alertService,
            );
            if ($account->logged() === false) {
                // The payload BackendIdentityFactory built failed core's own validateSession().
                // Refusing here rather than invoking is the whole point: a controller reached with a
                // half-valid identity is how a permission check becomes a formality.
                throw new RuntimeException(
                    'BackendActionInvoker: the constructed identity for "' . $identity->login
                    . '" is not accepted by AdminAccount::logged(); refusing to invoke.'
                );
            }

            // AFTER the sandbox and AFTER the account: BackendSmarty::init() reads $_SERVER,
            // $_SESSION['adminTheme'] and Shop::Container()->getAdminAccount()->getID().
            return $body($account, $alertService, new NonRenderingBackendSmarty($this->db, $this->cache));
        } finally {
            // Ordering matters: the junk-row delete needs the sandboxed identity to still be the
            // thing we are cleaning up after, so it runs before the ambient state goes back.
            $this->deleteJunkAdminSessionRow($sessionID, $identity->id, $preExisted);
            $restore->restore();
        }
    }

    /** @return array<string,mixed> */
    private function serverState(string $requestUri): array
    {
        return [
            'REQUEST_METHOD' => 'POST',
            'REQUEST_URI'    => $requestUri,
            'HTTP_HOST'      => (string)(\parse_url(\URL_SHOP, \PHP_URL_HOST) ?: 'localhost'),
        ];
    }

    /** @return array<string,mixed> */
    private function sessionState(AdminIdentity $identity, string $token): array
    {
        return [
            'AdminAccount' => $identity->session,
            'jtl_token'    => $token,
            // ShippingMethodsController dereferences this unguarded; an absent key is a PHP
            // warning, and phpunit.xml.dist sets failOnWarning="true".
            'Steuersatz'   => [],
            // Same class of key, and not only a warning fix: PlausiKundenfeld.php:55 and
            // Backend\CustomerFields.php:40 both read it unguarded, and the plausi's
            // duplicate-cName guard LOOKS THE NAME UP under it — absent, it would query language 0
            // and never match, so the guard would be silently inert through MCP. Session\Backend
            // seeds the shop-default language the same way (:70-84).
            'kSprache'     => $this->defaultLanguageID(),
            // A fresh AlertService seeds itself from here. Empty = this invocation's alerts are
            // exactly the ones we read back, with nothing inherited from the real session.
            'alerts'       => [],
        ];
    }

    /**
     * The shop-default language, i.e. what a real backend session would carry. Falls back to 1
     * (core's own fallback, `Session\Backend.php:86`) rather than throwing: a shop with no default
     * language row is not a reason to refuse every invocation.
     */
    private function defaultLanguageID(): int
    {
        $row = $this->db->getSingleObject("SELECT kSprache FROM tsprache WHERE cShopStandard = 'Y'");

        return $row === null ? 1 : (int)$row->kSprache;
    }

    /**
     * Everything that runs with the sandbox entered, for a form post. Split out so the `finally`
     * above can be read as one statement, and so no early `return` can skip it.
     */
    private function dispatch(
        string $class,
        string $pattern,
        AdminAccount $account,
        AlertService $alertService,
        NonRenderingBackendSmarty $smarty,
        float $started,
    ): InvocationResult {
        /** @var AbstractBackendController $controller */
        $controller = new $class(
            $this->db,
            $this->cache,
            $alertService,
            $account,
            Shop::Container()->getGetText(),
        );
        // The router sets the PATTERN, brackets and all (`setRoute('/' . $route)`), and controllers
        // render it back into forms and redirects. Passing the bare slug would produce subtly wrong
        // form actions on the four parameterised routes.
        $controller->setRoute('/' . $pattern);

        // Built from OUR sandboxed globals, so the PSR-7 view and the superglobal view — which
        // different controllers each reach for — cannot disagree.
        $request  = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);
        $response = $controller->getResponse($request, [], $smarty);

        return $this->result($response, $alertService, $smarty, $started);
    }

    /**
     * Everything that runs with the sandbox entered, for an IO call.
     *
     * The order below is the whole security argument of this method: permission first, construction
     * second, call third. Constructing the controller before checking would be harmless today and a
     * bug the day a constructor does something.
     *
     * @param list<mixed> $callArgs
     * @param string      $permission the resolved permission VALUE, not the constant name
     */
    private function dispatchIo(
        string $class,
        string $method,
        array $callArgs,
        string $permission,
        AdminAccount $account,
        AlertService $alertService,
        NonRenderingBackendSmarty $smarty,
        float $started,
    ): InvocationResult {
        // The identical check AdminIO::execute() performs (AdminIO.php:54-57) against the identical
        // object. We never enter AdminIO, so if this line is not here the permission a catalog entry
        // declares is decoration. Note it is the ACTING admin's rights that decide, not the MCP
        // client's — decision Q2.
        if ($account->permission($permission) !== true) {
            throw new PermissionDeniedException(
                'Admin lacks permission "' . $permission . '" required by ' . $class . '::' . $method . '().',
            );
        }

        /** @var AbstractBackendController $controller */
        $controller = new $class(
            $this->db,
            $this->cache,
            $alertService,
            $account,
            Shop::Container()->getGetText(),
        );
        // Mandatory: AbstractBackendController::getSmarty() throws when it was never set
        // (AbstractBackendController.php:472-475), and IOController::getShippingSurcharge() (:543)
        // plus the two mutating surcharge methods all fetch a template mid-call. `setRoute()` is NOT
        // called: `$route` defaults to '' (AbstractBackendController.php:42) and an IO method — which
        // renders a snippet, never a page with a form action — has no route to be told about.
        $controller->setSmarty($smarty);

        $returned = $controller->$method(...$callArgs);

        // The ONE exception to "the verdict comes from the alert list": `JTL\IO\IOError`
        // (`includes/src/IO/IOError.php:13`). A handful of IO functions signal failure purely by
        // RETURN VALUE and never touch the alert service — `IOController::addFav()` returns
        // `new IOError('Unauthorized', 401)` (:390), `createSearchIndex()` returns
        // `new IOError(__('errorIndexInvalid'), 403)` (:429). Reading those as `ok: true` with the
        // error tucked away in `data` is precisely the silent-success failure mode this whole epic
        // exists to prevent, so an `IOError` return is a failure, full stop.
        $ioError = $returned instanceof IOError ? $returned : null;

        return new InvocationResult(
            // Otherwise the same rule as a form post: derived from the alert list, never from the
            // return value. An IO method's return value is its payload, not its verdict —
            // `IOController` reports failure by pushing an error alert and STILL returning a
            // stdClass. The `IOError` case above is the documented exception, not a second rule.
            ok: $ioError === null
                && !$alertService->alertTypeExists(Alert::TYPE_ERROR)
                && !$alertService->alertTypeExists(Alert::TYPE_DANGER),
            alerts: $this->alertViews($alertService, $ioError),
            // There is no HTTP response here at all. 200 is the honest answer for "the method ran
            // and returned"; every other outcome left through an exception.
            redirect: null,
            status: 200,
            data: self::decode($returned),
            renderedTemplate: $smarty->renderedTemplate,
            durationMs: (\microtime(true) - $started) * 1000,
        );
    }

    /**
     * An IO method's return value as a plain array.
     *
     * `json_decode(json_encode(…), true)` rather than a `(array)` cast, deliberately: the cast is
     * shallow, so a nested `stdClass` would survive inside a value typed `array<string,mixed>` and
     * only reveal itself downstream. The round trip also proves the payload is JSON-encodable at the
     * point where a failure is still attributable, which matters because this ends up in an MCP
     * response. A non-object return (scalar, list) is wrapped under `value` so `$data` keeps its
     * `array<string,mixed>` shape.
     *
     * @return array<string,mixed>
     */
    private static function decode(mixed $returned): array
    {
        if ($returned === null) {
            return [];
        }
        $json = \json_encode($returned);
        if ($json === false) {
            throw new RuntimeException(
                'BackendActionInvoker: the IO method returned a value that is not JSON-encodable ('
                . \json_last_error_msg() . ').',
            );
        }
        /** @var mixed $decoded */
        $decoded = \json_decode($json, true);

        return (\is_array($decoded) && !\array_is_list($decoded)) ? $decoded : ['value' => $decoded];
    }

    /**
     * The alert list as {@see AlertView}s, with a returned {@see IOError} folded in as one more.
     *
     * Folding it in rather than adding a field keeps {@see InvocationResult}'s public shape exactly
     * as it was: a caller that already reads `alerts` / `errors()` sees the failure without knowing
     * `IOError` exists, and `ok === false` is never unexplained. The synthetic entry is typed
     * {@see Alert::TYPE_ERROR} so `errors()` picks it up, keyed `ioError` so it is distinguishable
     * from a key the shop itself chose, and carries the code in the message because
     * `IOError::$code` (an HTTP-ish status) has nowhere else to go — `InvocationResult::$status`
     * stays 200, which is the honest answer to "the method ran and returned".
     *
     * @return list<AlertView>
     */
    private function alertViews(AlertService $alertService, ?IOError $ioError = null): array
    {
        $alerts = [];
        /** @var Alert $alert */
        foreach ($alertService->getAlertList() as $alert) {
            $alerts[] = AlertView::fromAlert($alert);
        }
        if ($ioError !== null) {
            $alerts[] = new AlertView(
                Alert::TYPE_ERROR,
                'ioError',
                $ioError->message . ' (IOError code ' . $ioError->code . ')',
            );
        }

        return $alerts;
    }

    /**
     * A `Permissions::` constant NAME (what a catalog entry writes) to its value (what
     * `AdminAccount::permission()` compares). Reflection over `constant()` so an entry cannot name
     * an arbitrary global constant, and so the failure mode is a clear throw rather than PHP's
     * "undefined constant" error.
     *
     * @throws UnknownPermissionException
     */
    private static function resolvePermission(string $name): string
    {
        /** @var array<string,mixed> $constants */
        $constants = (new ReflectionClass(Permissions::class))->getConstants();
        if (!isset($constants[$name]) || !\is_string($constants[$name])) {
            throw new UnknownPermissionException(
                '"' . $name . '" is not a constant on ' . Permissions::class . '.',
            );
        }

        return $constants[$name];
    }

    private function result(
        ResponseInterface $response,
        AlertService $alertService,
        NonRenderingBackendSmarty $smarty,
        float $started,
    ): InvocationResult {
        $alerts   = $this->alertViews($alertService);
        $status   = $response->getStatusCode();
        $location = $response->getHeaderLine('Location');

        return new InvocationResult(
            // Derived from the alerts, never from the status: a 3xx is the normal success path for
            // every post/redirect/get screen.
            ok: !$alertService->alertTypeExists(Alert::TYPE_ERROR)
                && !$alertService->alertTypeExists(Alert::TYPE_DANGER),
            alerts: $alerts,
            redirect: ($status >= 300 && $status < 400 && $location !== '') ? $location : null,
            status: $status,
            data: [],
            renderedTemplate: $smarty->renderedTemplate,
            durationMs: (\microtime(true) - $started) * 1000,
        );
    }

    private function requestUri(string $pattern): string
    {
        $base = (string)(\parse_url(\URL_SHOP, \PHP_URL_PATH) ?? '');

        return \rtrim($base, '/') . '/' . \PFAD_ADMIN . $pattern;
    }

    /**
     * `AdminAccount::checkIfValid()` keys `active_admin_sessions` by `session_id()`. In an MCP
     * request that is the FRONTEND session id; on CLI it is `''`. Either way the row it inserts is
     * junk — it claims an admin is actively logged in under a session that is not a backend session.
     *
     * We therefore delete exactly the row we caused, and only when there was none before: if a row
     * already existed we must leave it alone, because then it is somebody's real session and
     * `checkIfValid()` only touched its `updated` column.
     */
    private function deleteJunkAdminSessionRow(string|false $sessionID, int $userID, bool $preExisted): void
    {
        if ($preExisted === true || !\is_string($sessionID)) {
            return;
        }
        try {
            if (!$this->db->tableExists('active_admin_sessions')) {
                return;
            }
            $this->db->queryPrepared(
                'DELETE FROM active_admin_sessions WHERE sessionID = :sid AND userID = :uid',
                ['sid' => $sessionID, 'uid' => $userID],
            );
        } catch (Throwable) {
            // Cleanup must never mask the invocation's own outcome. A surviving junk row is a
            // cosmetic wart in one admin screen; a swallowed write failure is a lie to the caller.
        }
    }

    private function activeSessionRowExists(string|false $sessionID): bool
    {
        if (!\is_string($sessionID)) {
            return false;
        }
        try {
            if (!$this->db->tableExists('active_admin_sessions')) {
                return false;
            }

            return $this->db->getSingleObject(
                'SELECT sessionID FROM active_admin_sessions WHERE sessionID = :sid',
                ['sid' => $sessionID],
            ) !== null;
        } catch (Throwable) {
            // Unknown => assume it existed, so the cleanup does nothing. Deleting a row we did not
            // create would log a real admin out.
            return true;
        }
    }

    /**
     * Undo `JTLSmarty`'s self-registration (`self::$instance[ContextType::BACKEND]`).
     *
     * Constructing any `BackendSmarty` registers it as THE backend instance, and ours renders
     * nothing — so leaving it registered would silently break a real backend render later in the
     * same process. The frontend key is separate and untouched.
     *
     * @internal called by the invoker's own tests; step 7 will fold it into the sandbox once
     *           `invoke_admin_action` makes multi-invocation processes routine.
     */
    public static function forgetBackendSmartyInstance(): void
    {
        if (!\class_exists(JTLSmarty::class) || !\class_exists(ContextType::class)) {
            return;
        }
        $property = new ReflectionProperty(JTLSmarty::class, 'instance');
        /** @var array<string,JTLSmarty> $instances */
        $instances = $property->getValue() ?? [];
        unset($instances[ContextType::BACKEND]);
        $property->setValue(null, $instances);
    }
}
