<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\OAuth;

use JTL\DB\DbInterface;
use RuntimeException;
use stdClass;

/**
 * RFC 7591 dynamic client registration. Creates a PUBLIC client (PKCE, no secret) — the shape the
 * ChatGPT/Claude connectors self-register — and returns the RFC 7591 response body. Grants are
 * restricted to authorization_code + refresh_token; response_type to `code`. A bad/omitted
 * redirect_uris list is rejected by the caller before we get here.
 */
final class ClientRegistrar
{
    private const ALLOWED_GRANTS = ['authorization_code', 'refresh_token'];
    private const MAX_REDIRECTS  = 5;
    private const MAX_NAME_LEN   = 120;

    /**
     * Registration is unauthenticated by RFC 7591 — that is the point, it is how a connector
     * bootstraps itself before any human is involved. What it must not be is an unbounded INSERT
     * for anyone who can reach the shop, so two caps apply per rolling 24 hours: one per source
     * address, and one for the shop as a whole in case the addresses vary.
     *
     * Both are far above real use (a connector registers once and keeps its client_id) and far
     * below "fills the table". A refusal is 429 with `Retry-After`, never a silent drop.
     */
    private const MAX_PER_IP_PER_DAY = 20;
    private const MAX_PER_DAY        = 200;

    /**
     * A client nobody ever approved is garbage after this many days: consent happens seconds after
     * registration, so anything older with no auth code and no access token was never used.
     */
    private const PRUNE_AFTER_DAYS = 7;

    public function __construct(private readonly DbInterface $db)
    {
    }

    /**
     * @param array<string,mixed> $request decoded JSON registration request
     * @param string              $ip      source address, for the per-address cap ('' when unknown)
     *
     * @return array{status:int,body:array<string,mixed>}
     */
    public function register(array $request, string $ip = ''): array
    {
        $this->pruneAbandoned();

        $refusal = $this->rateLimit($ip);
        if ($refusal !== null) {
            return $refusal;
        }

        $redirects = RedirectUriValidator::clean($request['redirect_uris'] ?? null);
        if ($redirects === [] || \count($redirects) > self::MAX_REDIRECTS) {
            return $this->error('invalid_redirect_uris', 'redirect_uris must be 1-5 https (or loopback http) URIs');
        }

        $grants = $this->intersectGrants($request['grant_types'] ?? null);
        if ($grants === []) {
            return $this->error('invalid_client_metadata', 'no supported grant_types requested');
        }

        $responseTypes = $request['response_types'] ?? ['code'];
        if (!\is_array($responseTypes) || array_diff($responseTypes, ['code']) !== []) {
            return $this->error('invalid_client_metadata', 'only response_type "code" is supported');
        }

        $name      = $this->clientName($request['client_name'] ?? null);
        $scopes    = $this->requestedScopes($request['scope'] ?? null);
        $clientId  = bin2hex(random_bytes(16));

        $row               = new stdClass();
        $row->cClientId    = $clientId;
        $row->cName        = $name;
        $row->cRedirectUris = (string)json_encode($redirects, \JSON_THROW_ON_ERROR);
        $row->nConfidential = 0;
        $row->cSecretHash  = null;
        $row->cScopes      = (string)json_encode($scopes, \JSON_THROW_ON_ERROR);
        $row->cGrantTypes  = (string)json_encode($grants, \JSON_THROW_ON_ERROR);
        $row->cRegistrarIp = mb_substr($ip, 0, 45);
        $row->dCreated     = (new \DateTimeImmutable())->format('Y-m-d H:i:s');

        try {
            $this->db->insert('tnt_mcp_oauth_client', $row);
        } catch (\Throwable $e) {
            throw new RuntimeException('tnt_mcp: could not persist OAuth client', 0, $e);
        }

        return [
            'status' => 201,
            'body'   => [
                'client_id'                  => $clientId,
                'client_id_issued_at'        => time(),
                'redirect_uris'              => $redirects,
                'grant_types'                => $grants,
                'response_types'             => ['code'],
                'token_endpoint_auth_method' => 'none',
                'scope'                      => implode(' ', $scopes),
                'client_name'                => $name,
            ],
        ];
    }

    /**
     * @param mixed $grantTypes
     *
     * @return list<string>
     */
    private function intersectGrants(mixed $grantTypes): array
    {
        if ($grantTypes === null) {
            return self::ALLOWED_GRANTS;
        }
        if (!\is_array($grantTypes)) {
            return [];
        }
        $out = [];
        foreach (self::ALLOWED_GRANTS as $grant) {
            if (\in_array($grant, $grantTypes, true)) {
                $out[] = $grant;
            }
        }

        return $out;
    }

    private function clientName(mixed $name): string
    {
        if (\is_string($name) && trim($name) !== '') {
            return mb_substr(trim($name), 0, self::MAX_NAME_LEN);
        }

        return 'MCP Connector';
    }

    /**
     * @return list<string>
     */
    private function requestedScopes(mixed $scope): array
    {
        if (!\is_string($scope) || trim($scope) === '') {
            return ScopeCatalog::all();
        }
        $out = [];
        foreach (preg_split('/\s+/', trim($scope)) ?: [] as $candidate) {
            if ($candidate !== '' && ScopeCatalog::knows($candidate) && !\in_array($candidate, $out, true)) {
                $out[] = $candidate;
            }
        }

        return $out === [] ? ScopeCatalog::all() : $out;
    }

    /**
     * @return array{status:int,body:array<string,mixed>}|null null when the request may proceed
     */
    private function rateLimit(string $ip): ?array
    {
        if ($ip !== '' && $this->countSince('AND cRegistrarIp = :ip', ['ip' => $ip]) >= self::MAX_PER_IP_PER_DAY) {
            return $this->tooMany('too many client registrations from this address in the last 24 hours');
        }
        if ($this->countSince('', []) >= self::MAX_PER_DAY) {
            return $this->tooMany('too many client registrations on this shop in the last 24 hours');
        }

        return null;
    }

    /**
     * @param array<string,string> $params
     */
    private function countSince(string $extraWhere, array $params): int
    {
        return $this->db->getSingleInt(
            'SELECT COUNT(*) AS registrations FROM tnt_mcp_oauth_client '
            . 'WHERE dCreated > DATE_SUB(NOW(), INTERVAL 1 DAY) ' . $extraWhere,
            'registrations',
            $params,
        );
    }

    /**
     * Delete clients that were registered, never approved by an admin, and are older than
     * {@see self::PRUNE_AFTER_DAYS}. A client that reached consent has an auth code or an access
     * token pointing at it, so `NOT EXISTS` is the whole test — no extra bookkeeping column and
     * nothing to keep in sync.
     *
     * Opportunistic (runs on the registration path only) and failure-tolerant: housekeeping must
     * never be the reason a legitimate connector cannot register.
     */
    private function pruneAbandoned(): void
    {
        try {
            $this->db->queryPrepared(
                'DELETE c FROM tnt_mcp_oauth_client c
                    WHERE c.dCreated < DATE_SUB(NOW(), INTERVAL :days DAY)
                      AND NOT EXISTS (SELECT 1 FROM tnt_mcp_oauth_auth_code a WHERE a.cClientId = c.cClientId)
                      AND NOT EXISTS (SELECT 1 FROM tnt_mcp_oauth_access_token t WHERE t.cClientId = c.cClientId)',
                ['days' => self::PRUNE_AFTER_DAYS],
            );
        } catch (\Throwable) {
            // intentionally ignored — see above
        }
    }

    /**
     * @return array{status:int,body:array<string,mixed>}
     */
    private function tooMany(string $description): array
    {
        return [
            'status' => 429,
            'body'   => ['error' => 'temporarily_unavailable', 'error_description' => $description],
        ];
    }

    /**
     * @return array{status:int,body:array<string,mixed>}
     */
    private function error(string $code, string $description): array
    {
        return ['status' => 400, 'body' => ['error' => $code, 'error_description' => $description]];
    }
}
