<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Backend;

use JTL\Router\Controller\Backend\AbstractBackendController;
use JTL\Router\Controller\Backend\AdminAccountController;
use JTL\Router\Controller\Backend\CodeController;
use JTL\Router\Controller\Backend\Collection;
use JTL\Router\Controller\Backend\DBManagerController;
use JTL\Router\Controller\Backend\DBUpdateController;
use JTL\Router\Controller\Backend\ElfinderController;
use JTL\Router\Controller\Backend\EmailTemplateController;
use JTL\Router\Controller\Backend\FilesystemController;
use JTL\Router\Controller\Backend\ImageManagementController;
use JTL\Router\Controller\Backend\LicenseController;
use JTL\Router\Controller\Backend\LogoutController;
use JTL\Router\Controller\Backend\NewsletterController;
use JTL\Router\Controller\Backend\OAuthAuthorizationController;
use JTL\Router\Controller\Backend\OPCCCController;
use JTL\Router\Controller\Backend\OPCController;
use JTL\Router\Controller\Backend\PasswordController;
use JTL\Router\Controller\Backend\StatusMailController;
use JTL\Router\Controller\Backend\UpgradeController;

/**
 * Resolves a backend route slug to its controller class, from the shop's own single source of
 * truth: `JTL\Router\Controller\Backend\Collection::getRoutes()` (Collection.php:14-116). Nothing
 * here maintains a second list of routes — a shop upgrade that renames or drops a route shows up
 * as an {@see UnknownRouteException}, not as a stale copy that quietly disagrees.
 *
 * ⚠️ The map is **not** the plain `slug => class` array design §1.8 assumes. Its keys are the raw
 * *route patterns* the router registers, so four of them carry a path-parameter suffix
 * (Collection.php:31, :51, :96, :99):
 *
 * ```
 * 'stats[/{id}]'    => StatsController::class
 * 'branding[/{id}]' => BrandingController::class
 * 'config[/{id}]'   => ConfigController::class
 * 'plugin/{id}'     => PluginController::class
 * ```
 *
 * We therefore index by the **slug prefix** (everything before the first `/` or `[`) while keeping
 * the original pattern available via {@see self::patternForRoute()} — `BackendRouter` passes that
 * pattern verbatim into `$controller->setRoute('/' . $route)` (BackendRouter.php:64), brackets and
 * all, so an invoker that wants to reproduce a real request must use the pattern, not the slug.
 *
 * `Collection::getRoutes()` is an instance method, not a constant, so it is called once and cached
 * per locator instance.
 */
final class ControllerLocator
{
    /**
     * Design §2.4's hard blocklist — controllers an MCP client must never reach, whatever a catalog
     * file says. Reasons, in the order below: privilege escalation (mint admins / change rights);
     * arbitrary SQL + DDL, which breaks the transaction wrapper; irreversible DDL/upgrade paths;
     * filesystem writers; session/credential mutation; senders of real mail; phones home and is a
     * known output-buffer offender (BackendControllerTest.php:84). Mirrors and extends core's own
     * `BackendControllerTest::SKIP_CONTROLLERS` (:34-38).
     *
     * @var list<class-string>
     */
    public const array BLOCKED = [
        AdminAccountController::class,
        DBManagerController::class,
        DBUpdateController::class,
        UpgradeController::class,
        OPCController::class,
        OPCCCController::class,
        ElfinderController::class,
        FilesystemController::class,
        ImageManagementController::class,
        LogoutController::class,
        PasswordController::class,
        OAuthAuthorizationController::class,
        CodeController::class,
        NewsletterController::class,
        StatusMailController::class,
        EmailTemplateController::class,
        LicenseController::class,
    ];

    /** @var array<string, class-string>|null slug => controller class, blocklisted entries removed */
    private ?array $map = null;

    /** @var array<string, string>|null slug => the raw route pattern Collection declares */
    private ?array $patterns = null;

    /**
     * @return class-string
     * @throws UnknownRouteException  when no such route exists
     * @throws BlockedRouteException  when the route exists but its controller is blocklisted
     */
    public function classForRoute(string $route): string
    {
        $slug = self::slug($route);
        $map  = $this->all();
        if (isset($map[$slug])) {
            return $map[$slug];
        }
        if (isset($this->blockedSlugs()[$slug])) {
            throw new BlockedRouteException(
                'Backend route "' . $slug . '" is permanently blocked for MCP invocation ('
                . $this->blockedSlugs()[$slug] . ').',
            );
        }

        throw new UnknownRouteException('Unknown backend route "' . $slug . '".');
    }

    /**
     * The invocable map, blocklisted entries already removed so describe/debug output can never
     * advertise a route the invoker would refuse.
     *
     * @return array<string, class-string> slug => controller class
     */
    public function all(): array
    {
        if ($this->map === null) {
            $this->load();
        }

        return $this->map ?? [];
    }

    /**
     * The raw pattern `Collection` declares for a slug — e.g. `config[/{id}]` for `config`. This is
     * what `BackendRouter` feeds to `AbstractBackendController::setRoute()` (BackendRouter.php:64).
     *
     * @throws UnknownRouteException
     */
    public function patternForRoute(string $route): string
    {
        $slug = self::slug($route);
        if ($this->patterns === null) {
            $this->load();
        }
        if (!isset($this->patterns[$slug])) {
            throw new UnknownRouteException('Unknown backend route "' . $slug . '".');
        }

        return $this->patterns[$slug];
    }

    public function isBlocked(string $route): bool
    {
        return isset($this->blockedSlugs()[self::slug($route)]);
    }

    /**
     * The class-level twin of {@see self::isBlocked()}, for `adminIo` entries — which name a
     * controller CLASS, not a route slug, and so never pass through {@see self::classForRoute()}.
     *
     * Without this, `kind: adminIo` would be a way around the whole blocklist: nothing else stops a
     * catalog file from naming `AdminAccountController` directly. `is_a(..., allow_string: true)`
     * rather than `in_array()` so that extending a blocked controller does not launder it.
     */
    public function isBlockedClass(string $class): bool
    {
        foreach (self::BLOCKED as $blocked) {
            if ($class === $blocked || \is_a($class, $blocked, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Gate for a class named directly by an `adminIo` catalog entry.
     *
     * The three refusals are one exception on purpose — see {@see BlockedControllerException}. Note
     * what is deliberately NOT required: that the class also own a route in `Collection`. IO methods
     * are reached through the shared `io` endpoint, so demanding a route of its own would refuse
     * every legitimate entry; the blocklist and the base-class check are what carry the policy.
     *
     * @return class-string
     * @throws BlockedControllerException
     */
    public function assertInvocableClass(string $class): string
    {
        if (!\class_exists($class)) {
            throw new BlockedControllerException('Backend controller class "' . $class . '" does not exist.');
        }
        if (!\is_a($class, AbstractBackendController::class, true)) {
            throw new BlockedControllerException(
                'Class "' . $class . '" is not a ' . AbstractBackendController::class . ' and may not be invoked.',
            );
        }
        if ($this->isBlockedClass($class)) {
            throw new BlockedControllerException(
                'Backend controller "' . $class . '" is permanently blocked for MCP invocation.',
            );
        }

        return $class;
    }

    /**
     * `stats[/{id}]` -> `stats`, `plugin/{id}` -> `plugin`, `/config` -> `config`.
     */
    public static function slug(string $route): string
    {
        $route = \ltrim(\trim($route), '/');
        // preg_split answers false on a bad subject; the whole route is the right fallback, not a
        // fatal on `false[0]`.
        $parts = \preg_split('~[/\[]~', $route, 2);

        return \strtolower(\rtrim($parts === false ? $route : ($parts[0] ?? ''), '/'));
    }

    /** @return array<string, class-string> slug => blocked controller class */
    private function blockedSlugs(): array
    {
        $blocked = [];
        foreach ((new Collection())->getRoutes() as $pattern => $class) {
            if (\in_array($class, self::BLOCKED, true)) {
                $blocked[self::slug($pattern)] = $class;
            }
        }

        return $blocked;
    }

    private function load(): void
    {
        $map      = [];
        $patterns = [];
        foreach ((new Collection())->getRoutes() as $pattern => $class) {
            $slug            = self::slug($pattern);
            $patterns[$slug] = $pattern;
            if (\in_array($class, self::BLOCKED, true)) {
                continue;
            }
            $map[$slug] = $class;
        }
        $this->map      = $map;
        $this->patterns = $patterns;
    }
}
