"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.

The provider's authorization server is configuration; these tests assert that authorization-server
metadata is fetched only from the configured issuer, that the resource server is never consulted for
AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint.
"""

import base64
import json
import urllib.parse

import httpx
import pytest

from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken

ISSUER = "https://auth.example.com"
RS = "https://mcp.example.com"
ASM_PATH = "/.well-known/oauth-authorization-server"
OIDC_PATH = "/.well-known/openid-configuration"


class InMemoryStorage:
    def __init__(self, tokens: OAuthToken | None = None) -> None:
        self.tokens = tokens

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        raise NotImplementedError

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        raise NotImplementedError


def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes:
    return json.dumps(
        {
            "issuer": issuer,
            "authorization_endpoint": f"{issuer}/authorize",
            "token_endpoint": token_endpoint or f"{issuer}/token",
        }
    ).encode()


def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes:
    payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}
    if scope is not None:
        payload["scope"] = scope
    return json.dumps(payload).encode()


def make_provider(
    storage: InMemoryStorage | None = None,
    *,
    scope: str | None = "mcp",
    token_endpoint_auth_method: str = "client_secret_post",
    record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
    async def assertion_provider(audience: str, resource: str) -> str:
        if record is not None:
            record.append((audience, resource))
        return "the-id-jag"

    return IdentityAssertionOAuthProvider(
        server_url=f"{RS}/mcp",
        storage=storage if storage is not None else InMemoryStorage(),
        client_id="test-client-id",
        client_secret="test-client-secret",
        issuer=ISSUER,
        assertion_provider=assertion_provider,
        scope=scope,
        token_endpoint_auth_method=token_endpoint_auth_method,  # type: ignore[arg-type]
    )


def mock_transport(
    requests: list[httpx.Request],
    *,
    asm: bytes | int = 200,
    token: bytes | int = 200,
    rs_first_status: int = 401,
    rs_first_headers: dict[str, str] | None = None,
) -> httpx.MockTransport:
    """Build a `MockTransport` that records every request and serves the configured ASM and token.

    `asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
    The MCP resource server's first response is `rs_first_status` (default 401) with optional
    headers; subsequent RS requests return 200.
    """
    rs_hits = 0

    def handle(request: httpx.Request) -> httpx.Response:
        nonlocal rs_hits
        requests.append(request)
        host, path = request.url.host, request.url.path
        if host == "mcp.example.com":
            rs_hits += 1
            if rs_hits == 1:
                return httpx.Response(rs_first_status, headers=rs_first_headers or {})
            return httpx.Response(200, json={"ok": True})
        if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
            if isinstance(asm, int):
                return httpx.Response(asm)
            return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
        if host == "auth.example.com" and path == "/token":
            if isinstance(token, int):
                return httpx.Response(token, json={"error": "invalid_grant"})
            return httpx.Response(200, content=token, headers={"content-type": "application/json"})
        raise AssertionError(f"unexpected request: {request.method} {request.url}")  # pragma: no cover

    return httpx.MockTransport(handle)


def form(request: httpx.Request) -> dict[str, str]:
    return dict(urllib.parse.parse_qsl(request.content.decode()))


@pytest.mark.anyio
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
    """A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    storage = InMemoryStorage()
    auth = make_provider(storage, record=record)

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
    ) as http:
        response = await http.post(f"{RS}/mcp")

    assert [(r.method, str(r.url)) for r in requests] == [
        ("POST", f"{RS}/mcp"),
        ("GET", f"{ISSUER}{ASM_PATH}"),
        ("POST", f"{ISSUER}/token"),
        ("POST", f"{RS}/mcp"),
    ]
    body = form(requests[2])
    assert body == {
        "grant_type": JWT_BEARER_GRANT_TYPE,
        "assertion": "the-id-jag",
        "client_id": "test-client-id",
        "resource": f"{RS}/mcp",
        "scope": "mcp",
        "client_secret": "test-client-secret",
    }
    assert "Authorization" not in requests[2].headers
    assert record == [(ISSUER, f"{RS}/mcp")]
    assert response.status_code == 200
    assert storage.tokens is not None
    assert storage.tokens.access_token == "issued-token"
    assert storage.tokens.scope == "mcp"


@pytest.mark.anyio
async def test_resource_server_metadata_is_never_consulted() -> None:
    """No PRM well-known and no RS-origin ASM well-known is ever fetched.

    This is the by-construction property: the AS is configuration, so the resource server has no
    input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
    """
    requests: list[httpx.Request] = []
    auth = make_provider()

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
    ) as http:
        await http.post(f"{RS}/mcp")

    rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"]
    assert rs_gets == []
    assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET")
    # No DCR was attempted anywhere.
    assert not any(r.url.path == "/register" for r in requests)


@pytest.mark.anyio
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
    """If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    auth = make_provider(record=record)

    async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
        with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
            await http.post(f"{RS}/mcp")

    # Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else.
    assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"]
    assert record == []
    assert not any(r.url.path == "/token" for r in requests)


@pytest.mark.anyio
async def test_asm_5xx_stops_discovery_and_raises() -> None:
    """A 5xx at the issuer's well-known stops discovery without trying further URLs."""
    requests: list[httpx.Request] = []
    auth = make_provider()

    async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
        with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
            await http.post(f"{RS}/mcp")

    assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"]


@pytest.mark.anyio
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
    """RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    auth = make_provider(record=record)

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
    ) as http:
        with pytest.raises(OAuthFlowError, match="issuer mismatch"):
            await http.post(f"{RS}/mcp")

    assert record == []
    assert not any(r.url.path == "/token" for r in requests)


@pytest.mark.anyio
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
    """A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    auth = make_provider(record=record)

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
    ) as http:
        with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
            await http.post(f"{RS}/mcp")

    assert record == []
    assert not any(r.url.path == "/token" for r in requests)


@pytest.mark.anyio
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
    """A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
    requests: list[httpx.Request] = []
    auth = make_provider(scope="mcp")

    transport = mock_transport(
        requests,
        asm=asm_body(),
        token=token_body(),
        rs_first_status=403,
        rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
    )
    async with httpx.AsyncClient(transport=transport, auth=auth) as http:
        response = await http.post(f"{RS}/mcp")

    [token_req] = [r for r in requests if r.url.path == "/token"]
    assert form(token_req)["scope"] == "mcp files:write"
    assert response.status_code == 200


@pytest.mark.anyio
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
    """A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    auth = make_provider(record=record)

    transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
    async with httpx.AsyncClient(transport=transport, auth=auth) as http:
        response = await http.post(f"{RS}/mcp")

    assert response.status_code == 403
    assert record == []
    assert [str(r.url) for r in requests] == [f"{RS}/mcp"]


@pytest.mark.anyio
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
    requests: list[httpx.Request] = []
    auth = make_provider()

    async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
        with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
            await http.post(f"{RS}/mcp")


@pytest.mark.anyio
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
    requests: list[httpx.Request] = []
    auth = make_provider(token_endpoint_auth_method="client_secret_basic")

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
    ) as http:
        await http.post(f"{RS}/mcp")

    [token_req] = [r for r in requests if r.url.path == "/token"]
    assert "client_secret" not in form(token_req)
    decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
    assert decoded == "test-client-id:test-client-secret"


@pytest.mark.anyio
async def test_stored_token_is_reused_without_reauthorizing() -> None:
    """A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
    requests: list[httpx.Request] = []
    storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
    auth = make_provider(storage)

    transport = mock_transport(requests, rs_first_status=200)
    async with httpx.AsyncClient(transport=transport, auth=auth) as http:
        response = await http.post(f"{RS}/mcp")

    assert response.status_code == 200
    assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
    assert requests[0].headers["Authorization"] == "Bearer cached"


@pytest.mark.anyio
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
    """ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
    requests: list[httpx.Request] = []
    record: list[tuple[str, str]] = []
    auth = make_provider(record=record)
    rs_hits = 0

    def handle(request: httpx.Request) -> httpx.Response:
        nonlocal rs_hits
        requests.append(request)
        host, path = request.url.host, request.url.path
        if host == "mcp.example.com":
            rs_hits += 1
            # First and third RS hits draw a 401; second and fourth succeed.
            return httpx.Response(401 if rs_hits in (1, 3) else 200)
        if host == "auth.example.com" and path == ASM_PATH:
            return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
        assert host == "auth.example.com" and path == "/token"
        return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})

    async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
        await http.post(f"{RS}/mcp")
        await http.post(f"{RS}/mcp")

    asm_gets = [r for r in requests if r.url.path == ASM_PATH]
    token_posts = [r for r in requests if r.url.path == "/token"]
    assert len(asm_gets) == 1
    assert len(token_posts) == 2
    assert len(record) == 2


@pytest.mark.anyio
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
    """With no configured scope and no scope in the token response, the stored token records None."""
    requests: list[httpx.Request] = []
    storage = InMemoryStorage()
    auth = make_provider(storage, scope=None)

    async with httpx.AsyncClient(
        transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
    ) as http:
        await http.post(f"{RS}/mcp")

    [token_req] = [r for r in requests if r.url.path == "/token"]
    assert "scope" not in form(token_req)
    assert storage.tokens is not None
    assert storage.tokens.scope is None


def test_empty_client_secret_is_rejected() -> None:
    async def assertion_provider(audience: str, resource: str) -> str:
        raise NotImplementedError

    with pytest.raises(ValueError, match="client_secret is required"):
        IdentityAssertionOAuthProvider(
            server_url=f"{RS}/mcp",
            storage=InMemoryStorage(),
            client_id="c",
            client_secret="",
            issuer=ISSUER,
            assertion_provider=assertion_provider,
        )


def test_empty_issuer_is_rejected() -> None:
    async def assertion_provider(audience: str, resource: str) -> str:
        raise NotImplementedError

    with pytest.raises(ValueError, match="issuer is required"):
        IdentityAssertionOAuthProvider(
            server_url=f"{RS}/mcp",
            storage=InMemoryStorage(),
            client_id="c",
            client_secret="s",
            issuer="",
            assertion_provider=assertion_provider,
        )


def test_origin_normalizes_default_ports() -> None:
    """`_origin` treats an explicit scheme-default port as equal to the port-less form."""
    assert _origin("https://host") == _origin("https://host:443")
    assert _origin("http://host") == _origin("http://host:80")
    assert _origin("https://host") != _origin("https://host:8443")
    assert _origin("https://host") != _origin("https://other")
