"""Tests for mcp_server.py and SwarmNode.as_mcp_server().

All NATS and HTTP I/O is mocked — no live server or NATS instance required.
Requires: pytest, pytest-asyncio, aiohttp[testing].
"""

from __future__ import annotations

import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from aiohttp.test_utils import TestClient, TestServer

from meshpocalypse_semantic.mcp_server import (
    MCPServerConfig,
    MCPTool,
    _build_app,
    _build_tools,
    _jsonrpc_error,
    _jsonrpc_result,
)
from meshpocalypse_semantic.swarm_node import SwarmNode, SwarmNodeConfig


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


_DEFAULT_CAPS = ["summarise", "search"]


def _make_config(
    capabilities: list[str] | None = None,
    nats_client: Any = None,
    tool_descriptions: dict[str, str] | None = None,
) -> MCPServerConfig:
    caps = _DEFAULT_CAPS if capabilities is None else capabilities
    return MCPServerConfig(
        agent_id="test-agent",
        capabilities=caps,
        nats_client=nats_client,
        tool_descriptions=tool_descriptions or {},
        host="127.0.0.1",
        port=3001,
    )


@pytest.fixture()
def config() -> MCPServerConfig:
    """Default MCPServerConfig with no NATS client."""
    return _make_config()


@pytest.fixture()
async def client(config: MCPServerConfig) -> TestClient:
    """aiohttp TestClient wrapping the MCP app."""
    app = _build_app(config)
    async with TestClient(TestServer(app)) as c:
        yield c


# ---------------------------------------------------------------------------
# 1. Tool generation
# ---------------------------------------------------------------------------


class TestBuildTools:
    """Unit tests for _build_tools()."""

    def test_generates_one_tool_per_capability(self) -> None:
        """Each capability becomes exactly one MCPTool."""
        cfg = _make_config(capabilities=["a", "b", "c"])
        tools = _build_tools(cfg)
        assert len(tools) == 3
        assert [t.name for t in tools] == ["a", "b", "c"]

    def test_default_description_contains_cap_name(self) -> None:
        """Auto-generated description includes the capability name."""
        cfg = _make_config(capabilities=["summarise"])
        tools = _build_tools(cfg)
        assert "summarise" in tools[0].description

    def test_custom_description_overrides_default(self) -> None:
        """tool_descriptions mapping overrides the auto-generated text."""
        cfg = _make_config(
            capabilities=["summarise"],
            tool_descriptions={"summarise": "My custom description"},
        )
        tools = _build_tools(cfg)
        assert tools[0].description == "My custom description"

    def test_partial_descriptions_mix_custom_and_default(self) -> None:
        """Only capabilities in tool_descriptions get custom text; others get default."""
        cfg = _make_config(
            capabilities=["a", "b"],
            tool_descriptions={"a": "custom-a"},
        )
        tools = _build_tools(cfg)
        assert tools[0].description == "custom-a"
        assert "b" in tools[1].description  # default includes name

    def test_mcp_tool_to_dict_shape(self) -> None:
        """MCPTool.to_dict() must include name, description, inputSchema."""
        tool = MCPTool(name="search", description="Find things")
        d = tool.to_dict()
        assert d["name"] == "search"
        assert d["description"] == "Find things"
        assert "inputSchema" in d
        assert d["inputSchema"]["type"] == "object"

    def test_empty_capabilities_produces_no_tools(self) -> None:
        """Zero capabilities → empty tool list."""
        cfg = _make_config(capabilities=[])
        assert _build_tools(cfg) == []


# ---------------------------------------------------------------------------
# 2. JSON-RPC helpers
# ---------------------------------------------------------------------------


class TestJsonRpcHelpers:
    """Unit tests for _jsonrpc_result and _jsonrpc_error."""

    def test_result_structure(self) -> None:
        resp = _jsonrpc_result(1, {"foo": "bar"})
        assert resp["jsonrpc"] == "2.0"
        assert resp["id"] == 1
        assert resp["result"] == {"foo": "bar"}
        assert "error" not in resp

    def test_error_structure(self) -> None:
        resp = _jsonrpc_error(2, -32600, "Invalid Request")
        assert resp["jsonrpc"] == "2.0"
        assert resp["id"] == 2
        assert resp["error"]["code"] == -32600
        assert resp["error"]["message"] == "Invalid Request"

    def test_error_with_data(self) -> None:
        resp = _jsonrpc_error(None, -32000, "oops", data={"detail": "x"})
        assert resp["error"]["data"] == {"detail": "x"}


# ---------------------------------------------------------------------------
# 3. Health endpoint
# ---------------------------------------------------------------------------


class TestHealthEndpoint:
    """Tests for GET /mcp/health."""

    async def test_health_returns_ok(self, client: TestClient) -> None:
        """Health endpoint should return HTTP 200 with status=ok."""
        resp = await client.get("/mcp/health")
        assert resp.status == 200
        body = await resp.json()
        assert body["status"] == "ok"

    async def test_health_returns_tool_count(self, client: TestClient) -> None:
        """toolCount must equal the number of declared capabilities."""
        resp = await client.get("/mcp/health")
        body = await resp.json()
        assert body["toolCount"] == 2  # "summarise" and "search" from fixture


# ---------------------------------------------------------------------------
# 4. tools/list
# ---------------------------------------------------------------------------


class TestToolsList:
    """Tests for POST /mcp with method=tools/list."""

    async def test_tools_list_returns_all_tools(self, client: TestClient) -> None:
        """tools/list must return every declared capability."""
        payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
        resp = await client.post("/mcp", json=payload)
        assert resp.status == 200
        body = await resp.json()
        tools = body["result"]["tools"]
        names = {t["name"] for t in tools}
        assert names == {"summarise", "search"}

    async def test_tools_list_includes_input_schema(self, client: TestClient) -> None:
        """Each tool in the list must have an inputSchema field."""
        payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
        resp = await client.post("/mcp", json=payload)
        body = await resp.json()
        for tool in body["result"]["tools"]:
            assert "inputSchema" in tool

    async def test_tools_list_custom_descriptions(self) -> None:
        """Custom descriptions must appear in tools/list output."""
        cfg = _make_config(
            capabilities=["summarise"],
            tool_descriptions={"summarise": "Summarise text for me"},
        )
        app = _build_app(cfg)
        async with TestClient(TestServer(app)) as c:
            payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
            resp = await c.post("/mcp", json=payload)
            body = await resp.json()
            assert body["result"]["tools"][0]["description"] == "Summarise text for me"


# ---------------------------------------------------------------------------
# 5. tools/call routing
# ---------------------------------------------------------------------------


class TestToolsCall:
    """Tests for POST /mcp with method=tools/call."""

    async def test_tools_call_unknown_tool_returns_error(
        self, client: TestClient
    ) -> None:
        """Calling an unknown tool must return a JSON-RPC error."""
        payload = {
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {"name": "nonexistent", "arguments": {}},
        }
        resp = await client.post("/mcp", json=payload)
        body = await resp.json()
        assert "error" in body
        assert body["error"]["code"] == -32602

    async def test_tools_call_no_nats_returns_error(self, client: TestClient) -> None:
        """tools/call when NATS client is None must return connection error."""
        payload = {
            "jsonrpc": "2.0",
            "id": 3,
            "method": "tools/call",
            "params": {"name": "summarise", "arguments": {"input": "hello"}},
        }
        resp = await client.post("/mcp", json=payload)
        body = await resp.json()
        assert "error" in body
        assert body["error"]["code"] == -32000
        assert "not connected" in body["error"]["message"].lower()

    async def test_tools_call_routes_through_nats(self) -> None:
        """tools/call with a connected NATS client must request the correct subject."""
        mock_nc = AsyncMock()
        mock_response = MagicMock()
        mock_response.data = json.dumps({"answer": 42}).encode()
        mock_nc.request = AsyncMock(return_value=mock_response)

        cfg = _make_config(capabilities=["summarise"], nats_client=mock_nc)
        app = _build_app(cfg)
        async with TestClient(TestServer(app)) as c:
            payload = {
                "jsonrpc": "2.0",
                "id": 4,
                "method": "tools/call",
                "params": {"name": "summarise", "arguments": {"input": "text"}},
            }
            resp = await c.post("/mcp", json=payload)
            body = await resp.json()

        assert "result" in body
        mock_nc.request.assert_awaited_once()
        subject_called = mock_nc.request.call_args[0][0]
        assert subject_called == "mesh.capability.summarise"

    async def test_tools_call_nats_timeout_returns_error(self) -> None:
        """A NATS timeout must return JSON-RPC error -32000."""
        mock_nc = AsyncMock()
        mock_nc.request = AsyncMock(side_effect=asyncio.TimeoutError)

        cfg = _make_config(capabilities=["search"], nats_client=mock_nc)
        app = _build_app(cfg)
        async with TestClient(TestServer(app)) as c:
            payload = {
                "jsonrpc": "2.0",
                "id": 5,
                "method": "tools/call",
                "params": {"name": "search", "arguments": {}},
            }
            resp = await c.post("/mcp", json=payload)
            body = await resp.json()

        assert "error" in body
        assert "timed out" in body["error"]["message"].lower()

    async def test_tools_call_missing_name_returns_error(
        self, client: TestClient
    ) -> None:
        """tools/call with missing 'name' param must return -32602."""
        payload = {
            "jsonrpc": "2.0",
            "id": 6,
            "method": "tools/call",
            "params": {"arguments": {}},
        }
        resp = await client.post("/mcp", json=payload)
        body = await resp.json()
        assert "error" in body
        assert body["error"]["code"] == -32602


# ---------------------------------------------------------------------------
# 6. Protocol error handling
# ---------------------------------------------------------------------------


class TestProtocolErrors:
    """Tests for malformed or unsupported requests."""

    async def test_unknown_method_returns_method_not_found(
        self, client: TestClient
    ) -> None:
        """An unknown method must return error code -32601."""
        payload = {"jsonrpc": "2.0", "id": 9, "method": "does/not/exist"}
        resp = await client.post("/mcp", json=payload)
        body = await resp.json()
        assert "error" in body
        assert body["error"]["code"] == -32601

    async def test_invalid_json_returns_parse_error(
        self, client: TestClient
    ) -> None:
        """Sending malformed JSON must return HTTP 400 with parse error."""
        resp = await client.post(
            "/mcp",
            data=b"{not valid json",
            headers={"Content-Type": "application/json"},
        )
        assert resp.status == 400
        body = await resp.json()
        assert "error" in body
        assert body["error"]["code"] == -32700


# ---------------------------------------------------------------------------
# 7. SwarmNode.as_mcp_server integration
# ---------------------------------------------------------------------------


class TestSwarmNodeAsMcpServer:
    """Integration tests for SwarmNode.as_mcp_server()."""

    async def test_as_mcp_server_starts_and_is_cancellable(self) -> None:
        """as_mcp_server should start without error and stop cleanly on cancel."""
        mock_nc = AsyncMock()
        with patch("meshpocalypse_semantic.swarm_node.nats.connect", return_value=mock_nc):
            config = SwarmNodeConfig(
                agent_id="node-test",
                capabilities=["ping"],
                nats_url="nats://localhost:4222",
            )
            node = SwarmNode(config)
            await node.connect()

            with patch(
                "meshpocalypse_semantic.mcp_server.start_mcp_server",
                new_callable=AsyncMock,
            ) as mock_start:
                mock_start.side_effect = asyncio.CancelledError

                with pytest.raises(asyncio.CancelledError):
                    await node.as_mcp_server(port=3099)

                mock_start.assert_awaited_once()

    async def test_as_mcp_server_passes_correct_config(self) -> None:
        """as_mcp_server must forward capabilities, host, port, descriptions."""
        mock_nc = AsyncMock()
        with patch("meshpocalypse_semantic.swarm_node.nats.connect", return_value=mock_nc):
            config = SwarmNodeConfig(
                agent_id="node-a",
                capabilities=["cap1", "cap2"],
                nats_url="nats://localhost:4222",
            )
            node = SwarmNode(config)
            await node.connect()

            captured: list[MCPServerConfig] = []

            async def _fake_start(cfg: MCPServerConfig) -> None:
                captured.append(cfg)
                raise asyncio.CancelledError

            with patch("meshpocalypse_semantic.mcp_server.start_mcp_server", _fake_start):
                with pytest.raises(asyncio.CancelledError):
                    await node.as_mcp_server(
                        port=4500,
                        host="127.0.0.1",
                        tool_descriptions={"cap1": "My cap"},
                    )

            assert len(captured) == 1
            cfg_used = captured[0]
            assert cfg_used.agent_id == "node-a"
            assert cfg_used.capabilities == ["cap1", "cap2"]
            assert cfg_used.port == 4500
            assert cfg_used.host == "127.0.0.1"
            assert cfg_used.tool_descriptions == {"cap1": "My cap"}
            assert cfg_used.nats_client is mock_nc

    async def test_as_mcp_server_without_connect_passes_none_nats(self) -> None:
        """as_mcp_server on a disconnected node must pass nats_client=None."""
        config = SwarmNodeConfig(agent_id="no-nc", capabilities=["x"])
        node = SwarmNode(config)  # NOT connected

        captured: list[MCPServerConfig] = []

        async def _fake_start(cfg: MCPServerConfig) -> None:
            captured.append(cfg)
            raise asyncio.CancelledError

        with patch("meshpocalypse_semantic.mcp_server.start_mcp_server", _fake_start):
            with pytest.raises(asyncio.CancelledError):
                await node.as_mcp_server()

        assert captured[0].nats_client is None
