<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Backend;

use Closure;
use JTL\Backend\AdminAccount;
use JTL\Session\Backend as BackendSession;
use JTL\Shop;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
use Throwable;

/**
 * Scoped, restorable replacement of every piece of ambient state a JTL backend controller reads or
 * writes (design §1.2). **This is the only class in this plugin allowed to write a superglobal.**
 *
 * Usage is one shape and one shape only:
 *
 * ```php
 * $restore = GlobalStateSandbox::enter($state);
 * try {
 *     // … invoke the backend controller …
 * } finally {
 *     $restore->restore();
 * }
 * ```
 *
 * What is snapshotted and re-installed, in install order (restore is the exact reverse):
 *
 * | # | state                        | install                                   | reached via            |
 * |---|------------------------------|-------------------------------------------|------------------------|
 * | 1 | `ob_get_level()`             | record only                               | public API             |
 * | 2 | `Session\Backend::$instance` | pre-seed so `getInstance()` short-circuits| reflection (protected) |
 * | 3 | `$_SESSION`                  | replace wholesale                         | `$GLOBALS`             |
 * | 4 | `$_POST/$_GET/$_REQUEST/$_COOKIE/$_FILES` | replace                      | `$GLOBALS`             |
 * | 5 | `$_SERVER`                   | merge (desired wins)                      | `$GLOBALS`             |
 * | 6 | `Shop::$isFrontend`          | `false`                                   | public API             |
 * | 7 | `Shop::$logged`              | `null` (force re-evaluation)              | reflection (protected) |
 * | 8 | container singleton `AdminAccount` | nothing                             | restore-only           |
 *
 * Why each one is dangerous is in the design; the two that are easy to get wrong:
 *
 * - **Row 2 (R7).** `Backend::getInstance()` (`Session/Backend.php:33-36`) constructs a real backend
 *   session when `$instance` is null, and that constructor path ends in `setcookie()` +
 *   `header_remove('Set-Cookie')` (`AbstractSession.php:79-89`, `:102`, `:124-142`) — it would
 *   rewrite the Set-Cookie headers of the HTTP response we are answering. Pre-seeding the singleton
 *   makes `getInstance()` short-circuit, so we never call `session_start()`, `session_id()` or
 *   `session_name()` at all (design decision Q8). Contrast {@see \Plugin\tnt_mcp\src\Mcp\OAuth\
 *   JtlAdminSession}, which *does* juggle real sessions — it has to *read* an existing backend
 *   session; we only *construct* an identity, so swapping the `$_SESSION` array is enough.
 * - **Row 7 (R6).** `Shop::isAdmin()` memoises into `Shop::$logged` (`Shop.php:659-719`, the
 *   short-circuit at `:661-662`). Leaving a memoised `true` behind would make the *rest of the
 *   request* an admin. The property is `protected static` (`Shop.php:69`) with no setter, so
 *   reflection is the only way — a known wart, documented rather than worked around.
 *
 * **Deferred to step 3:** the `active_admin_sessions` junk-row cleanup of design §1.2's last
 * paragraph. `AdminAccount::checkIfValid()` inserts a row keyed by `session_id()` (junk for us,
 * since we never start a session). Cleaning it needs a `DbInterface` and the acting `userID`,
 * neither of which the sandbox has or should have: folding a DB dependency in here would make the
 * one class that must be unit-testable in isolation untestable. `BackendActionInvoker` owns it.
 *
 * The class is deliberately usable **without a booted shop** — every `Shop::`/`Session\Backend`
 * touch is guarded, so the superglobal rows still work in a plain unit-test process.
 */
final class GlobalStateSandbox
{
    /** Nesting guard. Cleared by the {@see Restore} returned from `enter()`, and on rollback. */
    private static bool $active = false;

    /**
     * Fault injection for the rollback test: makes `enter()` throw right after N install steps have
     * succeeded. Read-and-cleared by `enter()`, so a value a test forgets to reset can never affect
     * a later call. It can only *fail* an entry — never skip a restore, never widen a permission.
     *
     * PRIVATE on purpose. This is the one class whose failure mode is a leaked authenticated admin
     * identity (R6), so it exposes no mutable public API — not even a fail-closed one. The rollback
     * test reaches it with `ReflectionProperty::setValue()`, which keeps the seam a testing detail
     * instead of part of the class's surface.
     *
     * @internal test seam; there is no other way to reach the partial-failure path from outside
     */
    /** @phpstan-ignore property.unusedType (assigned only by the rollback test, via reflection) */
    private static ?int $failAfterStepsForTest = null;

    /**
     * Snapshot the ambient state, install `$desired`, and return the undo token.
     *
     * Exception-safe: if any install step throws, every step already installed is rolled back
     * before the exception propagates — a half-entered sandbox is exactly the leak R6 describes.
     *
     * @throws RuntimeException when a sandbox is already active (nesting is forbidden — the second
     *                          `restore()` would put back the *first* sandbox's fake state as if it
     *                          were the real world)
     */
    public static function enter(SandboxState $desired): Restore
    {
        if (self::$active === true) {
            throw new RuntimeException('GlobalStateSandbox: already active');
        }
        $failAfter                   = self::$failAfterStepsForTest;
        self::$failAfterStepsForTest = null;
        self::$active                = true;

        /** @var list<callable():void> $undo */
        $undo       = [
            // Runs last (LIFO), so the guard is only released once everything is back.
            static function (): void {
                self::$active = false;
            },
        ];
        $installed  = 0;
        $checkpoint = static function () use (&$installed, $failAfter): void {
            $installed++;
            if ($failAfter !== null && $installed >= $failAfter) {
                throw new RuntimeException(
                    'GlobalStateSandbox: injected test failure after install step ' . $installed
                );
            }
        };

        try {
            $undo[] = self::rememberOutputBufferLevel();          // 1
            $checkpoint();
            $undo[] = self::preseedBackendSession();              // 2
            $checkpoint();
            $undo[] = self::replaceGlobal('_SESSION', $desired->session); // 3
            $checkpoint();
            $undo[] = self::replaceGlobal('_POST', $desired->post);       // 4
            $undo[] = self::replaceGlobal('_GET', $desired->get);
            // POST wins, and `+` (not array_merge) so numeric-looking keys keep their names.
            $undo[] = self::replaceGlobal('_REQUEST', $desired->post + $desired->get);
            $undo[] = self::replaceGlobal('_COOKIE', $desired->cookie);
            $undo[] = self::replaceGlobal('_FILES', $desired->files);
            $checkpoint();
            $undo[] = self::mergeServer($desired->server);        // 5
            $checkpoint();
            $undo[] = self::installIsFrontend();                  // 6
            $checkpoint();
            $undo[] = self::resetLoggedMemo();                    // 7
            $checkpoint();
            $undo[] = self::forgetAdminAccountSingletonOnRestore(); // 8 (restore-only)
            $checkpoint();
        } catch (Throwable $e) {
            // Reuse Restore so the rollback is the exact same LIFO, never-throwing machinery.
            (new Restore($undo))->restore();

            throw $e;
        }

        return new Restore($undo);
    }

    /**
     * Row 1 (R3). Controllers leak output buffers — core's own test has to unwind them
     * (`BackendControllerTest.php:83-85`). We discard, never flush: the surrounding response is
     * JSON and any leaked HTML in it would corrupt the MCP reply.
     */
    private static function rememberOutputBufferLevel(): Closure
    {
        $level = \ob_get_level();

        return static function () use ($level): void {
            // Bounded: ob_end_clean() returns false for a buffer PHP refuses to delete (zlib,
            // ob_start() with ~PHP_OUTPUT_HANDLER_REMOVABLE) — stop instead of spinning.
            $guard = 0;
            while (\ob_get_level() > $level && $guard < 256) {
                $guard++;
                if (\ob_end_clean() === false) {
                    return;
                }
            }
        };
    }

    /**
     * Row 2 (R7, Q8). Pre-seed `JTL\Session\Backend::$instance` (`Session/Backend.php:28`,
     * `protected static ?Backend`) with an object built by
     * `ReflectionClass::newInstanceWithoutConstructor()` so `getInstance()` returns it instead of
     * running the constructor.
     *
     * Only seeded when the slot is empty: a *real* backend session instance (a genuine backend
     * request) already short-circuits `getInstance()`, and replacing it with a constructor-less
     * shell would be a downgrade for anything that survives past our restore.
     */
    private static function preseedBackendSession(): Closure
    {
        if (!\class_exists(BackendSession::class) || !\property_exists(BackendSession::class, 'instance')) {
            return static function (): void {
            };
        }
        $property = new ReflectionProperty(BackendSession::class, 'instance');
        $prior    = $property->getValue();
        if ($prior === null) {
            $property->setValue(null, (new ReflectionClass(BackendSession::class))->newInstanceWithoutConstructor());
        }

        return static function () use ($property, $prior): void {
            $property->setValue(null, $prior);
        };
    }

    /**
     * Rows 3 + 4. Replace a superglobal, remembering whether it existed at all — `$_SESSION` is
     * genuinely absent in a CLI/unit-test process, and putting back an empty array there would not
     * be a restore.
     *
     * Reached through `$GLOBALS` rather than the `$_X` aliases because only `$GLOBALS` can express
     * "unset it again": `unset($_SESSION)` inside a function drops the local binding, not the global.
     *
     * @param array<string,mixed> $value
     */
    private static function replaceGlobal(string $name, array $value): Closure
    {
        $existed = \array_key_exists($name, $GLOBALS);
        $prior   = $existed ? $GLOBALS[$name] : null;

        $GLOBALS[$name] = $value;

        return static function () use ($name, $existed, $prior): void {
            if ($existed) {
                $GLOBALS[$name] = $prior;
            } else {
                unset($GLOBALS[$name]);
            }
        };
    }

    /**
     * Row 5. `$_SERVER` is **merged**, not replaced (see {@see SandboxState}), but restored
     * wholesale — the prior array is the only thing guaranteed to be right, and a key-level undo
     * would miss keys the invocation added itself.
     *
     * @param array<string,mixed> $desired
     */
    private static function mergeServer(array $desired): Closure
    {
        $existed = \array_key_exists('_SERVER', $GLOBALS);
        $prior   = $existed ? $GLOBALS['_SERVER'] : null;
        $base    = \is_array($prior) ? $prior : [];

        $GLOBALS['_SERVER'] = $desired + $base;

        return static function () use ($existed, $prior): void {
            if ($existed) {
                $GLOBALS['_SERVER'] = $prior;
            } else {
                unset($GLOBALS['_SERVER']);
            }
        };
    }

    /**
     * Row 6 (R9). `Shop::setIsFrontend(false)` (`Shop.php:348-350`) so `Shop::Smarty()` resolves the
     * BACKEND instance for shop code that reaches for the global one. `AdminAccount::__construct()`
     * flips it anyway (`AdminAccount.php:58`) — and never flips it back, which is precisely why we
     * snapshot it *before* anything else runs.
     *
     * A **public reader exists** — `Shop::isFrontend()` (`Shop.php:343-346`) — so unlike row 7 this
     * row needs no reflection. Same move as `JtlAdminSession.php:41` + `:84`.
     */
    private static function installIsFrontend(): Closure
    {
        if (!\class_exists(Shop::class)) {
            return static function (): void {
            };
        }
        $prior = Shop::isFrontend();
        Shop::setIsFrontend(false);

        return static function () use ($prior): void {
            Shop::setIsFrontend($prior);
        };
    }

    /**
     * Row 7 (R6). Clear the `Shop::$logged` memo (`Shop.php:69`) so `Shop::isAdmin()` re-evaluates
     * against our sandboxed session instead of returning a cached answer, and — far more
     * importantly — restore the caller's value afterwards so a `true` we caused cannot outlive the
     * invocation.
     *
     * **Known wart:** `protected static ?bool` with no accessor. Reflection is the only way in.
     */
    private static function resetLoggedMemo(): Closure
    {
        if (!\class_exists(Shop::class) || !\property_exists(Shop::class, 'logged')) {
            return static function (): void {
            };
        }
        $property = new ReflectionProperty(Shop::class, 'logged');
        $prior    = $property->getValue();
        $property->setValue(null, null);

        return static function () use ($property, $prior): void {
            $property->setValue(null, $prior);
        };
    }

    /**
     * Row 8 (R6). `AdminAccount` is a container singleton (`Services/Factory.php:159`); once
     * resolved against our sandboxed `$_SESSION` it would keep answering `logged() === true` for the
     * rest of the process. Restore-only: nothing is installed on enter, we just make sure the
     * instance the invocation created cannot survive it.
     *
     * Guarded three ways so a container-free unit test cannot explode, and so that we never *create*
     * a container as a side effect of tearing one down: the private `Shop::$container`
     * (`Shop.php:54`) is inspected by reflection first, and a null one means nothing was ever
     * resolved. `forgetInstance()` lives on illuminate's container, not on
     * `JTL\Services\ContainerInterface`, hence the `method_exists()` probe.
     */
    private static function forgetAdminAccountSingletonOnRestore(): Closure
    {
        return static function (): void {
            if (!\class_exists(Shop::class) || !\property_exists(Shop::class, 'container')) {
                return;
            }
            if ((new ReflectionProperty(Shop::class, 'container'))->getValue() === null) {
                return;
            }
            $container = Shop::Container();
            if (!\method_exists($container, 'forgetInstance')) {
                return;
            }
            $container->forgetInstance(AdminAccount::class);
        };
    }
}
