<?php

declare(strict_types=1);

final class MyronMcpOAuthTokenCodec
{
    public const ACCESS_PREFIX = 'myr_oauth_';
    public const REFRESH_PREFIX = 'myr_oauth_refresh_';
    public const CODE_PREFIX = 'myr_oauth_code_';
    public const CLIENT_PREFIX = 'mcp_client_';
    public const SECRET_PREFIX = 'mcp_secret_';
    public const PREFIX_LENGTH = 12;

    /**
     * @return array{token: string, prefix: string, hash: string}
     */
    public static function generate(string $literalPrefix): array
    {
        $raw = $literalPrefix . myronBase64UrlEncode(random_bytes(32));
        return [
            'token' => $raw,
            'prefix' => self::prefixFromToken($raw, $literalPrefix),
            'hash' => password_hash($raw, PASSWORD_BCRYPT, ['cost' => 12]),
        ];
    }

    public static function prefixFromToken(string $token, string $literalPrefix): string
    {
        if (!str_starts_with($token, $literalPrefix)) {
            return '';
        }
        return substr(substr($token, strlen($literalPrefix)), 0, self::PREFIX_LENGTH);
    }

    public static function verify(string $token, string $hash): bool
    {
        return $hash !== '' && password_verify($token, $hash);
    }
}

final class MyronMcpOAuthClientRepository
{
    public function __construct(private readonly PDO $pdo)
    {
        $this->ensureStorage();
    }

    public function ensureStorage(): void
    {
        $suffix = $this->tableSuffix();
        $this->run('CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
            id VARCHAR(80) PRIMARY KEY,
            client_name VARCHAR(255) NOT NULL DEFAULT \'\',
            client_secret_hash TEXT NULL,
            redirect_uris_json TEXT NOT NULL,
            scopes_json TEXT NOT NULL,
            created_at VARCHAR(40) NOT NULL,
            revoked_at VARCHAR(40) DEFAULT NULL
        )' . $suffix);
        $this->run('CREATE TABLE IF NOT EXISTS mcp_oauth_authorization_codes (
            id VARCHAR(80) PRIMARY KEY,
            code_prefix VARCHAR(32) NOT NULL,
            code_hash TEXT NOT NULL,
            client_id VARCHAR(80) NOT NULL,
            redirect_uri TEXT NOT NULL,
            resource TEXT NOT NULL,
            scopes_json TEXT NOT NULL,
            code_challenge VARCHAR(255) NOT NULL DEFAULT \'\',
            code_challenge_method VARCHAR(16) NOT NULL DEFAULT \'\',
            created_by_user_id VARCHAR(80) NOT NULL DEFAULT \'\',
            created_at VARCHAR(40) NOT NULL,
            expires_at VARCHAR(40) NOT NULL,
            consumed_at VARCHAR(40) DEFAULT NULL
        )' . $suffix);
        $this->run('CREATE TABLE IF NOT EXISTS mcp_oauth_access_tokens (
            id VARCHAR(80) PRIMARY KEY,
            token_prefix VARCHAR(32) NOT NULL,
            token_hash TEXT NOT NULL,
            client_id VARCHAR(80) NOT NULL,
            integration_id VARCHAR(80) NOT NULL,
            resource TEXT NOT NULL,
            scopes_json TEXT NOT NULL,
            created_at VARCHAR(40) NOT NULL,
            expires_at VARCHAR(40) NOT NULL,
            revoked_at VARCHAR(40) DEFAULT NULL,
            last_used_at VARCHAR(40) DEFAULT NULL
        )' . $suffix);
        $this->run('CREATE TABLE IF NOT EXISTS mcp_oauth_refresh_tokens (
            id VARCHAR(80) PRIMARY KEY,
            token_prefix VARCHAR(32) NOT NULL,
            token_hash TEXT NOT NULL,
            client_id VARCHAR(80) NOT NULL,
            integration_id VARCHAR(80) NOT NULL,
            resource TEXT NOT NULL,
            scopes_json TEXT NOT NULL,
            created_at VARCHAR(40) NOT NULL,
            expires_at VARCHAR(40) NOT NULL,
            revoked_at VARCHAR(40) DEFAULT NULL
        )' . $suffix);
        $this->addIndex('mcp_oauth_authorization_codes', 'idx_mcp_oauth_codes_prefix', ['code_prefix']);
        $this->addIndex('mcp_oauth_access_tokens', 'idx_mcp_oauth_access_prefix', ['token_prefix']);
        $this->addIndex('mcp_oauth_refresh_tokens', 'idx_mcp_oauth_refresh_prefix', ['token_prefix']);
        $this->addIndex('mcp_oauth_access_tokens', 'idx_mcp_oauth_access_integration', ['integration_id']);
        $this->addIndex('mcp_oauth_refresh_tokens', 'idx_mcp_oauth_refresh_integration', ['integration_id']);
    }

    /**
     * @param list<string> $redirectUris
     * @param list<string> $scopes
     * @return array{clientId: string, clientSecret: string, client: array<string, mixed>}
     */
    public function registerClient(string $clientName, array $redirectUris, array $scopes): array
    {
        $redirectUris = array_values(array_filter(array_map('trim', $redirectUris), static fn (string $v): bool => $v !== ''));
        if ($redirectUris === []) {
            throw new InvalidArgumentException('At least one redirect URI is required.');
        }
        foreach ($redirectUris as $uri) {
            $this->assertRedirectUriAllowed($uri);
        }
        $scopes = $this->normalizeScopes($scopes);
        $client = MyronMcpOAuthTokenCodec::generate(MyronMcpOAuthTokenCodec::CLIENT_PREFIX);
        $secret = MyronMcpOAuthTokenCodec::generate(MyronMcpOAuthTokenCodec::SECRET_PREFIX);
        $statement = $this->pdo->prepare(
            'INSERT INTO mcp_oauth_clients (
                id, client_name, client_secret_hash, redirect_uris_json, scopes_json, created_at, revoked_at
             ) VALUES (
                :id, :client_name, :client_secret_hash, :redirect_uris_json, :scopes_json, :created_at, NULL
             )'
        );
        $statement->execute([
            ':id' => $client['token'],
            ':client_name' => trim($clientName) !== '' ? trim($clientName) : 'MCP OAuth Client',
            ':client_secret_hash' => $secret['hash'],
            ':redirect_uris_json' => json_encode($redirectUris, JSON_UNESCAPED_SLASHES),
            ':scopes_json' => json_encode($scopes, JSON_UNESCAPED_SLASHES),
            ':created_at' => myronNow(),
        ]);

        return [
            'clientId' => $client['token'],
            'clientSecret' => $secret['token'],
            'client' => $this->findClient($client['token']) ?? [],
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findClient(string $clientId): ?array
    {
        $statement = $this->pdo->prepare('SELECT * FROM mcp_oauth_clients WHERE id = :id LIMIT 1');
        $statement->execute([':id' => trim($clientId)]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        return is_array($row) ? $this->hydrateClient($row) : null;
    }

    public function clientAcceptsRedirect(string $clientId, string $redirectUri): bool
    {
        $client = $this->findClient($clientId);
        return $client !== null && in_array($redirectUri, $client['redirectUris'], true);
    }

    public function verifyClientSecret(string $clientId, string $secret): bool
    {
        $statement = $this->pdo->prepare('SELECT client_secret_hash FROM mcp_oauth_clients WHERE id = :id AND revoked_at IS NULL');
        $statement->execute([':id' => trim($clientId)]);
        $hash = $statement->fetchColumn();
        return is_string($hash) && $hash !== '' && MyronMcpOAuthTokenCodec::verify($secret, $hash);
    }

    /**
     * @param list<string> $scopes
     * @return array{code: string, codeId: string}
     */
    public function createAuthorizationCode(
        string $clientId,
        string $redirectUri,
        string $resource,
        array $scopes,
        string $codeChallenge,
        string $codeChallengeMethod,
        string $createdByUserId
    ): array {
        $code = MyronMcpOAuthTokenCodec::generate(MyronMcpOAuthTokenCodec::CODE_PREFIX);
        $codeId = myronGenerateId('moc');
        $statement = $this->pdo->prepare(
            'INSERT INTO mcp_oauth_authorization_codes (
                id, code_prefix, code_hash, client_id, redirect_uri, resource,
                scopes_json, code_challenge, code_challenge_method, created_by_user_id,
                created_at, expires_at, consumed_at
             ) VALUES (
                :id, :code_prefix, :code_hash, :client_id, :redirect_uri, :resource,
                :scopes_json, :code_challenge, :code_challenge_method, :created_by_user_id,
                :created_at, :expires_at, NULL
             )'
        );
        $statement->execute([
            ':id' => $codeId,
            ':code_prefix' => $code['prefix'],
            ':code_hash' => $code['hash'],
            ':client_id' => trim($clientId),
            ':redirect_uri' => trim($redirectUri),
            ':resource' => trim($resource),
            ':scopes_json' => json_encode($this->normalizeScopes($scopes), JSON_UNESCAPED_SLASHES),
            ':code_challenge' => trim($codeChallenge),
            ':code_challenge_method' => trim($codeChallengeMethod),
            ':created_by_user_id' => trim($createdByUserId),
            ':created_at' => myronNow(),
            ':expires_at' => date('Y-m-d H:i:s', time() + 600),
        ]);

        return ['code' => $code['token'], 'codeId' => $codeId];
    }

    /**
     * @return array<string, mixed>|null
     */
    public function consumeAuthorizationCode(string $code): ?array
    {
        $prefix = MyronMcpOAuthTokenCodec::prefixFromToken($code, MyronMcpOAuthTokenCodec::CODE_PREFIX);
        if ($prefix === '') {
            return null;
        }
        $statement = $this->pdo->prepare('SELECT * FROM mcp_oauth_authorization_codes WHERE code_prefix = :prefix AND consumed_at IS NULL');
        $statement->execute([':prefix' => $prefix]);
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
            if (!is_array($row) || !MyronMcpOAuthTokenCodec::verify($code, (string) ($row['code_hash'] ?? ''))) {
                continue;
            }
            if (strtotime((string) ($row['expires_at'] ?? '')) < time()) {
                return null;
            }
            $this->pdo->prepare('UPDATE mcp_oauth_authorization_codes SET consumed_at = :consumed_at WHERE id = :id')
                ->execute([':id' => (string) $row['id'], ':consumed_at' => myronNow()]);
            return [
                'id' => (string) $row['id'],
                'clientId' => (string) $row['client_id'],
                'redirectUri' => (string) $row['redirect_uri'],
                'resource' => (string) $row['resource'],
                'scopes' => array_values(array_map('strval', (array) (json_decode((string) $row['scopes_json'], true) ?: []))),
                'codeChallenge' => (string) ($row['code_challenge'] ?? ''),
                'codeChallengeMethod' => (string) ($row['code_challenge_method'] ?? ''),
                'createdByUserId' => (string) ($row['created_by_user_id'] ?? ''),
            ];
        }

        return null;
    }

    /**
     * @param list<string> $scopes
     * @return array{accessToken: string, refreshToken: string, expiresIn: int, accessTokenId: string}
     */
    public function issueTokenPair(string $clientId, string $integrationId, string $resource, array $scopes): array
    {
        $access = MyronMcpOAuthTokenCodec::generate(MyronMcpOAuthTokenCodec::ACCESS_PREFIX);
        $refresh = MyronMcpOAuthTokenCodec::generate(MyronMcpOAuthTokenCodec::REFRESH_PREFIX);
        $now = myronNow();
        $scopeJson = json_encode($this->normalizeScopes($scopes), JSON_UNESCAPED_SLASHES);
        $accessId = myronGenerateId('moa');
        $refreshId = myronGenerateId('mor');
        $this->pdo->prepare(
            'INSERT INTO mcp_oauth_access_tokens (
                id, token_prefix, token_hash, client_id, integration_id, resource, scopes_json,
                created_at, expires_at, revoked_at, last_used_at
             ) VALUES (
                :id, :token_prefix, :token_hash, :client_id, :integration_id, :resource, :scopes_json,
                :created_at, :expires_at, NULL, NULL
             )'
        )->execute([
            ':id' => $accessId,
            ':token_prefix' => $access['prefix'],
            ':token_hash' => $access['hash'],
            ':client_id' => trim($clientId),
            ':integration_id' => trim($integrationId),
            ':resource' => trim($resource),
            ':scopes_json' => $scopeJson,
            ':created_at' => $now,
            ':expires_at' => date('Y-m-d H:i:s', time() + 3600),
        ]);
        $this->pdo->prepare(
            'INSERT INTO mcp_oauth_refresh_tokens (
                id, token_prefix, token_hash, client_id, integration_id, resource, scopes_json,
                created_at, expires_at, revoked_at
             ) VALUES (
                :id, :token_prefix, :token_hash, :client_id, :integration_id, :resource, :scopes_json,
                :created_at, :expires_at, NULL
             )'
        )->execute([
            ':id' => $refreshId,
            ':token_prefix' => $refresh['prefix'],
            ':token_hash' => $refresh['hash'],
            ':client_id' => trim($clientId),
            ':integration_id' => trim($integrationId),
            ':resource' => trim($resource),
            ':scopes_json' => $scopeJson,
            ':created_at' => $now,
            ':expires_at' => date('Y-m-d H:i:s', time() + 2592000),
        ]);

        return [
            'accessToken' => $access['token'],
            'refreshToken' => $refresh['token'],
            'expiresIn' => 3600,
            'accessTokenId' => $accessId,
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findValidAccessToken(string $token, string $resource): ?array
    {
        $prefix = MyronMcpOAuthTokenCodec::prefixFromToken($token, MyronMcpOAuthTokenCodec::ACCESS_PREFIX);
        if ($prefix === '') {
            return null;
        }
        $statement = $this->pdo->prepare('SELECT * FROM mcp_oauth_access_tokens WHERE token_prefix = :prefix AND revoked_at IS NULL');
        $statement->execute([':prefix' => $prefix]);
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
            if (!is_array($row) || !MyronMcpOAuthTokenCodec::verify($token, (string) ($row['token_hash'] ?? ''))) {
                continue;
            }
            if (strtotime((string) ($row['expires_at'] ?? '')) < time()) {
                return null;
            }
            if (!$this->resourceMatches($resource, (string) ($row['resource'] ?? ''))) {
                return null;
            }
            $this->pdo->prepare('UPDATE mcp_oauth_access_tokens SET last_used_at = :last_used_at WHERE id = :id')
                ->execute([':id' => (string) $row['id'], ':last_used_at' => myronNow()]);
            return [
                'id' => (string) $row['id'],
                'clientId' => (string) $row['client_id'],
                'integrationId' => (string) $row['integration_id'],
                'resource' => (string) $row['resource'],
                'scopes' => array_values(array_map('strval', (array) (json_decode((string) $row['scopes_json'], true) ?: []))),
            ];
        }

        return null;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function consumeRefreshToken(string $token, string $clientId, string $resource): ?array
    {
        $prefix = MyronMcpOAuthTokenCodec::prefixFromToken($token, MyronMcpOAuthTokenCodec::REFRESH_PREFIX);
        if ($prefix === '') {
            return null;
        }
        $statement = $this->pdo->prepare('SELECT * FROM mcp_oauth_refresh_tokens WHERE token_prefix = :prefix AND revoked_at IS NULL');
        $statement->execute([':prefix' => $prefix]);
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
            if (!is_array($row) || !MyronMcpOAuthTokenCodec::verify($token, (string) ($row['token_hash'] ?? ''))) {
                continue;
            }
            if ((string) ($row['client_id'] ?? '') !== $clientId) {
                return null;
            }
            if (!$this->resourceMatches($resource, (string) ($row['resource'] ?? ''))) {
                return null;
            }
            if (strtotime((string) ($row['expires_at'] ?? '')) < time()) {
                return null;
            }
            $this->pdo->prepare('UPDATE mcp_oauth_refresh_tokens SET revoked_at = :revoked_at WHERE id = :id')
                ->execute([':id' => (string) $row['id'], ':revoked_at' => myronNow()]);
            return [
                'clientId' => (string) $row['client_id'],
                'integrationId' => (string) $row['integration_id'],
                'resource' => (string) $row['resource'],
                'scopes' => array_values(array_map('strval', (array) (json_decode((string) $row['scopes_json'], true) ?: []))),
            ];
        }

        return null;
    }

    public function revokeIntegrationTokens(string $integrationId): void
    {
        foreach (['mcp_oauth_access_tokens', 'mcp_oauth_refresh_tokens'] as $table) {
            $statement = $this->pdo->prepare('UPDATE ' . $table . ' SET revoked_at = :revoked_at WHERE integration_id = :integration_id AND revoked_at IS NULL');
            $statement->execute([':integration_id' => trim($integrationId), ':revoked_at' => myronNow()]);
        }
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function listActiveGrantRows(): array
    {
        $statement = $this->pdo->query(
            'SELECT a.integration_id, a.client_id, c.client_name, MAX(a.last_used_at) AS last_used_at, MAX(a.expires_at) AS access_expires_at
             FROM mcp_oauth_access_tokens a
             LEFT JOIN mcp_oauth_clients c ON c.id = a.client_id
             WHERE a.revoked_at IS NULL
             GROUP BY a.integration_id, a.client_id, c.client_name
             ORDER BY MAX(a.created_at) DESC'
        );
        $rows = $statement ? $statement->fetchAll(PDO::FETCH_ASSOC) : [];
        return array_values(array_filter($rows ?: [], 'is_array'));
    }

    /**
     * @param list<string> $scopes
     * @return list<string>
     */
    public function normalizeScopes(array $scopes): array
    {
        $allowed = array_fill_keys(MyronApiScopes::ALL_BASE, true);
        $normalized = [];
        foreach ($scopes as $scope) {
            $scope = trim((string) $scope);
            if ($scope !== '' && isset($allowed[$scope])) {
                $normalized[$scope] = true;
            }
        }
        if ($normalized === []) {
            $normalized[MyronApiScopes::DESIGN_READ] = true;
        }
        return array_keys($normalized);
    }

    public function resourceMatches(string $actual, string $expected): bool
    {
        $actual = rtrim(strtolower(trim($actual)), '/');
        $expected = rtrim(strtolower(trim($expected)), '/');
        return $actual !== '' && $expected !== '' && $actual === $expected;
    }

    private function assertRedirectUriAllowed(string $uri): void
    {
        $parts = parse_url($uri);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));
        if ($scheme === 'https') {
            return;
        }
        if ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
            return;
        }
        throw new InvalidArgumentException('Redirect URI must use HTTPS or localhost HTTP.');
    }

    /**
     * @param array<string, mixed> $row
     * @return array<string, mixed>
     */
    private function hydrateClient(array $row): array
    {
        return [
            'id' => (string) ($row['id'] ?? ''),
            'clientName' => (string) ($row['client_name'] ?? ''),
            'redirectUris' => array_values(array_map('strval', (array) (json_decode((string) ($row['redirect_uris_json'] ?? '[]'), true) ?: []))),
            'scopes' => array_values(array_map('strval', (array) (json_decode((string) ($row['scopes_json'] ?? '[]'), true) ?: []))),
            'createdAt' => (string) ($row['created_at'] ?? ''),
            'revokedAt' => isset($row['revoked_at']) && $row['revoked_at'] !== null ? (string) $row['revoked_at'] : null,
        ];
    }

    private function tableSuffix(): string
    {
        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        return $driver === 'mysql' ? ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' : '';
    }

    private function run(string $sql): void
    {
        $this->pdo->exec($sql);
    }

    /**
     * @param list<string> $columns
     */
    private function addIndex(string $table, string $name, array $columns): void
    {
        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        try {
            $this->pdo->exec('CREATE INDEX ' . $name . ' ON ' . $table . '(' . implode(', ', $columns) . ')');
        } catch (PDOException $exception) {
            $message = strtolower($exception->getMessage());
            if (!str_contains($message, 'already exists') && !str_contains($message, 'duplicate key name')) {
                throw $exception;
            }
        }
    }
}
