"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""

import logging
from typing import Any

import httpx
import pytest
from mcp_types import (
    INVALID_PARAMS,
    INVALID_REQUEST,
    MISSING_REQUIRED_CLIENT_CAPABILITY,
    ElicitRequestParams,
    ElicitResult,
    ErrorData,
    TextContent,
)

from docs_src.troubleshooting import (
    tutorial001,
    tutorial002,
    tutorial003,
    tutorial004,
    tutorial005,
    tutorial006,
    tutorial007,
    tutorial008,
)
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
from mcp.server.mcpserver import RequestStateSecurity

# 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")]

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 _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    """The page's one `elicitation_callback`: always accept the booking."""
    return ElicitResult(action="accept", content={"confirm": True})


async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None:
    """The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block."""
    with pytest.raises(Exception) as exc_info:
        async with Client(tutorial001.mcp) as client:
            await client.read_resource("weather://Atlantis")
    assert not isinstance(exc_info.value, MCPError)
    assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$")


async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None:
    """The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`."""
    async with Client(tutorial001.mcp) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.read_resource("weather://Atlantis")
    assert str(exc_info.value) == "No forecast for 'Atlantis'."
    assert exc_info.value.error.code == INVALID_PARAMS


async def test_a_client_outside_its_async_with_refuses_every_call() -> None:
    """`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses."""
    client = Client(tutorial001.mcp)
    with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"):
        await client.list_tools()


async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None:
    """The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`."""
    async with Client(tutorial001.mcp) as client:
        result = await client.call_tool("forecast", {"city": "Atlantis"})
    assert result.is_error
    assert result.content == [
        TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.")
    ]


async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
    """`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
    async with Client(tutorial001.mcp) as client:
        result = await client.call_tool("get_forecast", {"city": "London"})
    assert result.is_error
    assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]


async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:
    """`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately."""
    mcp = MCPServer("Weather")
    undecorated: Any = mcp.tool
    with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):

        @undecorated
        def forecast(city: str) -> None:
            """Today's forecast for one city. Never called: the decoration itself is what raises."""


async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
    """tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
    async with Client(tutorial002.mcp) as client:
        (tool,) = (await client.list_tools()).tools
    assert tool.name == "forecast"
    assert tool.description == "Today's forecast for one city."


async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None:
    """The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log."""
    with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):

        @tutorial002.mcp.tool(name="forecast")
        def forecast_weekly(city: str) -> None:
            """The week ahead for one city. Never called: it is the duplicate that gets dropped."""

    assert "Tool already exists: forecast" in caplog.messages


async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """tutorial003: one 421, three spellings. The page presents all three as the same event."""
    transport = httpx.ASGITransport(app=tutorial003.app)
    async with tutorial003.mcp.session_manager.run():
        # What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
        async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
            with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
                response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
        assert (response.status_code, response.text) == (421, "Invalid Host header")
        # No `Content-Type: application/json`, which is exactly why the python client cannot show the body.
        assert response.headers.get("content-type") is None
        # What the server operator finds by grepping the server log.
        assert "Invalid Host header: mcp.example.com" in caplog.messages
        # What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
        async with httpx.AsyncClient(transport=transport) as http_client:
            client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
            with pytest.raises(Exception) as exc_info:  # pragma: no branch
                await client.__aenter__()  # the connection attempt itself is what fails
    assert not isinstance(exc_info.value, MCPError)
    assert exc_info.group_contains(MCPError, match="^Server returned an error response$")


async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
    """tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
    transport = httpx.ASGITransport(app=tutorial004.app)
    async with tutorial004.mcp.session_manager.run():
        async with httpx.AsyncClient(transport=transport) as http_client:
            allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
            async with Client(allowed) as c:  # pragma: no branch
                assert c.protocol_version == "2026-07-28"
                result = await c.call_tool("forecast", {"city": "London"})
    assert result.structured_content == {"result": "London: Rain."}


async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
    """tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
    transport = httpx.ASGITransport(app=tutorial005.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") 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_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None:
    """`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim."""
    mcp = MCPServer("Weather")
    app = mcp.streamable_http_app()
    async with mcp.session_manager.run():
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
            response = await h.post(
                "/mcp",
                json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
                headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"},
            )
    assert response.status_code == 404
    assert response.headers["content-type"] == "application/json"
    assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}


async def test_ctx_elicit_at_2026_has_no_back_channel() -> None:
    """tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all."""
    async with Client(tutorial006.mcp) as client:
        assert client.protocol_version == "2026-07-28"
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("book_table", {"date": "Friday"})
    assert exc_info.value.error == ErrorData(
        code=INVALID_REQUEST,
        message=(
            "Cannot send 'elicitation/create': "
            "this transport context has no back-channel for server-initiated requests."
        ),
    )


async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
    """The page's claim: registering the callback changes nothing. No request ever reaches the client."""
    async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
        with pytest.raises(MCPError, match="no back-channel for server-initiated requests"):
            await client.call_tool("book_table", {"date": "Friday"})


async def test_ctx_elicit_on_a_legacy_connection_works() -> None:
    """The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those."""
    async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client:
        result = await client.call_tool("book_table", {"date": "Friday"})
    assert result.structured_content == {"result": "Booked for Friday."}


async def test_the_resolver_form_works_on_a_2026_connection() -> None:
    """tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back."""
    async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client:
        assert client.protocol_version == "2026-07-28"
        result = await client.call_tool("book_table", {"date": "Friday"})
    assert result.structured_content == {"result": "Booked for Friday."}


async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None:
    """The `-32021` entry: the server refuses up front, and `data` names the capability to declare."""
    async with Client(tutorial007.mcp) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("book_table", {"date": "Friday"})
    assert exc_info.value.error == ErrorData(
        code=MISSING_REQUIRED_CLIENT_CAPABILITY,
        message=(
            "Client did not declare the form elicitation capability required by resolver "
            "'docs_src.troubleshooting.tutorial007:ask_to_confirm'"
        ),
        data={"requiredCapabilities": {"elicitation": {"form": {}}}},
    )


async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None:
    """The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask."""
    async with Client(tutorial006.mcp, mode="legacy") as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("book_table", {"date": "Friday"})
    assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported")


async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
    """tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
    transport = httpx.ASGITransport(app=tutorial008.app)
    async with tutorial008.mcp.session_manager.run():
        async with httpx.AsyncClient(transport=transport) as http_client:
            stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
            async with Client(stateless) as c:  # pragma: no branch
                with pytest.raises(MCPError) as exc_info:  # pragma: no branch
                    await c.call_tool("book_table", {"date": "Friday"})
    assert exc_info.value.error == ErrorData(
        code=INVALID_REQUEST,
        message=(
            "Cannot send 'elicitation/create': "
            "this transport context has no back-channel for server-initiated requests."
        ),
    )


async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None:
    """The wire message is deliberately frozen; the real reason goes only to the server log."""
    async with Client(tutorial001.mcp) as client:
        with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
            with pytest.raises(MCPError) as exc_info:  # pragma: no branch
                await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
    assert exc_info.value.error == ErrorData(
        code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
    )
    assert "requestState rejected on tools/call: malformed" in caplog.messages


async def test_a_short_request_state_key_is_rejected_at_construction() -> None:
    """`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one."""
    with pytest.raises(ValueError) as exc_info:
        RequestStateSecurity(keys=[b"hunter2"])
    assert str(exc_info.value) == (
        "request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. "
        'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
    )
