"""Unit tests for the streamable-HTTP client transport.

The full client<->server round trip is pinned by the interaction suite under
tests/interaction/transports/; these tests cover the transport's header encoding and the
per-message metadata-headers merge directly because the headers are an HTTP-seam observation
the public client never exposes.
"""

import base64
import json
from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any

import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
    CLIENT_CAPABILITIES_META_KEY,
    CLIENT_INFO_META_KEY,
    CONNECTION_CLOSED,
    INVALID_REQUEST,
    METHOD_NOT_FOUND,
    PROTOCOL_VERSION_META_KEY,
    JSONRPCError,
    JSONRPCNotification,
    JSONRPCRequest,
    JSONRPCResponse,
)
from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Receive, Scope, Send

from mcp.client.streamable_http import (
    MAX_RECONNECTION_ATTEMPTS,
    RequestContext,
    StreamableHTTPTransport,
    streamable_http_client,
)
from mcp.server import Server
from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
from mcp.shared._context_streams import ContextSendStream, create_context_streams
from mcp.shared.dispatcher import CallOptions, DispatchContext
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext
from tests.interaction.transports import StreamingASGITransport
from tests.shared.test_dispatcher import Recorder, echo_handlers


@pytest.mark.parametrize(
    ("raw", "expected", "wrapped"),
    [
        ("add", snapshot("add"), False),
        ("", snapshot(""), False),
        ("tool with spaces", snapshot("tool with spaces"), False),
        (" add", snapshot("=?base64?IGFkZA==?="), True),
        ("add ", snapshot("=?base64?YWRkIA==?="), True),
        ("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
        ("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
        ("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
    ],
)
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
    raw: str, expected: str, wrapped: bool
) -> None:
    """Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.

    The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
    Wrapped values round-trip through base64 so the server can recover the original name. A leading
    or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
    transports); an empty value is allowed and passes verbatim.
    """
    encoded = encode_header_value(raw)
    assert encoded == expected
    if wrapped:
        assert encoded.startswith("=?base64?") and encoded.endswith("?=")
        assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
    else:
        assert encoded == raw


@pytest.mark.anyio
async def test_post_request_merges_per_message_metadata_headers() -> None:
    """`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
    (SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
    recorded: list[httpx.Request] = []

    def handler(request: httpx.Request) -> httpx.Response:
        recorded.append(request)
        body = json.loads(request.content)
        return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}),
                    metadata=ClientMessageMetadata(headers={"x-test": "v"}),
                )
            )
            reply = await read.receive()
    assert isinstance(reply, SessionMessage)
    assert [r.method for r in recorded] == ["POST"]
    assert recorded[0].headers["x-test"] == "v"


@pytest.mark.anyio
async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
    """A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND.

    Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet,
    "Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
    """

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(404)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
            reply = await read.receive()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCError)
    assert reply.message.error.code == METHOD_NOT_FOUND


@pytest.mark.anyio
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
    """``initialize`` discards the cached protocol-version header; every other POST reads it.

    Steps:
    1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
    2. An ``initialize`` POST clears that cache before building headers, so the fallback
       handshake never carries a probe-stamped value.
    3. A subsequent stamped POST re-seeds the cache with the negotiated version.
    4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
       passes through the session's stamp) then reads the cache and carries the
       negotiated version — the spec MUST for all post-initialization HTTP requests.
    """
    recorded: list[httpx.Request] = []

    def handler(request: httpx.Request) -> httpx.Response:
        recorded.append(request)
        body = json.loads(request.content)
        if "id" not in body or "result" in body:
            return httpx.Response(202)
        return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}),
                )
            )
            await read.receive()
            await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
            await read.receive()
            await write.send(
                SessionMessage(
                    message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
                )
            )
            # An unstamped JSON-RPC response — what the dispatcher writes when answering
            # a server-initiated request (sampling/elicitation/roots).
            await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))

    assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
    assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
    assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
    assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
    assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"


class _ParkedSSEStream(httpx.AsyncByteStream):
    """An SSE response body that emits one comment line, then parks until closed.

    `opened` fires once the transport is iterating the body (the POST is truly in
    flight); `closed` fires when httpx tears the body down — the observable proof
    that an abort, not a response, ended the stream.
    """

    def __init__(self) -> None:
        self.opened = anyio.Event()
        self.closed = anyio.Event()
        self._release = anyio.Event()

    async def __aiter__(self) -> AsyncIterator[bytes]:
        self.opened.set()
        yield b": parked\n\n"
        await self._release.wait()

    async def aclose(self) -> None:
        self.closed.set()
        self._release.set()


def _sse_or_ack_handler(
    parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event
) -> Callable[[httpx.Request], httpx.Response]:
    """Requests get the parked SSE body; notifications get 202 and set `frame_posted`."""

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content)
        posted.append(body)
        if "id" in body:
            return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
        frame_posted.set()
        return httpx.Response(202)

    return handler


@pytest.mark.anyio
async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None:
    """At 2026 an outbound `notifications/cancelled` never POSTs — closing the named
    request's response stream IS the wire's cancellation signal — so the transport
    aborts the in-flight POST and swallows the frame."""
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []

    def handler(request: httpx.Request) -> httpx.Response:
        posted.append(json.loads(request.content))
        return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
                )
            )
            await parked.opened.wait()
            await write.send(
                SessionMessage(
                    JSONRPCNotification(
                        jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"}
                    )
                )
            )
            await parked.closed.wait()
    assert [body["method"] for body in posted] == ["subscriptions/listen"]


@pytest.mark.anyio
@pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"])
async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None:
    """Below 2026 — or before any stamped POST has revealed the version — the frame is
    the spec's cancellation signal: it POSTs, and the request's stream stays open
    (a 2025 disconnect is explicitly not a cancel)."""
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []
    frame_posted = anyio.Event()
    handler = _sse_or_ack_handler(parked, posted, frame_posted)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
        ):
            metadata = (
                ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version})
                if stamped_version is not None
                else None
            )
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
                    metadata=metadata,
                )
            )
            await parked.opened.wait()
            await write.send(
                SessionMessage(
                    JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
                )
            )
            await frame_posted.wait()
            # Checked before teardown: exiting the transport cancels the parked POST.
            assert not parked.closed.is_set()
    assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"]


@pytest.mark.anyio
@pytest.mark.parametrize(
    "params",
    [
        pytest.param({"requestId": 999}, id="unknown-id"),
        pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"),
        pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"),
        pytest.param({}, id="no-request-id"),
        pytest.param(None, id="no-params"),
    ],
)
async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None:
    """At 2026 the frame is swallowed even when it aborts nothing — the wire defines no
    client-to-server notifications, so a late cancel racing the response must not leak
    a POST — and a mismatched id must not abort someone else's stream."""
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content)
        posted.append(body)
        if body.get("id") == 1:
            return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
        return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="subscriptions/listen", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
                )
            )
            await parked.opened.wait()
            await write.send(
                SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params))
            )
            # A follow-up request completing proves the loop moved past the swallowed frame.
            await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={})))
            reply = await read.receive()
            # Checked before teardown: exiting the transport cancels the parked POST.
            assert not parked.closed.is_set()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCResponse)
    assert reply.message.id == 2
    assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"]


@pytest.mark.anyio
async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None:
    """A cancel carrying `ServerMessageMetadata` (a handler abandoning its own
    back-channel request) still names one of OUR outbound ids — every spec-legal
    cancel names a request its sender issued — so at 2026 it aborts that POST and
    stays off the wire like any other."""
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []
    frame_posted = anyio.Event()
    handler = _sse_or_ack_handler(parked, posted, frame_posted)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
                )
            )
            await parked.opened.wait()
            await write.send(
                SessionMessage(
                    message=JSONRPCNotification(
                        jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
                    ),
                    metadata=ServerMessageMetadata(related_request_id=99),
                )
            )
            await parked.closed.wait()
    assert [body["method"] for body in posted] == ["tools/call"]
    assert not frame_posted.is_set()


@pytest.mark.anyio
async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None:
    """The translation follows the era the NAMED request was sent under, not the
    cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation
    semantics (frame on the wire, stream left open) even after a later message
    flips the negotiated version to 2026."""
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []
    frame_posted = anyio.Event()

    def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content)
        posted.append(body)
        if body.get("id") == 1:
            return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
        if "id" in body:
            return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
        frame_posted.set()
        return httpx.Response(202)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
                )
            )
            await parked.opened.wait()
            # A modern-stamped request flips the cached negotiated version.
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}),
                    metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
                )
            )
            reply = await read.receive()
            assert isinstance(reply, SessionMessage)
            assert isinstance(reply.message, JSONRPCResponse)
            await write.send(
                SessionMessage(
                    JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
                )
            )
            await frame_posted.wait()
            # Checked before teardown: exiting the transport cancels the parked POST.
            assert not parked.closed.is_set()
    assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"]


class _SignalingBus(InMemorySubscriptionBus):
    """Signals subscribe/unsubscribe so a test observes the stream lifecycle through
    the bus Protocol (the public seam) instead of polling handler internals."""

    def __init__(self) -> None:
        super().__init__()
        self.subscribed = anyio.Event()
        self.unsubscribed = anyio.Event()

    def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
        unsubscribe = super().subscribe(listener)
        self.subscribed.set()

        def unsubscribe_and_signal() -> None:
            unsubscribe()
            self.unsubscribed.set()

        return unsubscribe_and_signal


@pytest.mark.anyio
async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
    """Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen`
    closes the POST's response stream — the server treats the disconnect as the cancel
    and releases the subscription — and no `notifications/cancelled` crosses the wire."""
    bus = _SignalingBus()
    server = Server("test", on_subscriptions_listen=ListenHandler(bus))

    async def app(scope: Scope, receive: Receive, send: Send) -> None:
        async with server.lifespan(server) as lifespan_state:
            await handle_modern_request(server, None, False, lifespan_state, scope, receive, send)

    posted_methods: list[str] = []

    async def record_request(request: httpx.Request) -> None:
        posted_methods.append(json.loads(request.content)["method"])

    acked = anyio.Event()

    async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
        assert method == "notifications/subscriptions/acknowledged"
        acked.set()

    on_request, _ = echo_handlers(Recorder())

    with anyio.fail_after(15):
        async with (
            httpx.AsyncClient(
                transport=StreamingASGITransport(app),
                base_url="http://testserver",
                event_hooks={"request": [record_request]},
            ) as http,
            streamable_http_client("http://testserver/mcp", http_client=http) as (read, write),
        ):
            dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write)
            async with anyio.create_task_group() as tg:  # pragma: no branch
                await tg.start(dispatcher.run, on_request, on_notify)
                listen_scope = anyio.CancelScope()

                async def send_listen() -> None:
                    params: dict[str, Any] = {
                        "_meta": {
                            PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
                            CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"},
                            CLIENT_CAPABILITIES_META_KEY: {},
                        },
                        "notifications": {"toolsListChanged": True},
                    }
                    opts: CallOptions = {
                        "request_id": "listen-1",
                        "headers": {
                            MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
                            MCP_METHOD_HEADER: "subscriptions/listen",
                        },
                    }
                    with listen_scope:
                        await dispatcher.send_raw_request("subscriptions/listen", params, opts)

                tg.start_soon(send_listen)
                await acked.wait()
                assert bus.subscribed.is_set()
                assert not bus.unsubscribed.is_set()
                listen_scope.cancel()
                await bus.unsubscribed.wait()
                tg.cancel_scope.cancel()
    assert posted_methods == ["subscriptions/listen"]


class _CompletingSSEStream(httpx.AsyncByteStream):
    """An SSE body that delivers one JSON-RPC response, then parks in `aclose`.

    Holding `aclose` keeps the finished POST task alive past its response, so a
    test can re-register the same request id underneath it before releasing.
    """

    def __init__(self, response_body: dict[str, Any]) -> None:
        self._event = f"data: {json.dumps(response_body)}\n\n".encode()
        self.release = anyio.Event()

    async def __aiter__(self) -> AsyncIterator[bytes]:
        yield self._event

    async def aclose(self) -> None:
        await self.release.wait()


@pytest.mark.anyio
async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None:
    """Request ids are reusable once resolved; a finished POST task unwinding late
    must not pop the successor's registration, or a cancel for the reused id would
    find nothing to abort and the live POST would leak past the cancellation."""
    completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}})
    parked = _ParkedSSEStream()
    posted: list[dict[str, Any]] = []
    streams = [completing, parked]

    def handler(request: httpx.Request) -> httpx.Response:
        posted.append(json.loads(request.content))
        return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}),
                    metadata=modern,
                )
            )
            reply = await read.receive()
            assert isinstance(reply, SessionMessage)
            assert isinstance(reply.message, JSONRPCResponse)
            # The first task is now parked in `aclose`; reuse its id underneath it.
            await write.send(
                SessionMessage(
                    message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}),
                    metadata=modern,
                )
            )
            await parked.opened.wait()
            completing.release.set()
            await anyio.wait_all_tasks_blocked()
            # The successor's registration survived: a cancel still aborts it.
            await write.send(
                SessionMessage(
                    JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"})
                )
            )
            await parked.closed.wait()
    assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]


class _DyingSSEStream(httpx.AsyncByteStream):
    """Emits one id-less comment then breaks - a non-resumable stream dropping."""

    def __init__(self) -> None:
        self.opened = anyio.Event()

    async def __aiter__(self) -> AsyncIterator[bytes]:
        self.opened.set()
        yield b": hello\n\n"
        raise httpx.ReadError("connection reset")

    async def aclose(self) -> None:
        pass


@pytest.mark.anyio
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
    """A per-request SSE stream that dies having carried no event ids can never deliver its
    response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
    dying = _DyingSSEStream()

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
            )
            reply = await read.receive()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCError)
    assert reply.message.id == "listen-1"
    assert reply.message.error.code == CONNECTION_CLOSED


class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
    """Parks after opening, then delivers one JSON-RPC response when told."""

    def __init__(self, response_body: dict[str, Any]) -> None:
        self._event = f"data: {json.dumps(response_body)}\n\n".encode()
        self.opened = anyio.Event()
        self.deliver = anyio.Event()

    async def __aiter__(self) -> AsyncIterator[bytes]:
        self.opened.set()
        await self.deliver.wait()
        yield self._event

    async def aclose(self) -> None:
        pass


@pytest.mark.anyio
async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None:
    """SDK-defined: re-issuing an id severs the superseded POST, so nothing from its
    stream (a late real response, or a synthesized error for its death) can resolve
    the reused id's waiter; only the successor's own response arrives."""
    stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
    succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
    streams: list[httpx.AsyncByteStream] = [stale, succeeding]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
            await stale.opened.wait()
            await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
            await succeeding.opened.wait()
            stale.deliver.set()
            await anyio.wait_all_tasks_blocked()
            succeeding.deliver.set()
            reply = await read.receive()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCResponse), reply.message
    assert reply.message.result == {"origin": "fresh"}


@pytest.mark.anyio
async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
    """SDK-defined: a server that answers a request with 202 Accepted has declared no
    response will follow (the spec requires SSE or JSON for requests); the transport
    resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(202)

    with anyio.fail_after(5):
        async with (
            httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
            streamable_http_client("http://test/mcp", http_client=http) as (read, write),
        ):
            await write.send(
                SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
            )
            reply = await read.receive()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCError)
    assert reply.message.id == "listen-1"
    assert reply.message.error.code == INVALID_REQUEST


def _abandoned_request_context(
    http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
) -> RequestContext:
    return RequestContext(
        client=http,
        session_id=None,
        session_message=SessionMessage(
            JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})
        ),
        metadata=None,
        read_stream_writer=send,
    )


@pytest.mark.anyio
async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None:
    """An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
    transport = StreamableHTTPTransport("http://test/mcp")
    send, receive = create_context_streams[SessionMessage | Exception](1)
    async with httpx.AsyncClient() as http:
        with anyio.fail_after(5):
            await transport._handle_reconnection(  # pyright: ignore[reportPrivateUsage]
                _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
            )
            reply = await receive.receive()
    assert isinstance(reply, SessionMessage)
    assert isinstance(reply.message, JSONRPCError)
    assert reply.message.id == "listen-1"
    assert reply.message.error.code == CONNECTION_CLOSED
    send.close()
    receive.close()


@pytest.mark.anyio
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
    """Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""
    transport = StreamableHTTPTransport("http://test/mcp")
    send, receive = create_context_streams[SessionMessage | Exception](1)
    receive.close()
    async with httpx.AsyncClient() as http:
        with anyio.fail_after(5):
            await transport._handle_reconnection(  # pyright: ignore[reportPrivateUsage]
                _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
            )
    send.close()
