"""Transport-parametrized connection factories for the interaction suite.

The `connect` fixture (see conftest.py) hands tests one of these factories so the same test body
runs over each transport without naming any of them: the factory is a drop-in replacement for
constructing `Client(server, ...)` and yields the connected client. The HTTP factories drive the
server's real Starlette app through the in-process streaming bridge, so the full transport layer
(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
"""

from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol

import httpx
from httpx_sse import ServerSentEvent, aconnect_sse
from mcp_types import (
    ClientCapabilities,
    Implementation,
    InitializeRequestParams,
    JSONRPCMessage,
    JSONRPCRequest,
    JSONRPCResponse,
    jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route

from mcp.client.client import Client
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction.transports._bridge import StreamingASGITransport

# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"

# DNS-rebinding protection validates Host/Origin headers against a real network attack that cannot
# exist for an in-process ASGI app, so the in-process factories disable it; tests that exercise the
# protection itself pass explicit settings (or transport_security=None to get the localhost
# auto-enable behaviour).
NO_DNS_REBINDING_PROTECTION = TransportSecuritySettings(enable_dns_rebinding_protection=False)


class Connect(Protocol):
    """Connect a Client to a server over the transport selected by the `connect` fixture.

    Accepts the same keyword arguments as `Client` and yields the connected client.
    """

    def __call__(
        self,
        server: Server | MCPServer,
        *,
        read_timeout_seconds: float | None = None,
        sampling_callback: SamplingFnT | None = None,
        list_roots_callback: ListRootsFnT | None = None,
        logging_callback: LoggingFnT | None = None,
        message_handler: MessageHandlerFnT | None = None,
        client_info: Implementation | None = None,
        elicitation_callback: ElicitationFnT | None = None,
        extensions: Sequence[ClientExtension] | None = None,
        spec_version: str = LATEST_HANDSHAKE_VERSION,
    ) -> AbstractAsyncContextManager[Client]: ...


@asynccontextmanager
async def connect_in_memory(
    server: Server | MCPServer,
    *,
    read_timeout_seconds: float | None = None,
    sampling_callback: SamplingFnT | None = None,
    list_roots_callback: ListRootsFnT | None = None,
    logging_callback: LoggingFnT | None = None,
    message_handler: MessageHandlerFnT | None = None,
    client_info: Implementation | None = None,
    elicitation_callback: ElicitationFnT | None = None,
    extensions: Sequence[ClientExtension] | None = None,
    spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
    """Yield a Client connected to the server over the in-memory transport.

    When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
    `mode=<version>`, which drives the server through the DirectDispatcher peer-pair
    (per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
    """
    async with Client(
        server,
        mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
        read_timeout_seconds=read_timeout_seconds,
        sampling_callback=sampling_callback,
        list_roots_callback=list_roots_callback,
        logging_callback=logging_callback,
        message_handler=message_handler,
        client_info=client_info,
        elicitation_callback=elicitation_callback,
        extensions=extensions,
    ) as client:
        yield client


@asynccontextmanager
async def connect_over_streamable_http(
    server: Server | MCPServer,
    *,
    stateless_http: bool = False,
    json_response: bool = False,
    event_store: EventStore | None = None,
    retry_interval: int | None = None,
    read_timeout_seconds: float | None = None,
    sampling_callback: SamplingFnT | None = None,
    list_roots_callback: ListRootsFnT | None = None,
    logging_callback: LoggingFnT | None = None,
    message_handler: MessageHandlerFnT | None = None,
    client_info: Implementation | None = None,
    elicitation_callback: ElicitationFnT | None = None,
    extensions: Sequence[ClientExtension] | None = None,
    spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
    """Yield a Client connected to the server's streamable HTTP app, entirely in process.

    With the defaults this is the matrix leg (stateful sessions, SSE responses); the stateless
    matrix arm binds `stateless_http=True` (see `connect_over_streamable_http_stateless`);
    transport-specific tests pass `json_response` to select the other server mode, and the
    resumability tests pass an `event_store` (with `retry_interval=0` so the client's
    reconnection wait is a no-op).

    When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
    `mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
    initialize handshake.
    """
    app = server.streamable_http_app(
        stateless_http=stateless_http,
        json_response=json_response,
        event_store=event_store,
        retry_interval=retry_interval,
        transport_security=NO_DNS_REBINDING_PROTECTION,
    )
    async with (
        server.session_manager.run(),
        httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
        Client(
            streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
            mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
            read_timeout_seconds=read_timeout_seconds,
            sampling_callback=sampling_callback,
            list_roots_callback=list_roots_callback,
            logging_callback=logging_callback,
            message_handler=message_handler,
            client_info=client_info,
            elicitation_callback=elicitation_callback,
            extensions=extensions,
        ) as client,
    ):
        yield client


connect_over_streamable_http_stateless: Connect = partial(connect_over_streamable_http, stateless_http=True)
"""The streamable-http matrix arm with the server in stateless mode (fresh transport per request,
no session id, no standalone GET stream). The same shared Server instance backs every request --
stateless mode does not require a server factory."""


@asynccontextmanager
async def mounted_app(
    server: Server | MCPServer,
    *,
    stateless_http: bool = False,
    json_response: bool = False,
    event_store: EventStore | None = None,
    retry_interval: int | None = None,
    transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
    on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
    on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
    headers: dict[str, str] | None = None,
    auth: AuthSettings | None = None,
    token_verifier: TokenVerifier | None = None,
    auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
    """Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.

    Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests
    use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
    speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
    that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
    session manager. `on_request` observes every outgoing HTTP request before it leaves the
    yielded client; `on_response` observes every HTTP response as its headers arrive (response
    bodies of SSE streams are not yet read at that point).

    DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
    localhost auto-enable behaviour) to test the protection itself.
    """
    lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
    app = lowlevel.streamable_http_app(
        stateless_http=stateless_http,
        json_response=json_response,
        event_store=event_store,
        retry_interval=retry_interval,
        transport_security=transport_security,
        auth=auth,
        token_verifier=token_verifier,
        auth_server_provider=auth_server_provider,
    )
    event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
    if on_request is not None:
        event_hooks["request"] = [on_request]
    if on_response is not None:
        event_hooks["response"] = [on_response]
    async with (
        server.session_manager.run(),
        httpx.AsyncClient(
            transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers
        ) as http_client,
    ):
        yield http_client, server.session_manager


@asynccontextmanager
async def client_via_http(
    http_client: httpx.AsyncClient,
    *,
    logging_callback: LoggingFnT | None = None,
    message_handler: MessageHandlerFnT | None = None,
    elicitation_callback: ElicitationFnT | None = None,
) -> AsyncIterator[Client]:
    """Connect a `Client` over an already-mounted streamable HTTP app.

    Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
    client-driven assertion can sit alongside raw-httpx assertions in the same test. The
    underlying `httpx.AsyncClient` is left open when the `Client` exits.
    """
    transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
    async with Client(
        transport,
        # Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
        # closing DELETE); the modern flow is sessionless and would silently change the subject.
        mode="legacy",
        logging_callback=logging_callback,
        message_handler=message_handler,
        elicitation_callback=elicitation_callback,
    ) as client:
        yield client


def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage]:
    """Decode SSE events into JSON-RPC messages, skipping priming events that carry no data."""
    return [jsonrpc_message_adapter.validate_json(event.data) for event in events if event.data]


async def post_jsonrpc(
    http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None
) -> tuple[httpx.Response, list[JSONRPCMessage]]:
    """POST a JSON-RPC body and read its SSE response stream to completion.

    Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages
    that arrived on the response's SSE stream. Only meaningful for requests the server answers
    with `text/event-stream`; for error responses or 202 notification acknowledgements, use
    `httpx.AsyncClient.post` directly and assert on the response.
    """
    async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source:
        events = [event async for event in source.aiter_sse()]
    return source.response, parse_sse_messages(events)


def base_headers(*, session_id: str | None = None) -> dict[str, str]:
    """Standard request headers for raw-httpx streamable-HTTP tests.

    Every well-formed request carries these (Accept covering both response representations,
    Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
    ID once one exists), so a test that wants to assert a specific rejection only varies the one
    header under test.
    """
    headers = {
        "accept": "application/json, text/event-stream",
        "content-type": "application/json",
        "mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
    }
    if session_id is not None:
        headers["mcp-session-id"] = session_id
    return headers


def initialize_body(request_id: int = 1) -> dict[str, object]:
    """A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
    params = InitializeRequestParams(
        protocol_version=LATEST_HANDSHAKE_VERSION,
        capabilities=ClientCapabilities(),
        client_info=Implementation(name="raw", version="0.0.0"),
    )
    return JSONRPCRequest(
        jsonrpc="2.0", id=request_id, method="initialize", params=params.model_dump(by_alias=True, exclude_none=True)
    ).model_dump(by_alias=True, exclude_none=True)


async def initialize_via_http(http: httpx.AsyncClient) -> str:
    """Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID.

    Validates the SSE response and sends the `notifications/initialized` follow-up, so the server
    is fully ready for subsequent feature requests when this returns.
    """
    async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source:
        assert source.response.status_code == 200
        # An event-store-backed server opens the stream with a priming event (empty data); skip it.
        events = [event async for event in source.aiter_sse() if event.data]
    assert len(events) == 1
    assert JSONRPCResponse.model_validate_json(events[0].data).id == 1
    session_id = source.response.headers["mcp-session-id"]
    initialized = await http.post(
        "/mcp",
        json={"jsonrpc": "2.0", "method": "notifications/initialized"},
        headers=base_headers(session_id=session_id),
    )
    assert initialized.status_code == 202
    return session_id


def build_sse_app(server: Server | MCPServer) -> tuple[Starlette, SseServerTransport]:
    """Mount a server on a Starlette app exposing the legacy SSE transport at /sse and /messages/.

    `MCPServer.sse_app()` exists but does not expose the underlying `SseServerTransport`, which
    the SSE-specific tests need; building the app explicitly here gives both server flavours the
    same routing while keeping that handle.
    """
    sse = SseServerTransport(
        "/messages/", security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False)
    )
    lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server

    async def handle_sse(request: Request) -> Response:
        async with sse.connect_sse(request.scope, request.receive, request._send) as (read, write):
            await lowlevel.run(read, write, lowlevel.create_initialization_options())
        return Response()

    app = Starlette(
        routes=[
            Route("/sse", endpoint=handle_sse, methods=["GET"]),
            Mount("/messages/", app=sse.handle_post_message),
        ],
    )
    return app, sse


@asynccontextmanager
async def connect_over_sse(
    server: Server | MCPServer,
    *,
    read_timeout_seconds: float | None = None,
    sampling_callback: SamplingFnT | None = None,
    list_roots_callback: ListRootsFnT | None = None,
    logging_callback: LoggingFnT | None = None,
    message_handler: MessageHandlerFnT | None = None,
    client_info: Implementation | None = None,
    elicitation_callback: ElicitationFnT | None = None,
    extensions: Sequence[ClientExtension] | None = None,
    spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
    """Yield a Client connected to the server's legacy SSE transport, entirely in process."""
    app, _ = build_sse_app(server)

    def httpx_client_factory(
        headers: dict[str, str] | None = None,
        timeout: httpx.Timeout | None = None,
        auth: httpx.Auth | None = None,
    ) -> httpx.AsyncClient:
        # The SSE server transport's connect_sse runs the entire MCP session inside the GET
        # request and only releases its streams after that request observes a disconnect, so the
        # bridge must let the application drain rather than cancelling at close.
        return httpx.AsyncClient(
            transport=StreamingASGITransport(app, cancel_on_close=False),
            base_url=BASE_URL,
            headers=headers,
            timeout=timeout,
            auth=auth,
        )

    transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
    async with Client(
        transport,
        # SSE is a legacy-only transport; the modern path has no SSE story.
        mode="legacy",
        read_timeout_seconds=read_timeout_seconds,
        sampling_callback=sampling_callback,
        list_roots_callback=list_roots_callback,
        logging_callback=logging_callback,
        message_handler=message_handler,
        client_info=client_info,
        elicitation_callback=elicitation_callback,
        extensions=extensions,
    ) as client:
        yield client
