"""Protected-resource and authorization-server metadata discovery, end to end.

Every client-side test connects a real `Client` via `connect_with_oauth` and asserts on the
recorded request paths the discovery probes produced; the discovery URL ordering is a wire
detail `Client` cannot observe directly but the recording can. Tests that need a metadata
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.

The two server-side tests (#5, #6) drive raw httpx against `mounted_app` because their
assertions are the metadata response bodies and headers, which `Client` does not surface.
"""

import json

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

from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
    RecordedRequest,
    auth_settings,
    connect_with_oauth,
    metadata_body,
    record_requests,
    shim,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider

pytestmark = pytest.mark.anyio

PRM_PATH_SUFFIXED = "/.well-known/oauth-protected-resource/mcp"
PRM_ROOT = "/.well-known/oauth-protected-resource"
ASM_ROOT = "/.well-known/oauth-authorization-server"
OIDC_ROOT = "/.well-known/openid-configuration"


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


def discovery_gets(recorded: list[RecordedRequest]) -> list[str]:
    """Return the well-known GET paths in recorded order, ignoring everything else."""
    return [r.path for r in recorded if r.method == "GET" and "/.well-known/" in r.path]


def real_asm() -> OAuthMetadata:
    """Build an authorization-server metadata document pointing at the real co-hosted endpoints."""
    return OAuthMetadata(
        issuer=AnyHttpUrl(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"],
    )


@requirement("client-auth:prm-discovery:fallback-order")
async def test_prm_discovery_uses_the_resource_metadata_url_from_www_authenticate() -> None:
    """The first protected-resource probe is the URL the 401's `WWW-Authenticate` header supplied.

    With co-hosted defaults the header carries the path-suffixed well-known URL; the client
    fetches that one first and, because it succeeds, never falls back. The single-probe
    sequence proves priority 1.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

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

    assert discovery_gets(recorded) == snapshot([PRM_PATH_SUFFIXED, ASM_ROOT])
    assert (recorded[0].method, recorded[0].path) == ("POST", "/mcp")
    assert (recorded[1].method, recorded[1].path) == ("GET", PRM_PATH_SUFFIXED)


@requirement("client-auth:prm-discovery:fallback-order")
async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() -> None:
    """When the path-suffixed PRM well-known 404s, the client falls back to the root well-known.

    The exact GET count is not asserted: the WWW-Authenticate URL equals the path well-known
    here, so the SDK probes it twice (once as priority 1, once as priority 2) before reaching
    root. Asserting "path before root, root reached, then the flow proceeds" pins the spec
    invariant; the duplicate probe is an implementation detail. The served PRM body carries an
    unrecognized field to prove the client's parser ignores unknown members (RFC 9728 §3.2).
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    prm = ProtectedResourceMetadata(
        resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(BASE_URL)]
    )
    app_shim = shim(
        not_found=frozenset({PRM_PATH_SUFFIXED}),
        serve={PRM_ROOT: metadata_body(prm, x_unknown_extension="ignored")},
    )

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

    well_known = discovery_gets(recorded)
    assert PRM_PATH_SUFFIXED in well_known
    assert PRM_ROOT in well_known
    assert well_known.index(PRM_PATH_SUFFIXED) < well_known.index(PRM_ROOT)
    assert any(r.path == "/authorize" for r in recorded)


@requirement("client-auth:prm-discovery:no-prm-fallback")
async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_the_server_origin() -> None:
    """When every protected-resource metadata probe 404s, the client falls back to the legacy path.

    The legacy 2025-03-26 behaviour: with no PRM document available, treat the MCP server's
    origin as the authorization server and fetch its `/.well-known/oauth-authorization-server`
    directly. The real co-hosted ASM endpoint is at exactly that location, so the flow completes.
    The recorded sequence shows both PRM well-known paths probed (and failed) before ASM_ROOT.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)
    app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT}))

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

    well_known = discovery_gets(recorded)
    assert PRM_PATH_SUFFIXED in well_known
    assert PRM_ROOT in well_known
    assert well_known[-1] == ASM_ROOT
    assert all(well_known.index(prm) < well_known.index(ASM_ROOT) for prm in (PRM_PATH_SUFFIXED, PRM_ROOT))
    assert result.tools[0].name == "probe"


@requirement("client-auth:dcr:registration-error-surfaces")
async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_error() -> None:
    """A 400 from `/register` surfaces as `OAuthRegistrationError` carrying the server's body.

    The shim makes `/register` return RFC 7591's `invalid_client_metadata`; the SDK reads the
    body and raises with the status and text in the message, before any authorize or token
    request is made.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)
    error_body = json.dumps({"error": "invalid_client_metadata", "error_description": "no"}).encode()
    app_shim = shim(serve={"/register": (400, error_body)})

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthRegistrationError, match=r"^Registration failed: 400 .*invalid_client_metadata"),
            flatten_subgroups=True,
        ):
            await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()

    assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []


@requirement("client-auth:prm-resource-mismatch")
async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() -> None:
    """A PRM document whose `resource` does not cover the server URL aborts the flow.

    The shim serves PRM at the URL the WWW-Authenticate header supplies, but with a `resource`
    on a different path; `check_resource_allowed` rejects it and `OAuthFlowError` is raised
    before any authorize or token request is made. The error reaches the test wrapped in nested
    single-element exception groups by the streamable-HTTP client's task group.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    prm = ProtectedResourceMetadata(
        resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[AnyHttpUrl(BASE_URL)]
    )
    app_shim = shim(serve={PRM_PATH_SUFFIXED: metadata_body(prm)})

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthFlowError, match="^Protected resource .* does not match expected"),
            flatten_subgroups=True,
        ):
            await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()

    assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []


@requirement("client-auth:as-metadata-discovery:priority-order")
@pytest.mark.parametrize(
    ("authorization_server", "not_found", "serve_at", "expected_order"),
    [
        pytest.param(
            f"{BASE_URL}/",
            frozenset({ASM_ROOT}),
            OIDC_ROOT,
            [ASM_ROOT, OIDC_ROOT],
            id="root-issuer",
        ),
        pytest.param(
            f"{BASE_URL}/tenant",
            frozenset({f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant"}),
            "/tenant/.well-known/openid-configuration",
            [f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant", "/tenant/.well-known/openid-configuration"],
            id="path-issuer",
        ),
    ],
)
async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
    authorization_server: str, not_found: frozenset[str], serve_at: str, expected_order: list[str]
) -> None:
    """Authorization-server metadata is fetched at the spec's endpoints in the spec's order.

    The shim 404s every endpoint before the last so the recording proves each probe and its
    position. For an issuer URL with no path the order is OAuth root then OIDC root; for an
    issuer URL with a path component it is OAuth path-inserted, OIDC path-inserted, then OIDC
    path-appended (the spec's three-endpoint MUST). The path-issuer case is driven by serving
    a PRM whose `authorization_servers` carries the path; the SDK's own AS routes stay at root
    (the served body points at the real `/authorize` and `/token`). The served bodies carry an
    unrecognized field to prove the client's parser ignores unknown members (RFC 8414 §3.2).
    """
    recorded, on_request = record_requests()
    asm = real_asm()
    asm.issuer = AnyHttpUrl(authorization_server)
    # The redirect iss must equal the issuer the client records from this metadata.
    provider = InMemoryAuthorizationServerProvider(issuer=str(asm.issuer))
    server = Server("guarded", on_list_tools=list_tools)

    prm = ProtectedResourceMetadata(
        resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(authorization_server)]
    )
    app_shim = shim(
        not_found=not_found,
        serve={
            PRM_PATH_SUFFIXED: metadata_body(prm),
            serve_at: metadata_body(asm, x_unknown_extension="ignored"),
        },
    )

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

    assert discovery_gets(recorded) == [PRM_PATH_SUFFIXED, *expected_order]


@requirement("hosting:auth:metadata-endpoints")
@requirement("hosting:auth:prm:authorization-servers-field")
async def test_the_prm_endpoint_serves_the_resource_url_and_at_least_one_authorization_server() -> None:
    """The protected-resource metadata document the SDK serves identifies the resource and an authorization server.

    Also asserts the response is `application/json` (RFC 9728 §3.2) and that fields the SDK has
    no value for are absent rather than null (`PydanticJSONResponse` serializes with
    `exclude_none=True`, satisfying RFC 9728 §3.2's omit-zero-value rule).
    """
    server = Server("bare")
    provider = InMemoryAuthorizationServerProvider()

    async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
        response = await http.get(PRM_PATH_SUFFIXED)

    assert response.status_code == 200
    assert response.headers["content-type"].startswith("application/json")

    document = json.loads(response.content)
    assert "resource_documentation" not in document
    assert "scopes_supported" in document

    metadata = ProtectedResourceMetadata.model_validate(document)
    assert str(metadata.resource).rstrip("/") == f"{BASE_URL}/mcp"
    assert len(metadata.authorization_servers) >= 1
    assert metadata.bearer_methods_supported == ["header"]


@requirement("hosting:auth:as-router")
async def test_as_metadata_advertises_authorize_token_registration_and_s256() -> None:
    """The authorization-server metadata document the SDK serves names the required endpoints and S256."""
    server = Server("bare")
    provider = InMemoryAuthorizationServerProvider()

    async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
        response = await http.get(ASM_ROOT)

    assert response.status_code == 200
    assert response.headers["content-type"].startswith("application/json")

    metadata = OAuthMetadata.model_validate_json(response.content)
    assert str(metadata.issuer).rstrip("/") == BASE_URL
    assert str(metadata.authorization_endpoint) == f"{BASE_URL}/authorize"
    assert str(metadata.token_endpoint) == f"{BASE_URL}/token"
    assert str(metadata.registration_endpoint) == f"{BASE_URL}/register"
    assert metadata.response_types_supported == ["code"]
    assert metadata.code_challenge_methods_supported is not None
    assert "S256" in metadata.code_challenge_methods_supported


@requirement("client-auth:as-metadata-discovery:issuer-validation")
async def test_as_metadata_with_a_mismatched_issuer_aborts_the_flow() -> None:
    """Authorization-server metadata whose `issuer` does not match the discovery URL is rejected.

    RFC 8414 §3.3 / SEP-2468 require the client to reject the document; the SDK compares `issuer`
    to the URL the metadata was fetched from and raises `OAuthFlowError` before any authorize or
    token request is made.
    """
    recorded, on_request = record_requests()
    provider = InMemoryAuthorizationServerProvider()
    server = Server("guarded", on_list_tools=list_tools)

    metadata = real_asm()
    metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")
    app_shim = shim(serve={ASM_ROOT: metadata_body(metadata)})

    with anyio.fail_after(5):
        with pytest.RaisesGroup(
            pytest.RaisesExc(OAuthFlowError, match="^Authorization server metadata issuer mismatch"),
            flatten_subgroups=True,
        ):
            await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()

    assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
