from __future__ import annotations

import asyncio
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from typing import cast

import jwt
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import TypeAdapter
from pydantic import ValidationError

from amsdal_ml.mcp_server.mcp_oauth import oauth_router
from amsdal_ml.mcp_server.mcp_oauth.routes.token import token as token_route
from amsdal_ml.mcp_server.mcp_oauth.services.errors import OAuthBadRequestError
from amsdal_ml.mcp_server.mcp_oauth.services.models import AuthorizationCodeFormData
from amsdal_ml.mcp_server.mcp_oauth.services.models import RefreshTokenFormData
from amsdal_ml.mcp_server.mcp_oauth.services.models import TokenFromData
from amsdal_ml.mcp_server.mcp_oauth.services.types import JWT_ALGORITHM
from amsdal_ml.mcp_server.mcp_oauth.services.types import GrantTypes
from amsdal_ml.mcp_server.mcp_oauth.services.types import JwtClaims
from amsdal_ml.mcp_server.mcp_oauth.services.types import OAuthConstants
from amsdal_ml.mcp_server.mcp_oauth.services.utils import build_refresh_payload
from amsdal_ml.mcp_server.mcp_oauth.services.utils import pkce_s256
from amsdal_ml.mcp_server.mcp_oauth.services.utils import store_authorization_code_jti
from amsdal_ml.ml_config import ml_config

HTTP_NOT_FOUND = 404


def call_token(**kwargs):
    grant_type = kwargs.get('grant_type')
    token_data: TokenFromData
    if grant_type == GrantTypes.AUTHORIZATION_CODE:
        token_data = AuthorizationCodeFormData(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=kwargs.get('code'),
            code_verifier=kwargs.get('code_verifier'),
            redirect_uri=kwargs.get('redirect_uri'),
            client_id=kwargs.get('client_id'),
            resource=kwargs.get('resource'),
        )
    elif grant_type == GrantTypes.REFRESH_TOKEN:
        token_data = RefreshTokenFormData(
            grant_type=GrantTypes.REFRESH_TOKEN,
            refresh_token=kwargs.get('refresh_token'),
            client_id=kwargs.get('client_id'),
            resource=kwargs.get('resource'),
        )
    else:
        token_data = TypeAdapter(TokenFromData).validate_python({'grant_type': grant_type})

    return asyncio.run(token_route(token_data=token_data))


token = call_token


def _decode_token(token_value: str, signing_key: str) -> dict[str, str | int | None]:
    return jwt.decode(
        token_value,
        signing_key,
        algorithms=[JWT_ALGORITHM],
        options={'verify_aud': False},
    )


def _build_code_token(
    *,
    signing_key: str,
    client_id: str = 'client-123',
    redirect_uri: str = 'https://chatgpt.com/connector_platform_oauth_redirect',
    resource: str = 'https://mcp.example.com',
    code_verifier: str = 'verifier-123',
    sub: str = 'user-123',
    issuer: str | None = None,
    code_challenge_method: str = OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
    code_challenge: str | None = None,
    jti: str = 'code-jti-generic',
) -> str:
    now = datetime.now(tz=UTC)
    store_authorization_code_jti(jti, int((now + timedelta(minutes=10)).timestamp()))
    payload = {
        JwtClaims.ISSUER.value: issuer or ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: client_id,
        JwtClaims.REDIRECT_URI.value: redirect_uri,
        JwtClaims.RESOURCE.value: resource,
        JwtClaims.CODE_CHALLENGE.value: code_challenge or pkce_s256(code_verifier),
        JwtClaims.CODE_CHALLENGE_METHOD.value: code_challenge_method,
        JwtClaims.SUBJECT.value: sub,
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.EXPIRATION.value: int((now + timedelta(minutes=10)).timestamp()),
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.JWT_ID.value: jti,
    }
    return jwt.encode(payload, signing_key, algorithm=JWT_ALGORITHM)


def test_token_authorization_code_happy_path(oauth_signing_key):
    now = datetime.now(tz=UTC)
    code_verifier = 'verifier-123'
    client_id = 'client-123'
    redirect_uri = 'https://chatgpt.com/connector_platform_oauth_redirect'
    resource = 'https://mcp.example.com'
    jti = 'code-jti-1'
    store_authorization_code_jti(jti, int((now + timedelta(minutes=10)).timestamp()))

    code_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: client_id,
        JwtClaims.REDIRECT_URI.value: redirect_uri,
        JwtClaims.RESOURCE.value: resource,
        JwtClaims.CODE_CHALLENGE.value: pkce_s256(code_verifier),
        JwtClaims.CODE_CHALLENGE_METHOD.value: OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.EXPIRATION.value: int((now + timedelta(minutes=10)).timestamp()),
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.JWT_ID.value: jti,
    }
    code = jwt.encode(code_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    response = call_token(
        grant_type=GrantTypes.AUTHORIZATION_CODE,
        code=code,
        code_verifier=code_verifier,
        redirect_uri=redirect_uri,
        client_id=client_id,
        resource=resource,
        refresh_token=None,
    )

    access_payload = _decode_token(response.access_token, oauth_signing_key)
    refresh_payload = _decode_token(response.refresh_token, oauth_signing_key)

    assert response.token_type == OAuthConstants.TOKEN_TYPE_BEARER.value
    assert response.scope == 'files:read'
    assert access_payload[JwtClaims.SUBJECT.value] == 'user-123'
    assert access_payload[JwtClaims.AUDIENCE.value] == resource
    assert access_payload[JwtClaims.TOKEN_TYPE.value] == OAuthConstants.TOKEN_TYPE_ACCESS.value
    assert refresh_payload[JwtClaims.CLIENT_ID.value] == client_id
    assert refresh_payload[JwtClaims.TOKEN_TYPE.value] == OAuthConstants.TOKEN_TYPE_REFRESH.value


def test_token_refresh_happy_path(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = build_refresh_payload(
        sub='user-123',
        client_id='client-123',
        resource='https://mcp.example.com',
        scope='files:read',
        now=now,
    )
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    response = call_token(
        grant_type=GrantTypes.REFRESH_TOKEN,
        code=None,
        code_verifier=None,
        redirect_uri=None,
        client_id='client-123',
        resource='https://mcp.example.com',
        refresh_token=refresh_token,
    )

    access_payload = _decode_token(response.access_token, oauth_signing_key)
    rotated_refresh_payload = _decode_token(response.refresh_token, oauth_signing_key)

    assert access_payload[JwtClaims.SUBJECT.value] == 'user-123'
    assert access_payload[JwtClaims.TOKEN_TYPE.value] == OAuthConstants.TOKEN_TYPE_ACCESS.value
    assert rotated_refresh_payload[JwtClaims.CLIENT_ID.value] == 'client-123'
    assert rotated_refresh_payload[JwtClaims.TOKEN_TYPE.value] == OAuthConstants.TOKEN_TYPE_REFRESH.value


def test_token_refresh_rejects_missing_client_id_in_refresh_token(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.EXPIRATION.value: int((now + timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-missing-client',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_REFRESH.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='client_id mismatch'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_raises_for_unsupported_grant_type(oauth_signing_key):  # noqa: ARG001
    with pytest.raises(ValidationError, match='grant_type'):
        token(
            grant_type=cast(GrantTypes, 'client_credentials'),
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource=None,
            refresh_token=None,
        )


def test_token_authorization_code_requires_all_fields(oauth_signing_key):  # noqa: ARG001
    with pytest.raises(ValidationError, match='code'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=None,
            code_verifier='verifier',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_invalid_code(oauth_signing_key):  # noqa: ARG001
    with pytest.raises(OAuthBadRequestError, match='invalid authorization code'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code='not-a-jwt',
            code_verifier='verifier',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_redirect_mismatch(oauth_signing_key):
    now = datetime.now(tz=UTC)
    code_verifier = 'verifier-123'
    jti = 'code-jti-redirect-mismatch'
    store_authorization_code_jti(jti, int((now + timedelta(minutes=10)).timestamp()))
    code_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.REDIRECT_URI.value: 'https://expected.example/callback',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.CODE_CHALLENGE.value: pkce_s256(code_verifier),
        JwtClaims.CODE_CHALLENGE_METHOD.value: OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.EXPIRATION.value: int((now + timedelta(minutes=10)).timestamp()),
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.JWT_ID.value: jti,
    }
    code = jwt.encode(code_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='redirect_uri mismatch'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier=code_verifier,
            redirect_uri='https://other.example/callback',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_refresh_rejects_invalid_token_type(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.AUDIENCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.EXPIRATION.value: int((now + timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-invalid-type',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_ACCESS.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='invalid token type'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_refresh_rejects_resource_mismatch(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = build_refresh_payload(
        sub='user-123',
        client_id='client-123',
        resource='https://mcp.example.com',
        scope='files:read',
        now=now,
    )
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='resource mismatch'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://other-mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_refresh_allows_trailing_slash_resource_match(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = build_refresh_payload(
        sub='user-123',
        client_id='client-123',
        resource='https://mcp.example.com/',
        scope='files:read',
        now=now,
    )
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    response = token(
        grant_type=GrantTypes.REFRESH_TOKEN,
        code=None,
        code_verifier=None,
        redirect_uri=None,
        client_id='client-123',
        resource='https://mcp.example.com',
        refresh_token=refresh_token,
    )

    assert response.token_type == OAuthConstants.TOKEN_TYPE_BEARER.value


def test_token_raises_when_oauth_disabled(monkeypatch):
    monkeypatch.setattr(ml_config, 'oauth_enabled', False)
    app = FastAPI()
    app.include_router(oauth_router)
    response = TestClient(app).post(
        '/oauth/token',
        data={
            'grant_type': GrantTypes.REFRESH_TOKEN.value,
            'client_id': 'client-123',
            'resource': 'https://mcp.example.com',
            'refresh_token': 'x',
        },
    )

    assert response.status_code == HTTP_NOT_FOUND
    assert response.json()['detail'] == 'OAuth disabled'


def test_token_refresh_requires_token_param(oauth_signing_key):  # noqa: ARG001
    with pytest.raises(ValidationError, match='refresh_token'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_refresh_rejects_invalid_refresh_token(oauth_signing_key):  # noqa: ARG001
    with pytest.raises(OAuthBadRequestError, match='invalid refresh token'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token='bad-token',
        )


def test_token_authorization_code_rejects_expired_code(oauth_signing_key):
    now = datetime.now(tz=UTC)
    code_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.REDIRECT_URI.value: 'https://chatgpt.com/connector_platform_oauth_redirect',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.CODE_CHALLENGE.value: pkce_s256('verifier-123'),
        JwtClaims.CODE_CHALLENGE_METHOD.value: OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.EXPIRATION.value: int((now - timedelta(minutes=1)).timestamp()),
        JwtClaims.ISSUED_AT.value: int((now - timedelta(minutes=2)).timestamp()),
        JwtClaims.JWT_ID.value: 'code-jti-expired',
    }
    code = jwt.encode(code_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='authorization code expired'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_invalid_code_issuer(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        issuer='https://wrong-issuer.example',
        jti='code-jti-issuer',
    )

    with pytest.raises(OAuthBadRequestError, match='invalid code issuer'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_client_id_mismatch(oauth_signing_key):
    code = _build_code_token(signing_key=oauth_signing_key, client_id='client-in-code', jti='code-jti-client')

    with pytest.raises(OAuthBadRequestError, match='client_id mismatch'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-in-request',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_resource_mismatch(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        resource='https://token-resource.example',
        jti='code-jti-resource',
    )

    with pytest.raises(OAuthBadRequestError, match='resource mismatch'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://request-resource.example',
            refresh_token=None,
        )


def test_token_authorization_code_allows_missing_resource_and_uses_code_resource(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        resource='https://mcp.example.com',
        jti='code-jti-no-resource-in-request',
    )

    response = token(
        grant_type=GrantTypes.AUTHORIZATION_CODE,
        code=code,
        code_verifier='verifier-123',
        redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
        client_id='client-123',
        resource=None,
        refresh_token=None,
    )

    access_payload = _decode_token(response.access_token, oauth_signing_key)
    assert access_payload[JwtClaims.AUDIENCE.value] == 'https://mcp.example.com'


def test_token_authorization_code_allows_trailing_slash_resource_match(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        resource='https://mcp.example.com/',
        jti='code-jti-resource-trailing-slash',
    )

    response = token(
        grant_type=GrantTypes.AUTHORIZATION_CODE,
        code=code,
        code_verifier='verifier-123',
        redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
        client_id='client-123',
        resource='https://mcp.example.com',
        refresh_token=None,
    )

    assert response.token_type == OAuthConstants.TOKEN_TYPE_BEARER.value


def test_token_authorization_code_rejects_unsupported_challenge_method(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        code_challenge_method='plain',
        jti='code-jti-challenge-method',
    )

    with pytest.raises(OAuthBadRequestError, match='unsupported code_challenge_method'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_invalid_code_verifier(oauth_signing_key):
    code = _build_code_token(
        signing_key=oauth_signing_key,
        code_challenge=pkce_s256('correct-verifier'),
        jti='code-jti-verifier',
    )

    with pytest.raises(OAuthBadRequestError, match='invalid code_verifier'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='wrong-verifier',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_reused_or_unknown_jti(oauth_signing_key):
    now = datetime.now(tz=UTC)
    code_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.REDIRECT_URI.value: 'https://chatgpt.com/connector_platform_oauth_redirect',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.CODE_CHALLENGE.value: pkce_s256('verifier-123'),
        JwtClaims.CODE_CHALLENGE_METHOD.value: OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.EXPIRATION.value: int((now + timedelta(minutes=10)).timestamp()),
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.JWT_ID.value: 'unknown-jti',
    }
    code = jwt.encode(code_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='authorization code already used or unknown'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_authorization_code_rejects_invalid_subject(oauth_signing_key):
    code = _build_code_token(signing_key=oauth_signing_key, sub='', jti='code-jti-subject')

    with pytest.raises(OAuthBadRequestError, match='invalid code subject'):
        token(
            grant_type=GrantTypes.AUTHORIZATION_CODE,
            code=code,
            code_verifier='verifier-123',
            redirect_uri='https://chatgpt.com/connector_platform_oauth_redirect',
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=None,
        )


def test_token_refresh_rejects_expired_token(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.AUDIENCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.ISSUED_AT.value: int((now - timedelta(days=2)).timestamp()),
        JwtClaims.EXPIRATION.value: int((now - timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-expired',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_REFRESH.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='refresh token expired'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_refresh_rejects_invalid_issuer(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: 'https://wrong-issuer.example',
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.AUDIENCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.EXPIRATION.value: int((now + timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-issuer',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_REFRESH.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='invalid refresh issuer'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_refresh_rejects_client_id_mismatch(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = build_refresh_payload(
        sub='user-123',
        client_id='client-in-token',
        resource='https://mcp.example.com',
        scope='files:read',
        now=now,
    )
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='client_id mismatch'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-in-request',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_refresh_rejects_invalid_subject(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.SUBJECT.value: '',
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.AUDIENCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: 'files:read',
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.EXPIRATION.value: int((now + timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-subject',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_REFRESH.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    with pytest.raises(OAuthBadRequestError, match='invalid refresh subject'):
        token(
            grant_type=GrantTypes.REFRESH_TOKEN,
            code=None,
            code_verifier=None,
            redirect_uri=None,
            client_id='client-123',
            resource='https://mcp.example.com',
            refresh_token=refresh_token,
        )


def test_token_authorization_code_sets_scope_none_for_non_string_scope(oauth_signing_key):
    now = datetime.now(tz=UTC)
    code_verifier = 'verifier-123'
    client_id = 'client-123'
    redirect_uri = 'https://chatgpt.com/connector_platform_oauth_redirect'
    resource = 'https://mcp.example.com'
    jti = 'code-jti-non-string-scope'
    store_authorization_code_jti(jti, int((now + timedelta(minutes=10)).timestamp()))

    code_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.CLIENT_ID.value: client_id,
        JwtClaims.REDIRECT_URI.value: redirect_uri,
        JwtClaims.RESOURCE.value: resource,
        JwtClaims.CODE_CHALLENGE.value: pkce_s256(code_verifier),
        JwtClaims.CODE_CHALLENGE_METHOD.value: OAuthConstants.CODE_CHALLENGE_METHOD_S256.value,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.SCOPE.value: ['files:read'],
        JwtClaims.EXPIRATION.value: int((now + timedelta(minutes=10)).timestamp()),
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.JWT_ID.value: jti,
    }
    code = jwt.encode(code_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    response = token(
        grant_type=GrantTypes.AUTHORIZATION_CODE,
        code=code,
        code_verifier=code_verifier,
        redirect_uri=redirect_uri,
        client_id=client_id,
        resource=resource,
        refresh_token=None,
    )

    assert response.scope is None


def test_token_refresh_sets_scope_none_for_non_string_scope(oauth_signing_key):
    now = datetime.now(tz=UTC)
    refresh_payload = {
        JwtClaims.ISSUER.value: ml_config.oauth_issuer,
        JwtClaims.SUBJECT.value: 'user-123',
        JwtClaims.CLIENT_ID.value: 'client-123',
        JwtClaims.RESOURCE.value: 'https://mcp.example.com',
        JwtClaims.AUDIENCE.value: 'https://mcp.example.com',
        JwtClaims.SCOPE.value: ['files:read'],
        JwtClaims.ISSUED_AT.value: int(now.timestamp()),
        JwtClaims.EXPIRATION.value: int((now + timedelta(days=1)).timestamp()),
        JwtClaims.JWT_ID.value: 'refresh-jti-non-string-scope',
        JwtClaims.TOKEN_TYPE.value: OAuthConstants.TOKEN_TYPE_REFRESH.value,
    }
    refresh_token = jwt.encode(refresh_payload, oauth_signing_key, algorithm=JWT_ALGORITHM)

    response = token(
        grant_type=GrantTypes.REFRESH_TOKEN,
        code=None,
        code_verifier=None,
        redirect_uri=None,
        client_id='client-123',
        resource='https://mcp.example.com',
        refresh_token=refresh_token,
    )

    assert response.scope is None
