"""Tests for `Connection`.

`Connection` wraps an `Outbound` (the standalone stream). Construct it via the
`from_envelope` / `for_loop` factories so `protocol_version` is always
populated and `has_standalone_channel` is derived from the held outbound. Its
`notify` is best-effort (never raises); `send_raw_request` raises
`NoBackChannelError` structurally from the no-channel sentinel. Tested with a
stub `Outbound` so we can assert wire shape and inject failures.
"""

import logging
from collections.abc import Mapping
from typing import Any, Literal

import anyio
import pytest
from mcp_types import (
    LATEST_PROTOCOL_VERSION,
    ClientCapabilities,
    CreateMessageRequest,
    CreateMessageRequestParams,
    ElicitationCapability,
    EmptyResult,
    Implementation,
    ListRootsRequest,
    ListRootsResult,
    PingRequest,
    Request,
    RequestParams,
    RootsCapability,
    SamplingCapability,
    SamplingContextCapability,
    SamplingToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import BaseModel, ValidationError

from mcp.server.connection import Connection
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError

_CLIENT_INFO = Implementation(name="t", version="0")


class StubOutbound:
    def __init__(
        self, *, result: dict[str, Any] | None = None, raise_on_send: type[BaseException] | None = None
    ) -> None:
        self.requests: list[tuple[str, Mapping[str, Any] | None]] = []
        self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
        self._result = result if result is not None else {}
        self._raise_on_send = raise_on_send

    async def send_raw_request(
        self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
    ) -> dict[str, Any]:
        self.requests.append((method, params))
        return self._result

    async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
        if self._raise_on_send is not None:
            raise self._raise_on_send()
        self.notifications.append((method, params))


# --- factories -----------------------------------------------------------------


def test_from_envelope_is_born_ready_with_no_back_channel():
    """SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`,
    and holds the no-channel sentinel so `has_standalone_channel` derives False."""
    conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
    assert conn.protocol_version == LATEST_MODERN_VERSION
    assert conn.initialized.is_set()
    assert conn.initialize_accepted is True
    assert conn.has_standalone_channel is False
    assert conn.client_params is None
    assert conn.session_id is None


def test_from_envelope_records_client_params_when_both_info_and_caps_supplied():
    """SDK-defined: when both client info and capabilities are supplied,
    `from_envelope` synthesizes `client_params` so capability checks can run."""
    caps = ClientCapabilities(sampling=SamplingCapability())
    conn = Connection.from_envelope(LATEST_MODERN_VERSION, _CLIENT_INFO, caps)
    assert conn.client_params is not None
    assert conn.client_params.client_info.name == "t"
    assert conn.client_params.capabilities.sampling is not None
    assert conn.client_params.protocol_version == LATEST_MODERN_VERSION


@pytest.mark.parametrize(
    ("info", "caps"),
    [(None, ClientCapabilities()), (_CLIENT_INFO, None)],
)
def test_from_envelope_leaves_client_params_none_when_either_is_missing(
    info: Implementation | None, caps: ClientCapabilities | None
):
    """SDK-defined: `client_params` is only synthesized when both info and
    caps are present; either missing leaves it `None`."""
    conn = Connection.from_envelope(LATEST_MODERN_VERSION, info, caps)
    assert conn.client_params is None


def test_from_envelope_with_explicit_outbound_has_standalone_channel():
    """SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel`
    derives True since the held outbound is not the no-channel sentinel."""
    out = StubOutbound()
    conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out)
    assert conn.has_standalone_channel is True
    assert conn.outbound is out
    assert conn.initialized.is_set()


def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready():
    """SDK-defined: `for_loop` seeds `protocol_version` from the hint when given,
    else `LATEST_HANDSHAKE_VERSION`; the connection awaits the initialize handshake."""
    out = StubOutbound()
    conn = Connection.for_loop(out)
    assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
    assert conn.has_standalone_channel is True
    assert not conn.initialized.is_set()
    assert conn.initialize_accepted is False
    assert conn.client_params is None

    hinted = Connection.for_loop(out, protocol_version_hint=LATEST_MODERN_VERSION)
    assert hinted.protocol_version == LATEST_MODERN_VERSION


def test_for_loop_records_session_id_when_supplied():
    """SDK-defined: `for_loop` stores the `session_id` kwarg verbatim."""
    conn = Connection.for_loop(StubOutbound(), session_id="sess-1")
    assert conn.session_id == "sess-1"


# --- outbound channel ----------------------------------------------------------


@pytest.mark.anyio
async def test_connection_notify_forwards_to_outbound():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.notify("notifications/message", {"level": "info", "data": "hi"})
    assert out.notifications == [("notifications/message", {"level": "info", "data": "hi"})]


@pytest.mark.anyio
async def test_connection_notify_swallows_broken_stream_and_debug_logs(caplog: pytest.LogCaptureFixture):
    caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
    out = StubOutbound(raise_on_send=anyio.BrokenResourceError)
    conn = Connection.for_loop(out)
    await conn.notify("notifications/message", {"data": "x"})  # must not raise
    assert "stream closed" in caplog.text.lower()


@pytest.mark.anyio
async def test_connection_notify_drops_when_no_standalone_channel(caplog: pytest.LogCaptureFixture):
    """SDK-defined: the no-channel sentinel debug-logs and drops; `notify` never raises."""
    caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
    conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
    await conn.notify("notifications/message", {"data": "x"})  # must not raise
    assert "no standalone channel" in caplog.text.lower()


@pytest.mark.anyio
async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalone_channel():
    """SDK-defined: the no-channel sentinel raises structurally; `Connection` does no pre-check."""
    conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
    with pytest.raises(NoBackChannelError):
        await conn.send_raw_request("ping", None)


@pytest.mark.anyio
async def test_connection_send_raw_request_forwards_when_standalone_channel_present():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    result = await conn.send_raw_request("ping", None)
    assert out.requests == [("ping", None)]
    assert result == {}


@pytest.mark.anyio
async def test_connection_send_request_with_spec_type_infers_result_type():
    out = StubOutbound(result={"roots": [{"uri": "file:///ws"}]})
    conn = Connection.for_loop(out)
    result = await conn.send_request(ListRootsRequest())
    method, _ = out.requests[0]
    assert method == "roots/list"
    assert isinstance(result, ListRootsResult)
    assert str(result.roots[0].uri) == "file:///ws"


@pytest.mark.anyio
async def test_connection_send_request_validates_result_alias_only():
    """Peer results validate alias-only; a snake_case key from the wire is
    ignored as extra, not populated by Python field name."""
    snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
    conn = Connection.for_loop(StubOutbound(result=snake))
    result = await conn.send_request(CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=1)))
    assert result.stop_reason is None


@pytest.mark.anyio
async def test_connection_send_request_with_result_type_kwarg_validates_custom_type():
    out = StubOutbound(result={})
    conn = Connection.for_loop(out)
    result = await conn.send_request(PingRequest(), result_type=EmptyResult)
    assert isinstance(result, EmptyResult)


@pytest.mark.anyio
async def test_connection_send_request_nonconforming_result_raises_validation_error():
    conn = Connection.for_loop(StubOutbound(result={"bogus": 1}))
    with pytest.raises(ValidationError):
        await conn.send_request(ListRootsRequest())


@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
    """A spec-method result that fails the per-version surface schema raises
    `ValidationError` even when the caller's `result_type` would accept it."""
    conn = Connection.for_loop(StubOutbound(result={"roots": "nope"}))
    with pytest.raises(ValidationError):
        await conn.send_request(ListRootsRequest(), result_type=EmptyResult)


@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
    """A spec-valid client result passes the surface gate and parses to the typed model."""
    conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
    assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
    result = await conn.send_request(ListRootsRequest())
    assert isinstance(result, ListRootsResult)
    assert str(result.roots[0].uri) == "file:///ws"


class _CustomRequest(Request[RequestParams | None, Literal["custom/echo"]]):
    method: Literal["custom/echo"] = "custom/echo"
    params: RequestParams | None = None


class _CustomResult(BaseModel):
    value: int


@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
    """Surface row absent for the negotiated version: gate is bypassed and only
    the inferred result type validates."""
    conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=LATEST_MODERN_VERSION)
    result = await conn.send_request(PingRequest())
    assert isinstance(result, EmptyResult)


@pytest.mark.anyio
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
    """Non-spec methods are not blocked by the surface gate; `result_type` validates."""
    conn = Connection.for_loop(StubOutbound(result={"value": 7}))
    result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
    assert isinstance(result, _CustomResult)
    assert result.value == 7


@pytest.mark.anyio
async def test_connection_ping_sends_ping_on_standalone():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.ping()
    assert out.requests == [("ping", None)]


@pytest.mark.anyio
async def test_connection_log_sends_logging_message_notification():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.log("info", {"k": "v"}, logger="my.logger")  # pyright: ignore[reportDeprecated]
    method, params = out.notifications[0]
    assert method == "notifications/message"
    assert params is not None
    assert params["level"] == "info"
    assert params["data"] == {"k": "v"}
    assert params["logger"] == "my.logger"


@pytest.mark.anyio
async def test_connection_log_with_meta_includes_meta_in_params():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.log("info", "x", meta={"traceId": "abc"})  # pyright: ignore[reportDeprecated]
    _, params = out.notifications[0]
    assert params is not None
    assert params["_meta"] == {"traceId": "abc"}


@pytest.mark.anyio
async def test_connection_list_changed_notifications_send_correct_methods():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.send_tool_list_changed()
    await conn.send_prompt_list_changed()
    await conn.send_resource_list_changed()
    await conn.send_resource_updated("file:///workspace/a.txt")
    methods = [m for m, _ in out.notifications]
    assert methods == [
        "notifications/tools/list_changed",
        "notifications/prompts/list_changed",
        "notifications/resources/list_changed",
        "notifications/resources/updated",
    ]
    assert out.notifications[-1][1] == {"uri": "file:///workspace/a.txt"}


@pytest.mark.anyio
async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_params():
    out = StubOutbound()
    conn = Connection.for_loop(out)
    await conn.send_tool_list_changed(meta={"k": 1})
    assert out.notifications == [("notifications/tools/list_changed", {"_meta": {"k": 1}})]


# --- check_capability ----------------------------------------------------------


def test_connection_check_capability_false_when_no_client_params_recorded():
    """SDK-defined: `check_capability` returns False when no `client_params`
    were recorded, regardless of which factory built the connection."""
    conn = Connection.for_loop(StubOutbound())
    assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
    # Same for a born-ready connection that supplied neither info nor caps.
    assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False


@pytest.mark.parametrize(
    ("have", "want", "expected"),
    [
        (ClientCapabilities(roots=None), ClientCapabilities(roots=RootsCapability()), False),
        (
            ClientCapabilities(roots=RootsCapability(list_changed=False)),
            ClientCapabilities(roots=RootsCapability(list_changed=True)),
            False,
        ),
        (ClientCapabilities(sampling=None), ClientCapabilities(sampling=SamplingCapability()), False),
        (
            ClientCapabilities(sampling=SamplingCapability()),
            ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
            False,
        ),
        (
            ClientCapabilities(sampling=SamplingCapability()),
            ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
            False,
        ),
        (
            ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
            ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
            True,
        ),
        (
            ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
            ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
            True,
        ),
        (ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
        (ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
        (ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
        (ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"a": {}}), True),
    ],
)
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):
    conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, _CLIENT_INFO, have)
    assert conn.check_capability(want) is expected


def test_connection_check_capability_true_when_client_declares_it():
    conn = Connection.from_envelope(
        LATEST_PROTOCOL_VERSION,
        _CLIENT_INFO,
        ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True)),
    )
    assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
    assert conn.check_capability(ClientCapabilities(roots=RootsCapability(list_changed=True))) is True
    assert conn.check_capability(ClientCapabilities(elicitation=ElicitationCapability())) is False
