"""End-to-end MCP protocol tests using FastMCP's built-in test client.

These tests exercise the full stack: FastMCP routing → MCPMixin tool
discovery → JSON-RPC parameter mapping → tool execution → Pydantic
serialization → MCP response. They catch integration bugs that unit
tests miss (e.g. `from __future__ import annotations` breaking Literal
type introspection at the FastMCP layer).
"""

import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.exceptions import ToolError
from fastmcp.utilities.tests import run_server_async

from mcjtag.mixins import (
    BreakpointMixin,
    ConnectionMixin,
    FlashMixin,
    JTAGMixin,
    MemoryMixin,
    RawMixin,
    RegistersMixin,
    RTTMixin,
    SVDMixin,
    SWDMixin,
    TargetMixin,
    TransportMixin,
)
from mcjtag.state import SessionState

# ── Mock session factory ──────────────────────────────────────────────


def _mock_session():
    """Build a mock openocd.Session with all subsystems."""
    session = MagicMock()

    session.target = MagicMock()
    session.target.state = AsyncMock(
        return_value=SimpleNamespace(
            state="halted",
            current_pc=0x08001234,
            name="stm32f1x.cpu",
        )
    )
    session.target.halt = AsyncMock(
        return_value=SimpleNamespace(
            state="halted",
            current_pc=0x08001234,
            name="stm32f1x.cpu",
        )
    )
    session.target.resume = AsyncMock()
    session.target.step = AsyncMock(
        return_value=SimpleNamespace(
            state="halted",
            current_pc=0x08001236,
            name="stm32f1x.cpu",
        )
    )
    session.target.reset = AsyncMock()

    session.memory = MagicMock()
    session.memory.read_u32 = AsyncMock(return_value=[0xDEADBEEF, 0xCAFEBABE])
    session.memory.read_u8 = AsyncMock(return_value=[0x41, 0x42])
    session.memory.write_u32 = AsyncMock()
    session.memory.search = AsyncMock(return_value=[0x20000100])

    session.registers = MagicMock()
    session.registers.read_all = AsyncMock(
        return_value={
            "r0": SimpleNamespace(value=0, size=32),
            "pc": SimpleNamespace(value=0x08001234, size=32),
            "sp": SimpleNamespace(value=0x20005000, size=32),
        }
    )
    session.registers.write = AsyncMock()

    session.flash = MagicMock()
    session.flash.banks = AsyncMock(
        return_value=[
            SimpleNamespace(
                index=0,
                name="stm32f1x.flash",
                base=0x08000000,
                size=65536,
                target="stm32f1x.cpu",
            ),
        ]
    )
    session.flash.info = AsyncMock(return_value=SimpleNamespace(sectors=[SimpleNamespace()] * 16))

    session.jtag = MagicMock()
    session.jtag.scan_chain = AsyncMock(
        return_value=[
            SimpleNamespace(
                name="stm32f1x.cpu",
                idcode=0x3BA00477,
                ir_length=4,
                enabled=True,
            ),
        ]
    )
    session.jtag.irscan = AsyncMock(return_value=0x01)
    session.jtag.drscan = AsyncMock(return_value=0xDEAD)

    session.swd = MagicMock()
    session.swd.info = AsyncMock(
        return_value=SimpleNamespace(
            name="stm32f1x.dap",
            dpidr=0x2BA01477,
            ap_count=1,
            raw_info="DPIDR: 0x2ba01477",
        )
    )
    session.swd.list_aps = AsyncMock(
        return_value=[
            SimpleNamespace(index=0, idr=0x04770031, base=0xE00FF003, ap_type="MEM-AP"),
        ]
    )
    session.swd.dpreg = AsyncMock(return_value=0x2BA01477)
    session.swd.apreg = AsyncMock(return_value=0x04770031)

    session.breakpoints = MagicMock()
    session.breakpoints.add = AsyncMock()
    session.breakpoints.remove = AsyncMock()
    session.breakpoints.list = AsyncMock(return_value=[])
    session.breakpoints.add_watchpoint = AsyncMock()
    session.breakpoints.remove_watchpoint = AsyncMock()
    session.breakpoints.list_watchpoints = AsyncMock(return_value=[])

    session.rtt = MagicMock()
    session.rtt.setup = AsyncMock()
    session.rtt.start = AsyncMock()
    session.rtt.stop = AsyncMock()
    session.rtt.channels = AsyncMock(return_value=[])
    session.rtt.read = AsyncMock(return_value="")
    session.rtt.write = AsyncMock()

    session.transport = MagicMock()
    session.transport.select = AsyncMock(return_value="swd")
    session.transport.list = AsyncMock(return_value=["jtag", "swd"])
    session.transport.adapter_info = AsyncMock(return_value="cmsis-dap")
    session.transport.adapter_speed = AsyncMock(return_value=1000)

    session.command = AsyncMock(return_value="Open On-Chip Debugger 0.12.0")
    session.close = AsyncMock()

    return session


# ── Test server factory ───────────────────────────────────────────────


class MCJTAGTestServer(
    ConnectionMixin,
    TargetMixin,
    MemoryMixin,
    RegistersMixin,
    FlashMixin,
    JTAGMixin,
    SWDMixin,
    BreakpointMixin,
    RTTMixin,
    TransportMixin,
    SVDMixin,
    RawMixin,
):
    """Same mixin composition as the real server."""


@pytest.fixture
async def mcp_client():
    """Spin up a real MCP server with a mocked OpenOCD session."""
    mock_session = _mock_session()

    @asynccontextmanager
    async def test_lifespan(server: FastMCP):
        state = SessionState(session=mock_session)
        yield state

    mcp = FastMCP("mcjtag-test", lifespan=test_lifespan)
    test_server = MCJTAGTestServer()
    test_server.register_all(mcp)

    async with run_server_async(mcp) as url, Client(StreamableHttpTransport(url)) as client:
        yield client


# ── Protocol-level tests ──────────────────────────────────────────────


class TestToolDiscovery:
    """Verify all tools are registered and discoverable via MCP."""

    async def test_tools_are_listed(self, mcp_client: Client):
        tools = await mcp_client.list_tools()
        tool_names = {t.name for t in tools}

        expected = {
            "connect",
            "start_openocd",
            "disconnect",
            "target_state",
            "target_control",
            "read_memory",
            "write_memory",
            "search_memory",
            "read_registers",
            "write_register",
            "flash_info",
            "flash_program",
            "jtag_scan",
            "jtag_shift",
            "swd_info",
            "swd_list_aps",
            "swd_read_dp",
            "swd_write_dp",
            "swd_read_ap",
            "swd_write_ap",
            "breakpoint_add",
            "breakpoint_remove",
            "breakpoint_list",
            "watchpoint_add",
            "watchpoint_remove",
            "watchpoint_list",
            "rtt_setup",
            "rtt_start",
            "rtt_stop",
            "rtt_channels",
            "rtt_read",
            "rtt_write",
            "transport_info",
            "adapter_speed",
            "svd_inspect",
            "raw_command",
        }
        assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}"

    async def test_tool_count(self, mcp_client: Client):
        tools = await mcp_client.list_tools()
        assert len(tools) >= 34


class TestTargetToolsE2E:
    async def test_target_state(self, mcp_client: Client):
        result = await mcp_client.call_tool("target_state")
        # FastMCP returns CallToolResult with content list
        text = result.content[0].text
        data = json.loads(text)
        assert data["state"] == "halted"
        assert data["halted"] is True
        assert data["pc"] == "0x08001234"

    async def test_target_halt(self, mcp_client: Client):
        result = await mcp_client.call_tool("target_control", {"action": "halt"})
        data = json.loads(result.content[0].text)
        assert data["action"] == "halt"
        assert data["halted"] is True

    async def test_target_control_invalid_action(self, mcp_client: Client):
        """Invalid Literal value should be caught by FastMCP validation."""
        with pytest.raises(ToolError):
            await mcp_client.call_tool("target_control", {"action": "explode"})


class TestMemoryToolsE2E:
    async def test_read_memory(self, mcp_client: Client):
        result = await mcp_client.call_tool(
            "read_memory", {"address": "0x08000000", "count": 2, "width": 32}
        )
        data = json.loads(result.content[0].text)
        assert data["width"] == 32
        assert data["count"] == 2
        assert len(data["values"]) == 2
        assert "0xdeadbeef" in data["values"]
        assert data["hexdump"]  # non-empty

    async def test_read_memory_bad_count(self, mcp_client: Client):
        with pytest.raises(ToolError):
            await mcp_client.call_tool("read_memory", {"address": "0x08000000", "count": 0})

    async def test_search_memory(self, mcp_client: Client):
        result = await mcp_client.call_tool(
            "search_memory",
            {
                "pattern": "DEADBEEF",
                "start": "0x20000000",
                "end": "0x20010000",
            },
        )
        data = json.loads(result.content[0].text)
        assert data["count"] == 1
        assert "0x20000100" in data["matches"]


class TestRegisterToolsE2E:
    async def test_read_all_registers(self, mcp_client: Client):
        result = await mcp_client.call_tool("read_registers")
        data = json.loads(result.content[0].text)
        assert data["count"] == 3
        assert "pc" in data["registers"]
        assert "sp" in data["registers"]


class TestFlashToolsE2E:
    async def test_flash_info(self, mcp_client: Client):
        result = await mcp_client.call_tool("flash_info")
        data = json.loads(result.content[0].text)
        assert data["count"] == 1
        assert data["banks"][0]["name"] == "stm32f1x.flash"
        assert data["banks"][0]["sectors"] == 16

    async def test_flash_program_bad_path(self, mcp_client: Client):
        result = await mcp_client.call_tool("flash_program", {"path": "/nonexistent/firmware.bin"})
        data = json.loads(result.content[0].text)
        assert data["success"] is False
        assert "not found" in data["message"]


class TestJTAGToolsE2E:
    async def test_jtag_scan(self, mcp_client: Client):
        result = await mcp_client.call_tool("jtag_scan")
        data = json.loads(result.content[0].text)
        assert data["count"] == 1
        assert data["taps"][0]["idcode"] == "0x3ba00477"
        assert data["taps"][0]["name"] == "stm32f1x.cpu"

    async def test_jtag_irscan(self, mcp_client: Client):
        result = await mcp_client.call_tool(
            "jtag_shift",
            {
                "tap": "stm32f1x.cpu",
                "operation": "irscan",
                "value": "0x0E",
            },
        )
        data = json.loads(result.content[0].text)
        assert data["operation"] == "irscan"
        assert data["value_out"] == "0x1"


class TestRawCommandE2E:
    async def test_safe_command(self, mcp_client: Client):
        result = await mcp_client.call_tool("raw_command", {"command": "version"})
        data = json.loads(result.content[0].text)
        assert "Open On-Chip Debugger" in data["response"]


class TestConnectionE2E:
    async def test_already_connected(self, mcp_client: Client):
        """Session is pre-connected via fixture — connect should say so."""
        result = await mcp_client.call_tool("connect")
        data = json.loads(result.content[0].text)
        assert "Already connected" in data["message"]
