"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK."""

import inspect

import httpx
import pytest
from mcp_types import TextContent
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
from starlette.routing import Mount, Route

from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client
from mcp.server import MCPServer

# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]


async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None:
    """tutorial001: the factory returns a Starlette application with a single route at `/mcp`."""
    (route,) = tutorial001.app.routes
    assert isinstance(route, Route)
    assert route.path == "/mcp"


async def test_the_server_behind_the_app_is_unchanged() -> None:
    """tutorial001: wrapping the server in an ASGI app changes nothing about its tools."""
    async with Client(tutorial001.mcp) as client:
        result = await client.call_tool("add_note", {"text": "milk"})
        assert result.content == [TextContent(type="text", text="Saved: milk")]
        assert result.structured_content == {"result": "Saved: milk"}


async def test_streamable_http_app_takes_runs_options_except_port() -> None:
    """The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them."""
    parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
    assert parameters == {
        "streamable_http_path",
        "json_response",
        "stateless_http",
        "event_store",
        "retry_interval",
        "transport_security",
        "host",
    }


async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
    """The `!!! check`: nothing starts the session manager except its lifespan."""
    transport = httpx.ASGITransport(app=tutorial001.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
        with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
            await http.post("/mcp")


async def test_mounting_at_the_root_keeps_the_default_path() -> None:
    """tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
    (mount,) = tutorial002.app.routes
    assert isinstance(mount, Mount)
    assert mount.path == ""
    (inner,) = mount.routes
    assert isinstance(inner, Route)
    assert inner.path == "/mcp"


async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
    """The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list."""

    async def about(request: Request) -> Response:
        return PlainTextResponse("about")

    mcp_app = MCPServer("Notes").streamable_http_app()
    listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
    listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])

    transport = httpx.ASGITransport(app=listed_after)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
        assert (await http.get("/about")).status_code == 404

    transport = httpx.ASGITransport(app=listed_before)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
        assert (await http.get("/about")).status_code == 200


async def test_the_host_lifespan_enters_the_session_manager() -> None:
    """tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly."""
    async with tutorial002.lifespan(tutorial002.app):
        async with Client(tutorial002.mcp) as client:
            result = await client.call_tool("add_note", {"text": "milk"})
            assert result.structured_content == {"result": "Saved: milk"}


async def test_two_servers_get_two_mounts() -> None:
    """tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`."""
    notes_mount, tasks_mount = tutorial003.app.routes
    assert isinstance(notes_mount, Mount)
    assert isinstance(tasks_mount, Mount)
    assert notes_mount.path == "/notes"
    assert tasks_mount.path == "/tasks"


async def test_one_lifespan_starts_both_session_managers() -> None:
    """tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer."""
    async with tutorial003.lifespan(tutorial003.app):
        async with Client(tutorial003.notes) as client:
            notes_result = await client.call_tool("add_note", {"text": "milk"})
            assert notes_result.structured_content == {"result": "Saved: milk"}
        async with Client(tutorial003.tasks) as client:
            tasks_result = await client.call_tool("add_task", {"title": "ship"})
            assert tasks_result.structured_content == {"result": "Created: ship"}


async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None:
    """tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path."""
    (mount,) = tutorial004.app.routes
    assert isinstance(mount, Mount)
    assert mount.path == "/notes"
    (inner,) = mount.routes
    assert isinstance(inner, Route)
    assert inner.path == "/"


async def test_cors_exposes_the_session_id_header() -> None:
    """tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
    (middleware,) = tutorial005.app.user_middleware
    assert middleware.cls is CORSMiddleware
    transport = httpx.ASGITransport(app=tutorial005.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
        preflight = await http.options(
            "/mcp",
            headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
        )
        assert preflight.status_code == 200
        assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE"

        response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"})
        assert response.headers["access-control-allow-origin"] == "https://app.example.com"
        assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"


async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
    """tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app."""
    mcp_route, health_route = tutorial006.app.routes
    assert isinstance(mcp_route, Route)
    assert isinstance(health_route, Route)
    assert mcp_route.path == "/mcp"
    assert health_route.path == "/health"


async def test_the_health_check_answers_outside_the_protocol() -> None:
    """tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
    transport = httpx.ASGITransport(app=tutorial006.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
        response = await http.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


INITIALIZE = {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}


async def test_the_default_app_is_localhost_only() -> None:
    """The "Localhost only" section: with no `transport_security=`, the app answers a real hostname
    with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`,
    before any MCP code runs."""
    bare = MCPServer("Notes")
    app = bare.streamable_http_app()
    transport = httpx.ASGITransport(app=app)
    async with bare.session_manager.run():
        async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
            wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
        async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
            wrong_origin = await http.post(
                "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
            )
    assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header")
    assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header")


async def test_the_documented_browser_origin_works_end_to_end() -> None:
    """tutorial005: the page's scenario for real. The public hostname, the browser origin, a
    realistic preflight naming the `Mcp-*` headers, then the actual request."""
    transport = httpx.ASGITransport(app=tutorial005.app)
    async with tutorial005.lifespan(tutorial005.app):
        async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
            preflight = await http.options(
                "/mcp",
                headers={
                    "Origin": "https://app.example.com",
                    "Access-Control-Request-Method": "POST",
                    "Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id",
                },
            )
            assert preflight.status_code == 200
            allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")}
            assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed

            response = await http.post(
                "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
            )
            assert response.status_code == 200
            assert response.headers["mcp-session-id"]
            assert response.headers["access-control-allow-origin"] == "https://app.example.com"
            assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
