"""Authorization-request, token-request, and PKCE wire-level invariants of the SDK's OAuth client.

Every test connects a real `Client` end to end via `connect_with_oauth`; the assertions are on
the parsed authorize URL and the recorded `/token` form body, because those wire shapes are what
the spec mandates and `Client` cannot observe them. The recording uses `record_requests`, which
snapshots each request at send time so the auth flow's in-place header mutation on retry never
affects what was captured for the first attempt.

Tests #1/#2/#4/#5 share one `recorded_oauth_flow` fixture (one connect, several disjoint
assertions on its recording); the others connect fresh because each needs a different harness
configuration.
"""

import base64
import hashlib
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from urllib.parse import parse_qsl, quote, urlsplit

import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from pydantic import AnyHttpUrl, AnyUrl

from mcp.client.auth import OAuthFlowError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
    REDIRECT_URI,
    HeadlessOAuth,
    InMemoryTokenStorage,
    RecordedRequest,
    auth_settings,
    connect_with_oauth,
    first_challenge_shim,
    record_requests,
    shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider

pytestmark = pytest.mark.anyio

PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
ASM_PATH = "/.well-known/oauth-authorization-server"


async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
    return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])


def authorize_params(authorize_url: str) -> dict[str, str]:
    """Parse the authorize URL's query string into a flat dict (one value per key)."""
    return dict(parse_qsl(urlsplit(authorize_url).query))


def form_body(request: RecordedRequest) -> dict[str, str]:
    """Parse an `application/x-www-form-urlencoded` request body into a flat dict."""
    return dict(parse_qsl(request.content.decode()))


def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
    """Filter recorded requests by method and exact path."""
    return [r for r in recorded if r.method == method and r.path == path]


@dataclass
class RecordedFlow:
    """One completed OAuth connect: every recorded request, plus the parsed authorize URL params."""

    requests: list[RecordedRequest]
    authorize_url: str

    @property
    def authorize(self) -> dict[str, str]:
        return authorize_params(self.authorize_url)

    @property
    def token_request(self) -> RecordedRequest:
        token_posts = find(self.requests, "POST", "/token")
        assert len(token_posts) == 1
        return token_posts[0]


@pytest.fixture
async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]:
    """Run one full OAuth connect with default configuration and yield its recorded wire traffic.

    `valid_scopes` includes `offline_access` so the AS metadata advertises it and the SDK's
    SEP-2207 auto-append (and the resulting `prompt=consent`) is exercised; `required_scopes`
    stays at `["mcp"]` so the issued token still passes the bearer middleware.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)
    settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "offline_access"])

    with anyio.fail_after(5):
        async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as (
            client,
            headless,
        ):
            await client.list_tools()

    assert headless.authorize_url is not None
    yield RecordedFlow(requests=recorded, authorize_url=headless.authorize_url)


@requirement("client-auth:pkce:s256")
@requirement("client-auth:resource-parameter")
@requirement("client-auth:authorize:offline-access-consent")
async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
    recorded_oauth_flow: RecordedFlow,
) -> None:
    """Every spec-mandated parameter appears on the authorize URL with the right value.

    The full key set is snapshotted so a parameter added or dropped fails the test. The
    `code_challenge` length bound is the RFC 7636 §4.2 grammar; an S256 challenge is in
    practice always 43 characters, so the upper bound is never approached.
    """
    params = recorded_oauth_flow.authorize

    assert sorted(params) == snapshot(
        [
            "client_id",
            "code_challenge",
            "code_challenge_method",
            "prompt",
            "redirect_uri",
            "resource",
            "response_type",
            "scope",
            "state",
        ]
    )
    assert params["response_type"] == "code"
    assert params["code_challenge_method"] == "S256"
    assert 43 <= len(params["code_challenge"]) <= 128
    # The exact resource value depends on canonical-URI normalisation (a spec ambiguity); pin
    # the stable prefix so the test does not lock in a trailing-slash decision.
    assert params["resource"].startswith(BASE_URL)
    assert params["state"] != ""

    assert params["scope"].split(" ") == snapshot(["mcp", "offline_access"])
    assert params["prompt"] == "consent"


@requirement("client-auth:pkce:s256")
async def test_the_code_verifier_on_the_token_request_hashes_to_the_code_challenge(
    recorded_oauth_flow: RecordedFlow,
) -> None:
    """The PKCE verifier sent on /token is the S256 pre-image of the challenge sent on /authorize.

    The verifier is also checked against RFC 7636 §4.1's length and `unreserved` charset.
    """
    challenge = recorded_oauth_flow.authorize["code_challenge"]
    verifier = form_body(recorded_oauth_flow.token_request)["code_verifier"]

    assert re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
    assert base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") == challenge


@requirement("client-auth:state:verify")
async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
    """A callback whose state does not match the value sent on /authorize raises and stops the flow.

    The auth flow runs inside the streamable-HTTP client's task group, so the `OAuthFlowError`
    reaches the test wrapped in nested single-element exception groups; `pytest.RaisesGroup`
    asserts the leaf type and the SDK-authored message prefix (the full message embeds two
    random tokens).
    """
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)
    headless = HeadlessOAuth(state_override="wrong-state")

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthFlowError, match="^State parameter mismatch:"), flatten_subgroups=True
        ):
            # Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
            # so an `async with` body would be unreachable; entering explicitly avoids dead code.
            await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()


@requirement("client-auth:authorization-response:iss-verify")
async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
    """A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.

    `iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
    compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
    """
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)
    headless = HeadlessOAuth(iss_override="https://attacker.example.com")

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
        ):
            await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()


@requirement("client-auth:resource-parameter")
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
    recorded_oauth_flow: RecordedFlow,
) -> None:
    """The /token form body has exactly the auth-code grant fields, with redirect_uri and resource matching /authorize.

    `client_secret` is present because the SDK's dynamic-registration handler issues a secret
    and the client defaults to `client_secret_post`.
    """
    token_req = recorded_oauth_flow.token_request
    body = form_body(token_req)

    assert sorted(body) == snapshot(
        ["client_id", "client_secret", "code", "code_verifier", "grant_type", "redirect_uri", "resource"]
    )
    assert body["grant_type"] == "authorization_code"
    assert body["code"] != ""
    assert body["redirect_uri"] == recorded_oauth_flow.authorize["redirect_uri"]
    assert body["resource"] == recorded_oauth_flow.authorize["resource"]
    assert token_req.headers["content-type"] == "application/x-www-form-urlencoded"


@requirement("client-auth:bearer-header:every-request")
async def test_every_mcp_request_after_auth_carries_the_bearer_header_and_never_a_query_token(
    recorded_oauth_flow: RecordedFlow,
) -> None:
    """Every MCP request after the flow has `Authorization: Bearer ...` and never `?access_token=`.

    The first /mcp POST is the unauthenticated trigger and is asserted to carry no Authorization
    header; that assertion is only meaningful because the recording snapshots requests at send
    time (the SDK mutates the same request object in place for the retry).
    """
    mcp_posts = find(recorded_oauth_flow.requests, "POST", "/mcp")
    assert len(mcp_posts) >= 3

    assert "authorization" not in mcp_posts[0].headers
    for r in mcp_posts[1:]:
        assert r.headers["authorization"].startswith("Bearer ")
        assert r.headers["authorization"] != "Bearer "
        assert "access_token" not in dict(r.url.params)


@requirement("client-auth:token-endpoint-auth-method")
async def test_a_client_with_a_secret_authenticates_the_token_request_with_http_basic() -> None:
    """A `client_secret_basic` client sends URL-encoded credentials in HTTP Basic, not the body.

    Credentials are URL-encoded before base64 per RFC 6749 §2.3.1; the secret contains `/` so
    the encoding is observable.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    client_info = OAuthClientInformationFull(
        client_id="cid",
        client_secret="s/cret",
        token_endpoint_auth_method="client_secret_basic",
        redirect_uris=[AnyUrl(REDIRECT_URI)],
        grant_types=["authorization_code", "refresh_token"],
        scope="mcp",
    )
    await provider.register_client(client_info)
    storage = InMemoryTokenStorage(client_info=client_info)

    with anyio.fail_after(5):
        async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
            await client.list_tools()

    assert find(recorded, "POST", "/register") == []
    [token_req] = find(recorded, "POST", "/token")

    decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
    assert decoded == f"{quote('cid', safe='')}:{quote('s/cret', safe='')}"
    assert "client_secret" not in form_body(token_req)


@requirement("client-auth:token-endpoint-auth-method")
async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_advertised_methods() -> None:
    """The token-endpoint auth method comes from the registered client info, not from AS metadata.

    The shim serves AS metadata advertising only `client_secret_basic`; the client dynamically
    registers and the SDK's registration handler issues `client_secret_post`. The client uses
    `client_secret_post` (secret in the body, no Basic header) because the SDK reads the
    registered `token_endpoint_auth_method`, not `token_endpoint_auth_methods_supported`. Other
    SDKs (TypeScript, Go) do consult the AS metadata; this test pins where the python SDK's
    selection point lives.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    override = OAuthMetadata(
        issuer=AnyHttpUrl(f"{BASE_URL}/"),
        authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
        token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
        registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
        scopes_supported=["mcp"],
        grant_types_supported=["authorization_code", "refresh_token"],
        code_challenge_methods_supported=["S256"],
        token_endpoint_auth_methods_supported=["client_secret_basic"],
    )
    serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}

    with anyio.fail_after(5):
        async with connect_with_oauth(
            server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve), on_request=on_request
        ) as (client, _):
            await client.list_tools()

    [register] = find(recorded, "POST", "/register")
    assert json.loads(register.content).get("token_endpoint_auth_method") is None

    [token_req] = find(recorded, "POST", "/token")
    body = form_body(token_req)
    assert "client_secret" in body
    assert body["client_secret"] != ""
    assert "authorization" not in token_req.headers


@requirement("client-auth:scope-selection:priority")
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
    """When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.

    The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
    on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
    `first_challenge_shim` and disables token verification so the post-auth retry succeeds
    regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
    `required_scopes`); the challenge says `from-header`; the authorize URL must carry
    `from-header`.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])
    server = Server("guarded", on_list_tools=list_tools)
    settings = auth_settings(required_scopes=["from-prm"], valid_scopes=["from-header", "from-prm"])
    challenge = f'Bearer scope="from-header", resource_metadata="{BASE_URL}{PRM_PATH}"'

    with anyio.fail_after(5):
        async with connect_with_oauth(
            server,
            provider=provider,
            settings=settings,
            verify_tokens=False,
            app_shim=first_challenge_shim(challenge),
            on_request=on_request,
        ) as (client, headless):
            await client.list_tools()

    assert headless.authorize_url is not None
    assert authorize_params(headless.authorize_url)["scope"] == "from-header"

    [register] = find(recorded, "POST", "/register")
    assert json.loads(register.content)["scope"] == "from-header"


@requirement("client-auth:pkce:refuse-if-unsupported")
async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_supported() -> None:
    """AS metadata without `code_challenge_methods_supported` does not stop the client sending PKCE.

    The spec says the client MUST refuse to proceed in this case; the SDK proceeds and the flow
    completes. See the divergence on the requirement.
    """
    override = OAuthMetadata(
        issuer=AnyHttpUrl(f"{BASE_URL}/"),
        authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
        token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
        registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
        scopes_supported=["mcp"],
        grant_types_supported=["authorization_code", "refresh_token"],
    )
    assert override.code_challenge_methods_supported is None
    serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}

    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    with anyio.fail_after(5):
        async with connect_with_oauth(
            server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve)
        ) as (client, headless):
            result = await client.list_tools()

    assert headless.authorize_url is not None
    params = authorize_params(headless.authorize_url)
    assert params["code_challenge_method"] == "S256"
    assert params["code_challenge"] != ""
    assert result.tools[0].name == "echo"


@requirement("client-auth:authorize:error-surfaces")
async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_token_request() -> None:
    """An `error=` redirect from /authorize aborts the flow with no /token request issued.

    The SDK's callback contract is `() -> (code, state)` with no error form, so the failure is
    observed as an empty code reaching the SDK and `OAuthFlowError("No authorization code
    received")` being raised. The actual `error` value from the redirect is not surfaced to the
    caller; that gap is noted in the manifest.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider(deny_authorize=True)
    server = Server("guarded", on_list_tools=list_tools)
    headless = HeadlessOAuth()

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthFlowError, match="^No authorization code received$"), flatten_subgroups=True
        ):
            await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__()

    assert headless.error == "access_denied"
    assert find(recorded, "POST", "/token") == []
