<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp;

use JTL\Backend\NotificationEntry;
use JTL\Events\Dispatcher;
use JTL\Plugin\Bootstrapper;
use JTL\Router\Router;
use JTL\Smarty\JTLSmarty;
use Plugin\tnt_mcp\src\Mcp\Admin\AdminLanguage;
use Plugin\tnt_mcp\src\Mcp\Admin\PermissionAudit;
use Plugin\tnt_mcp\src\Mcp\Http\McpHttpEndpoint;
use Plugin\tnt_mcp\src\Mcp\OAuth\OAuthController;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

/**
 * Plugin entry point. Registers the public MCP endpoint as a real front-end route so an external
 * MCP client (ChatGPT / Claude / any) can POST JSON-RPC to it with a per-shop bearer token — no
 * admin session involved. The route delegates to {@see McpHttpEndpoint}, which authenticates,
 * scope-checks and audits every call; the MCP services under src/Mcp stay plain DI classes so
 * they remain unit-testable outside the shop runtime.
 *
 * Route: POST /tnt-mcp (pretty route, mapped into every locale group by the JTL router).
 */
class Bootstrap extends Bootstrapper
{
    /** Public slug the MCP client connects to (POST). */
    public const ROUTE_SLUG = '/tnt-mcp';

    /**
     * Decision Q2's install-time rights check — see {@see PermissionAudit}.
     *
     * ⚠️ This WARNS, it does not block, and that is a deliberate choice:
     *  - `preInstallCheck()` is the only hook that can abort, and the shop calls it **only on a
     *    fresh install** (`$this->plugin === null`, Installer.php:180-195). An UPDATE — which is
     *    exactly the path on which the shop-mutating `invoke_admin_action` arrives — never reaches
     *    it, so blocking there would guard the harmless case and miss the dangerous one.
     *  - A refused `preInstallCheck()` collapses into `InstallCode::CANCELED` (Installer.php:196),
     *    a generic failure with nowhere to put the list of missing permission names. The
     *    requirement is that the operator LEARNS which permissions are missing; cancelling destroys
     *    that message.
     *  - Missing permissions are not a security hole under Q2. The acting admin's real rights are
     *    enforced per call (BackendActionInvoker.php:322-327), so a missing permission means the
     *    action is refused at run time — a functionality gap, never an escalation.
     *
     * The same finding is repeated permanently on the "Integrationen" tab, so it survives a
     * dismissed notification.
     */
    public function installed(): void
    {
        $this->notifyMissingPermissions();
    }

    /**
     * @param string $oldVersion
     * @param string $newVersion
     */
    public function updated($oldVersion, $newVersion): void
    {
        $this->notifyMissingPermissions();
    }

    private function notifyMissingPermissions(): void
    {
        try {
            $admin = PermissionAudit::currentAdmin();
            if ($admin === null) {
                return;
            }
            $report = PermissionAudit::forShippedCatalog()
                ->report($admin['login'], $admin['groupId'], $admin['permissions']);
            if ($report['ok'] && $report['unknown'] === []) {
                return;
            }

            $this->addNotify(
                NotificationEntry::TYPE_WARNING,
                AdminLanguage::pick($report['title']),
                AdminLanguage::pick($report['text']),
            );
        } catch (Throwable) {
            // A rights check must never be the reason an install or update fails.
        }
    }

    public function boot(Dispatcher $dispatcher): void
    {
        parent::boot($dispatcher);

        // The shop's main autoloader maps `Plugin\` → plugins/, so the plugin's own classes load
        // without help. The third-party OAuth stack (league/oauth2-server, defuse/php-encryption,
        // …) lives under the plugin's committed vendor/, which the shop does NOT auto-register — so
        // load it here (idempotently) before any route can reach a League\* class.
        $autoload = __DIR__ . '/vendor/autoload.php';
        if (is_file($autoload) && !class_exists(\League\OAuth2\Server\AuthorizationServer::class, false)) {
            require_once $autoload;
        }

        // HOOK_ROUTER_PRE_DISPATCH (400) fires with the Router before the default controller is
        // registered, which is exactly where plugins add static routes so they aren't shadowed.
        $dispatcher->listen('shop.hook.' . \HOOK_ROUTER_PRE_DISPATCH, function (array $args): void {
            $router = $args['router'] ?? null;
            if ($router instanceof Router) {
                $router->addRoute(self::ROUTE_SLUG, [$this, 'handleMcpRequest'], 'tnt_mcp_endpoint', ['POST']);
                $router->addRoute(self::ROUTE_SLUG . '/agy-config', [$this, 'handleAgyConfig'], 'tnt_mcp_agy_config', ['GET']);

                // OAuth 2.1 endpoints so ChatGPT/Claude connectors can authorize against /tnt-mcp.
                // Discovery lives at the host root (RFC 8414 / RFC 9728) — the default '/' locale
                // group registers these with no locale prefix, which is what the specs require.
                $router->addRoute(
                    '/.well-known/oauth-authorization-server',
                    [$this, 'handleOAuthMetadata'],
                    'tnt_mcp_oauth_as_meta',
                    ['GET'],
                );
                $router->addRoute(
                    '/.well-known/oauth-protected-resource',
                    [$this, 'handleProtectedResourceMetadata'],
                    'tnt_mcp_oauth_pr_meta',
                    ['GET'],
                );
                $router->addRoute(self::ROUTE_SLUG . '/register', [$this, 'handleOAuthRegister'], 'tnt_mcp_oauth_register', ['POST']);
                $router->addRoute(self::ROUTE_SLUG . '/authorize', [$this, 'handleOAuthAuthorize'], 'tnt_mcp_oauth_authorize', ['GET', 'POST']);
                $router->addRoute(self::ROUTE_SLUG . '/token', [$this, 'handleOAuthToken'], 'tnt_mcp_oauth_token', ['POST']);
            }
        });
    }

    /**
     * Front-controller callback for the MCP route. Signature is fixed by the JTL router strategy
     * (request, route vars, smarty) → PSR-7 response.
     *
     * @param array<string,mixed> $params
     */
    public function handleMcpRequest(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return (new McpHttpEndpoint($this->getDB()))->handle($request);
    }

    /**
     * Antigravity MCP config endpoint (GET).
     *
     * @param array<string,mixed> $params
     */
    public function handleAgyConfig(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->agyConfig($request);
    }

    /**
     * RFC 8414 authorization-server metadata (GET, root well-known).
     *
     * @param array<string,mixed> $params
     */
    public function handleOAuthMetadata(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->authorizationServerMetadata($request);
    }

    /**
     * RFC 9728 protected-resource metadata (GET, root well-known).
     *
     * @param array<string,mixed> $params
     */
    public function handleProtectedResourceMetadata(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->protectedResourceMetadata($request);
    }

    /**
     * RFC 7591 dynamic client registration (POST).
     *
     * @param array<string,mixed> $params
     */
    public function handleOAuthRegister(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->register($request);
    }

    /**
     * OAuth authorization endpoint (GET) — admin-gated consent, issues a PKCE-bound code.
     *
     * @param array<string,mixed> $params
     */
    public function handleOAuthAuthorize(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->authorize($request);
    }

    /**
     * OAuth token endpoint (POST) — exchanges code + PKCE verifier for tokens.
     *
     * @param array<string,mixed> $params
     */
    public function handleOAuthToken(
        ServerRequestInterface $request,
        array $params,
        JTLSmarty $smarty,
    ): ResponseInterface {
        return OAuthController::create($this->getDB())->token($request);
    }
}
