<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\OAuth;

use JsonException;
use JTL\DB\DbInterface;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
use Plugin\tnt_mcp\src\Mcp\Http\ForwardedRequest;
use Plugin\tnt_mcp\src\Mcp\OAuth\Entities\ShopUserEntity;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

/**
 * HTTP handlers for the OAuth 2.1 endpoints (discovery, dynamic registration, authorize, token).
 * All are plain PSR-7 in → PSR-7 out so they slot into the JTL route callbacks and stay testable.
 *
 * The authorize endpoint enforces the locked decision: a token is only ever issued after a real
 * JTL backend admin (2FA-satisfied) clicks Approve. Un-authenticated visitors get a login prompt,
 * never a code. The resource owner of an issued token is that approving admin's `cLogin`
 * ({@see ShopUserEntity}) — decision Q1 — so a token records *who* authorised it and the
 * backend-action invoker can act as exactly that admin.
 */
final class OAuthController
{
    public function __construct(
        private readonly DbInterface $db,
        private readonly AdminSession $admin,
        private readonly OAuthServerFactory $factory,
    ) {
    }

    public static function create(DbInterface $db): self
    {
        return new self($db, new JtlAdminSession(), new OAuthServerFactory($db, KeyManager::default($db)));
    }

    // ── Discovery ──────────────────────────────────────────────────────────────────────────────

    public function authorizationServerMetadata(ServerRequestInterface $request): ResponseInterface
    {
        return $this->cacheable(
            new JsonResponse(DiscoveryMetadata::authorizationServer($this->baseUrl($request))),
        );
    }

    public function agyConfig(ServerRequestInterface $request): ResponseInterface
    {
        $base = $this->baseUrl($request);
        
        return $this->cacheable(new JsonResponse([
            'mcpServers' => [
                'tnt-mcp' => [
                    'serverUrl' => $base . '/tnt-mcp',
                ],
            ],
        ]));
    }

    public function protectedResourceMetadata(ServerRequestInterface $request): ResponseInterface
    {
        $base = $this->baseUrl($request);

        return $this->cacheable(
            new JsonResponse(DiscoveryMetadata::protectedResource($base . '/tnt-mcp', $base)),
        );
    }

    // ── Dynamic client registration (RFC 7591) ───────────────────────────────────────────────────

    public function register(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $decoded = json_decode((string)$request->getBody(), true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return $this->noStore(new JsonResponse(
                ['error' => 'invalid_client_metadata', 'error_description' => 'request body must be JSON'],
                400,
            ));
        }
        if (!\is_array($decoded)) {
            return $this->noStore(new JsonResponse(
                ['error' => 'invalid_client_metadata', 'error_description' => 'request body must be a JSON object'],
                400,
            ));
        }

        $result = (new ClientRegistrar($this->db))->register($decoded, ForwardedRequest::clientIp($request));

        $response = $this->noStore(new JsonResponse($result['body'], $result['status']));

        return $result['status'] === 429 ? $response->withHeader('Retry-After', '3600') : $response;
    }

    // ── Authorization endpoint (admin-gated consent) ─────────────────────────────────────────────

    public function authorize(ServerRequestInterface $request): ResponseInterface
    {
        $server = $this->factory->authorizationServer();

        try {
            $authRequest = $server->validateAuthorizationRequest($request);
        } catch (OAuthServerException $e) {
            return $e->generateHttpResponse(new Response());
        } catch (Throwable) {
            return new JsonResponse(['error' => 'server_error'], 500);
        }

        // league 9.4 registers both PKCE verifiers and silently defaults an omitted
        // `code_challenge_method` to `plain` (AuthCodeGrant::validateAuthorizationRequest), which
        // would let anyone who sees the authorize URL — browser history, referrer, proxy log —
        // redeem the code. Our discovery document advertises S256 only, so refuse anything else
        // rather than quietly honouring it.
        $challengeMethod = $request->getQueryParams()['code_challenge_method'] ?? null;
        if (($request->getQueryParams()['code_challenge'] ?? null) !== null && $challengeMethod !== 'S256') {
            return $this->noStore(new JsonResponse([
                'error'             => 'invalid_request',
                'error_description' => 'code_challenge_method must be S256',
            ], 400));
        }

        // Locked decision: approval requires a logged-in backend admin. No session → prompt login,
        // never issue a code.
        if (!$this->admin->isAuthenticated()) {
            return new HtmlResponse($this->loginPromptHtml($request), 401);
        }

        // Decision Q-CSRF: the approval only counts as a POST body carrying a nonce we minted while
        // rendering the consent screen for this browser session. A GET — including one a third-party
        // page navigated the admin to with `?approve=yes` — can therefore only ever *show* the
        // screen. See {@see ConsentNonce} for why the ambient admin cookie makes this necessary.
        $body     = $request->getParsedBody();
        $body     = \is_array($body) ? $body : [];
        $decision = \is_string($body['approve'] ?? null) ? $body['approve'] : '';

        if ($decision === '' || !ConsentNonce::consume(
            \is_string($body[ConsentNonce::FIELD] ?? null) ? $body[ConsentNonce::FIELD] : null
        )) {
            return new HtmlResponse($this->consentHtml($request, $authRequest));
        }

        // NB: below this point the request is either approved or denied — no more HTML is
        // rendered, so the branded templates above are the only place consent copy lives.

        // Decision Q1: bind the token to the admin who approved it, so the backend-action invoker
        // can later act as exactly that identity ({@see \Plugin\tnt_mcp\src\Mcp\Backend\
        // BackendIdentityFactory}). The login is captured inside isAuthenticated()'s backend-session
        // window above — it is unreadable from here, see JtlAdminSession's docblock.
        //
        // Fail closed: if we cannot name the approver we do not issue an unbound token. The old
        // `'shop'` constant is never minted again (ShopUserEntity::IDENTIFIER is legacy-only).
        $approver = $this->admin->approvingLogin();
        if ($approver === null || $approver === '' || $approver === ShopUserEntity::IDENTIFIER) {
            return new JsonResponse([
                'error'             => 'server_error',
                'error_description' => 'could not determine the approving backend admin',
            ], 500);
        }

        $authRequest->setUser(new ShopUserEntity($approver));
        $authRequest->setAuthorizationApproved($decision === 'yes');

        try {
            return $server->completeAuthorizationRequest($authRequest, new Response());
        } catch (OAuthServerException $e) {
            return $e->generateHttpResponse(new Response());
        } catch (Throwable) {
            return new JsonResponse(['error' => 'server_error'], 500);
        }
    }

    // ── Token endpoint ───────────────────────────────────────────────────────────────────────────

    public function token(ServerRequestInterface $request): ResponseInterface
    {
        $server = $this->factory->authorizationServer();

        try {
            return $server->respondToAccessTokenRequest($request, new Response());
        } catch (OAuthServerException $e) {
            return $e->generateHttpResponse(new Response());
        } catch (Throwable) {
            return new JsonResponse(['error' => 'server_error'], 500);
        }
    }

    // ── Helpers ───────────────────────────────────────────────────────────────────────────────────

    /**
     * Public origin the connector actually hit (scheme + host [+ non-default port]). Prefers a
     * trusted-proxy `X-Forwarded-Host`/`-Proto` over the internal request host — see
     * {@see ForwardedRequest} for the loopback/private-address anti-spoofing gate.
     */
    private function baseUrl(ServerRequestInterface $request): string
    {
        return ForwardedRequest::baseUrl($request);
    }

    private function cacheable(ResponseInterface $response): ResponseInterface
    {
        return $response->withHeader('Cache-Control', 'public, max-age=86400');
    }

    private function noStore(ResponseInterface $response): ResponseInterface
    {
        return $response->withHeader('Cache-Control', 'no-store')->withHeader('Pragma', 'no-cache');
    }

    /**
     * Branded, shop-admin-login-styled version of the "please sign in first" screen. Language is
     * chosen from the browser's Accept-Language header — no admin session exists yet at this
     * point, see {@see ConsentLang::resolve()}.
     */
    private function loginPromptHtml(ServerRequestInterface $request): string
    {
        $lang = ConsentLang::resolve(null, $request->getHeaderLine('Accept-Language') ?: null);
        $back = (string)$request->getUri();

        return ConsentTemplate::loginPrompt(
            $lang,
            ConsentBranding::shopName(),
            ConsentBranding::logoUrl(),
            $this->admin->loginUrl(),
            $back,
        );
    }

    /**
     * Branded consent screen shown to an already-authenticated backend admin. Language is chosen
     * from $_SESSION['AdminAccount']->language (mirrors how includes/src/Smarty/BackendSmarty.php:70
     * and includes/src/Plugin/PluginLoader.php:35 pick the admin locale), falling back to
     * Accept-Language, then German.
     *
     * ⚠️ Best-effort only, contrary to what this docblock used to claim: by the time we get here
     * {@see JtlAdminSession::isAuthenticated()} has restored the **frontend** `$_SESSION` (its
     * `finally` block), and the frontend session carries no real `AdminAccount`. So this read
     * normally misses and the Accept-Language fallback does the work. Harmless for a locale; it is
     * precisely why the token's admin binding is captured inside `JtlAdminSession` instead of
     * being read from `$_SESSION` here.
     */
    private function consentHtml(ServerRequestInterface $request, AuthorizationRequestInterface $authRequest): string
    {
        $clientName  = $authRequest->getClient()->getName();
        $sessionLang = null;
        $account     = $_SESSION['AdminAccount'] ?? null;
        if (\is_object($account) && isset($account->language) && \is_string($account->language)) {
            $sessionLang = $account->language;
        }
        $lang = ConsentLang::resolve($sessionLang, $request->getHeaderLine('Accept-Language') ?: null);

        // The form POSTs back to this same URL *including its query string* — league reads the
        // authorization parameters off the query params, so they have to survive the round trip. The
        // only hidden field is the nonce; re-emitting the caller's own query params as hidden inputs
        // would have proved nothing about who sent the request.
        $uri    = $request->getUri();
        $action = $uri->getPath() . ($uri->getQuery() === '' ? '' : '?' . $uri->getQuery());
        $hidden = \sprintf(
            '<input type="hidden" name="%s" value="%s">',
            \htmlspecialchars(ConsentNonce::FIELD, \ENT_QUOTES),
            \htmlspecialchars(ConsentNonce::issue(), \ENT_QUOTES),
        );

        return ConsentTemplate::consent(
            $lang,
            ConsentBranding::shopName(),
            ConsentBranding::logoUrl(),
            $clientName,
            self::consentScopes($authRequest),
            $action,
            $hidden,
        );
    }

    /**
     * What the admin is actually about to grant. Mirrors {@see Repositories\ScopeRepository
     * ::finalizeScopes()}: a request that names no usable scope receives the full catalogue, so an
     * empty request must not render an empty — and therefore misleading — list.
     *
     * @return list<string>
     */
    private static function consentScopes(AuthorizationRequestInterface $authRequest): array
    {
        $requested = [];
        foreach ($authRequest->getScopes() as $scope) {
            $id = $scope->getIdentifier();
            if (\in_array($id, ScopeCatalog::all(), true)) {
                $requested[] = $id;
            }
        }

        return $requested === [] ? ScopeCatalog::all() : \array_values(\array_unique($requested));
    }
}
