"""Client extensions (SEP-2133) over the full client-server loop: a server extension
substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it."""

from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal

import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent
from pydantic import ValidationError

from mcp import MCPError
from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement

pytestmark = pytest.mark.anyio

_RECEIPTS = "com.example/receipts"
_FLAGS = "com.example/flags"


class ReceiptResult(Result):
    result_type: Literal["receipt"] = "receipt"
    receipt_token: str
    settings_echo: dict[str, Any] | None = None


_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]


class Receipts(ClientExtension):
    """Client half: claims the `receipt` shape with the test's resolver and settings."""

    identifier = _RECEIPTS

    def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None:
        self._resolve = resolve
        self._settings = {} if settings is None else settings

    def settings(self) -> dict[str, Any]:
        return self._settings

    def claims(self) -> Sequence[ResultClaim[Any]]:
        return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)]


class _ReceiptIssuer(Extension):
    """Server half: answers `buy` with the claimed shape; every other tool passes through."""

    identifier = _RECEIPTS

    async def intercept_tool_call(
        self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
    ) -> HandlerResult:
        if params.name != "buy":
            return await call_next(ctx)
        return {"resultType": "receipt", "receiptToken": "r-117"}


def _receipt_shop(issuer: Extension) -> MCPServer:
    server = MCPServer("shop", extensions=[issuer])

    @server.tool()
    def buy(item: str) -> CallToolResult:
        """Buy an item."""
        raise NotImplementedError  # the server extension answers `buy` before the tool runs

    @server.tool()
    def redeem(token: str) -> str:
        """Exchange a receipt token for the goods."""
        return f"goods for {token}"

    return server


@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
    """The owning extension's claim resolver redeems the substituted `receipt` through
    `ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
    received: list[ReceiptResult] = []

    async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
        received.append(claimed)
        return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})

    async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
        result = await client.call_tool("buy", {"item": "lamp"})

    assert [claimed.receipt_token for claimed in received] == ["r-117"]
    assert result == snapshot(
        CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
    )


@requirement("extensions:client:claimed-result-undeclared-invalid")
async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None:
    """Spec-mandated: an unrecognized `resultType` is invalid, so a client without the
    owning extension fails to parse the claimed shape."""
    async with connect(_receipt_shop(_ReceiptIssuer())) as client:
        with pytest.raises(ValidationError):
            await client.call_tool("buy", {"item": "lamp"})


class _SettingsEchoIssuer(Extension):
    """Server half: requires the declaring client, then echoes its declared settings."""

    identifier = _RECEIPTS

    async def intercept_tool_call(
        self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
    ) -> HandlerResult:
        require_client_extension(ctx, _RECEIPTS)
        client_params = ctx.session.client_params
        assert client_params is not None
        extensions = client_params.capabilities.extensions
        assert extensions is not None
        return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}


@requirement("extensions:client:capability-ad:gates-server-behaviour")
async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None:
    """The per-request `_meta` capability ad gates the claimed substitution: declared
    settings reach the resolver and a non-declaring client is refused with -32021."""
    server = MCPServer("shop", extensions=[_SettingsEchoIssuer()])

    @server.tool()
    def buy(item: str) -> CallToolResult:
        """Buy an item."""
        raise NotImplementedError  # the server extension answers `buy` before the tool runs

    received: list[ReceiptResult] = []

    async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
        received.append(claimed)
        return CallToolResult(content=[TextContent(text="done")])

    async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
        result = await client.call_tool("buy", {"item": "lamp"})
    assert result.content == [TextContent(text="done")]
    assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]

    async with connect(server) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("buy", {"item": "lamp"})
    assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY


async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
    raise NotImplementedError  # no claimed shape can be delivered on a legacy wire


@requirement("extensions:client:capability-ad:legacy-omits-claimed")
async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None:
    """On a legacy connection the claim-bearing identifier drops out of the initialize
    capability ad while an ad-only identifier still advertises."""
    server = MCPServer("introspector")

    @server.tool()
    def declared(ctx: Context) -> list[str]:
        """Report the extension identifiers the client advertised."""
        capabilities = ctx.client_capabilities
        assert capabilities is not None
        return sorted(capabilities.extensions or {})

    client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)]
    async with connect(server, extensions=client_extensions) as client:
        result = await client.call_tool("declared", {})

    assert result.structured_content == {"result": [_FLAGS]}
