"""Mock-based integration tests for mcjtag tool methods.

These test that our validation layer runs BEFORE touching hardware,
and that correct values are delegated to the mocked Session.
"""

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from openocd.errors import OpenOCDError

from mcjtag.mixins.connection import ConnectionMixin
from mcjtag.mixins.diagnostics import DiagnosticsMixin
from mcjtag.mixins.flash import FlashMixin
from mcjtag.mixins.jtag import JTAGMixin
from mcjtag.mixins.memory import MemoryMixin
from mcjtag.mixins.raw import RawMixin
from mcjtag.mixins.registers import RegistersMixin
from mcjtag.mixins.target import TargetMixin
from mcjtag.state import SessionState

# ── Fake Context harness ──────────────────────────────────────────────


def _make_ctx(state: SessionState):
    """Build a minimal fake FastMCP Context that carries our SessionState."""
    ctx = MagicMock()
    ctx.request_context.lifespan_context = state
    ctx.report_progress = AsyncMock()
    return ctx


def _make_session():
    """Build a mock Session with async subsystems."""
    session = MagicMock()

    # Target subsystem
    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()

    # Memory subsystem
    session.memory = MagicMock()
    session.memory.read_u8 = AsyncMock(return_value=[0x41, 0x42, 0x43, 0x44])
    session.memory.read_u16 = AsyncMock(return_value=[0x4142, 0x4344])
    session.memory.read_u32 = AsyncMock(return_value=[0xDEADBEEF])
    session.memory.read_u64 = AsyncMock(return_value=[0x0102030405060708])
    session.memory.write_u8 = AsyncMock()
    session.memory.write_u16 = AsyncMock()
    session.memory.write_u32 = AsyncMock()
    session.memory.search = AsyncMock(return_value=[0x20000100, 0x20000200])

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

    # Flash subsystem
    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.flash.write_image = AsyncMock()

    # JTAG subsystem
    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)

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

    # Connection
    session.close = AsyncMock()

    return session


# ── Target tools ──────────────────────────────────────────────────────


class TestTargetTools:
    async def test_target_state(self):
        state = SessionState(session=_make_session())
        mixin = TargetMixin()
        result = await mixin.target_state(_make_ctx(state))
        assert result.halted is True
        assert result.pc == "0x08001234"
        assert result.state == "halted"

    async def test_target_state_requires_session(self):
        state = SessionState()
        mixin = TargetMixin()
        with pytest.raises(OpenOCDError, match="Not connected"):
            await mixin.target_state(_make_ctx(state))

    async def test_target_halt(self):
        state = SessionState(session=_make_session())
        mixin = TargetMixin()
        result = await mixin.target_control(_make_ctx(state), action="halt")
        assert result.action == "halt"
        assert result.halted is True

    async def test_target_step(self):
        state = SessionState(session=_make_session())
        mixin = TargetMixin()
        result = await mixin.target_control(_make_ctx(state), action="step")
        assert result.action == "step"
        state.session.target.step.assert_called_once_with(None)

    async def test_resume_with_address(self):
        state = SessionState(session=_make_session())
        mixin = TargetMixin()
        await mixin.target_control(_make_ctx(state), action="resume", address="0x08001000")
        state.session.target.resume.assert_called_once_with(0x08001000)

    async def test_odd_address_rejected(self):
        state = SessionState(session=_make_session())
        mixin = TargetMixin()
        with pytest.raises(OpenOCDError, match="not 2-byte aligned"):
            await mixin.target_control(_make_ctx(state), action="resume", address="0x08001001")


# ── Memory tools ──────────────────────────────────────────────────────


class TestMemoryTools:
    async def test_read_u32(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        result = await mixin.read_memory(_make_ctx(state), address="0x08000000", count=1, width=32)
        assert result.width == 32
        assert result.count == 1
        assert "0xdeadbeef" in result.values
        assert result.hexdump  # Not empty

    async def test_read_u8(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        result = await mixin.read_memory(_make_ctx(state), address="0x20000000", count=4, width=8)
        assert result.count == 4
        state.session.memory.read_u8.assert_called_once_with(0x20000000, 4)

    async def test_read_count_zero_rejected(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="must be positive"):
            await mixin.read_memory(_make_ctx(state), address="0x08000000", count=0)

    async def test_read_count_too_large(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="exceeds maximum"):
            await mixin.read_memory(_make_ctx(state), address="0x08000000", count=5000)

    async def test_read_bad_hex_address(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="Invalid hex"):
            await mixin.read_memory(_make_ctx(state), address="not_hex")

    @patch.dict("os.environ", {"MCJTAG_SAFE_WRITE_RANGES": "none"})
    async def test_write_u32(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        result = await mixin.write_memory(
            _make_ctx(state),
            address="0x20000000",
            values=["0xDEADBEEF"],
            width=32,
        )
        assert result.written is True
        state.session.memory.write_u32.assert_called_once_with(0x20000000, [0xDEADBEEF])

    async def test_write_to_flash_blocked(self):
        """Default safe ranges only allow SRAM — flash writes should be rejected."""
        import os

        os.environ.pop("MCJTAG_SAFE_WRITE_RANGES", None)
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="outside safe write ranges"):
            await mixin.write_memory(
                _make_ctx(state),
                address="0x08000000",
                values=["0xDEADBEEF"],
            )

    async def test_write_value_overflow(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="exceeds"):
            await mixin.write_memory(
                _make_ctx(state),
                address="0x20000000",
                values=["0x1FFFFFFFF"],  # > 32-bit max
                width=32,
            )

    async def test_search_memory(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        result = await mixin.search_memory(
            _make_ctx(state),
            pattern="DEADBEEF",
            start="0x20000000",
            end="0x20010000",
        )
        assert result.count == 2
        assert "0x20000100" in result.matches

    async def test_search_bad_pattern(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="Invalid hex pattern"):
            await mixin.search_memory(
                _make_ctx(state),
                pattern="ZZZZ",
                start="0x20000000",
                end="0x20010000",
            )

    async def test_search_reversed_range(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="must be after"):
            await mixin.search_memory(
                _make_ctx(state),
                pattern="DEAD",
                start="0x20010000",
                end="0x20000000",
            )

    async def test_search_range_too_large(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="exceeds maximum"):
            await mixin.search_memory(
                _make_ctx(state),
                pattern="DEAD",
                start="0x00000000",
                end="0x10000000",  # 256 MB — way over 1 MB limit
            )

    @patch.dict("os.environ", {"MCJTAG_SAFE_WRITE_RANGES": "none"})
    async def test_write_unaligned_32bit_rejected(self):
        """Unaligned 32-bit writes should be rejected (hard fault risk)."""
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="Unaligned"):
            await mixin.write_memory(
                _make_ctx(state),
                address="0x20000001",  # not 4-byte aligned
                values=["0xDEADBEEF"],
                width=32,
            )

    @patch.dict("os.environ", {"MCJTAG_SAFE_WRITE_RANGES": "none"})
    async def test_write_unaligned_16bit_rejected(self):
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        with pytest.raises(OpenOCDError, match="Unaligned"):
            await mixin.write_memory(
                _make_ctx(state),
                address="0x20000001",  # not 2-byte aligned
                values=["0x1234"],
                width=16,
            )

    @patch.dict("os.environ", {"MCJTAG_SAFE_WRITE_RANGES": "none"})
    async def test_write_8bit_allows_any_alignment(self):
        """8-bit writes should work at any address — no alignment needed."""
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        result = await mixin.write_memory(
            _make_ctx(state),
            address="0x20000001",
            values=["0xFF"],
            width=8,
        )
        assert result.written is True

    async def test_search_reports_progress(self):
        """search_memory should call ctx.report_progress."""
        state = SessionState(session=_make_session())
        mixin = MemoryMixin()
        ctx = _make_ctx(state)
        await mixin.search_memory(ctx, pattern="DEADBEEF", start="0x20000000", end="0x20010000")
        assert ctx.report_progress.call_count == 2
        # First call: start (0, range_size, ...)
        first_call = ctx.report_progress.call_args_list[0]
        assert first_call[0][0] == 0
        assert first_call[0][1] == 0x10000  # 64KB range
        # Second call: complete (range_size, range_size, ...)
        last_call = ctx.report_progress.call_args_list[1]
        assert last_call[0][0] == 0x10000
        assert last_call[0][1] == 0x10000


# ── Register tools ────────────────────────────────────────────────────


class TestRegisterTools:
    async def test_read_all(self):
        state = SessionState(session=_make_session())
        mixin = RegistersMixin()
        result = await mixin.read_registers(_make_ctx(state))
        assert result.count == 2
        assert "pc" in result.registers
        assert "r0" in result.registers

    async def test_read_specific(self):
        state = SessionState(session=_make_session())
        mixin = RegistersMixin()
        await mixin.read_registers(_make_ctx(state), names=["pc"])
        state.session.registers.read_many.assert_called_once_with(["pc"])

    async def test_write_register(self):
        state = SessionState(session=_make_session())
        mixin = RegistersMixin()
        result = await mixin.write_register(_make_ctx(state), name="pc", value="0x08001000")
        assert result.written is True
        assert result.value == "0x08001000"
        state.session.registers.write.assert_called_once_with("pc", 0x08001000)

    async def test_write_bad_hex(self):
        state = SessionState(session=_make_session())
        mixin = RegistersMixin()
        with pytest.raises(OpenOCDError, match="Invalid hex"):
            await mixin.write_register(_make_ctx(state), name="pc", value="garbage")


# ── Flash tools ───────────────────────────────────────────────────────


class TestFlashTools:
    async def test_flash_info(self):
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        result = await mixin.flash_info(_make_ctx(state))
        assert result.count == 1
        assert result.banks[0].name == "stm32f1x.flash"
        assert result.banks[0].sectors == 16

    async def test_flash_program_file_not_found(self):
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        result = await mixin.flash_program(_make_ctx(state), path="/nonexistent/firmware.bin")
        assert result.success is False
        assert "not found" in result.message

    async def test_flash_program_bad_extension(self, tmp_path):
        bad_file = tmp_path / "firmware.txt"
        bad_file.write_bytes(b"\x00" * 100)
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        result = await mixin.flash_program(_make_ctx(state), path=str(bad_file))
        assert result.success is False
        assert "Unsupported file type" in result.message

    async def test_flash_program_empty_file(self, tmp_path):
        empty_file = tmp_path / "firmware.bin"
        empty_file.write_bytes(b"")
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        result = await mixin.flash_program(_make_ctx(state), path=str(empty_file))
        assert result.success is False
        assert "empty" in result.message

    async def test_flash_program_success(self, tmp_path):
        fw = tmp_path / "firmware.bin"
        fw.write_bytes(b"\x00" * 1024)
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        result = await mixin.flash_program(_make_ctx(state), path=str(fw))
        assert result.success is True
        assert result.erased is True
        assert result.verified is True
        state.session.flash.write_image.assert_called_once()

    async def test_flash_program_reports_progress(self, tmp_path):
        """flash_program should report erase/write/verify progress phases."""
        fw = tmp_path / "firmware.bin"
        fw.write_bytes(b"\x00" * 1024)
        state = SessionState(session=_make_session())
        mixin = FlashMixin()
        ctx = _make_ctx(state)
        await mixin.flash_program(ctx, path=str(fw))
        # erase=True, verify=True → 3 phases, plus completion = 3 calls
        assert ctx.report_progress.call_count == 3
        # Final call should report completion
        final = ctx.report_progress.call_args_list[-1]
        assert final[0][0] == final[0][1]  # progress == total


# ── JTAG tools ────────────────────────────────────────────────────────


class TestJTAGTools:
    async def test_jtag_scan(self):
        state = SessionState(session=_make_session())
        mixin = JTAGMixin()
        result = await mixin.jtag_scan(_make_ctx(state))
        assert result.count == 1
        assert result.taps[0].name == "stm32f1x.cpu"
        assert result.taps[0].idcode == "0x3ba00477"

    async def test_jtag_irscan(self):
        state = SessionState(session=_make_session())
        mixin = JTAGMixin()
        result = await mixin.jtag_shift(
            _make_ctx(state), tap="stm32f1x.cpu", operation="irscan", value="0x0E"
        )
        assert result.operation == "irscan"
        assert result.value_out == "0x1"

    async def test_jtag_drscan(self):
        state = SessionState(session=_make_session())
        mixin = JTAGMixin()
        result = await mixin.jtag_shift(
            _make_ctx(state),
            tap="stm32f1x.cpu",
            operation="drscan",
            value="0x00",
            bits=32,
        )
        assert result.operation == "drscan"
        assert result.bits == 32

    async def test_drscan_without_bits_rejected(self):
        state = SessionState(session=_make_session())
        mixin = JTAGMixin()
        with pytest.raises(OpenOCDError, match="bits parameter is required"):
            await mixin.jtag_shift(
                _make_ctx(state), tap="stm32f1x.cpu", operation="drscan", value="0x00"
            )


# ── Raw command tool ──────────────────────────────────────────────────


class TestRawCommandTool:
    async def test_safe_command(self):
        state = SessionState(session=_make_session())
        mixin = RawMixin()
        result = await mixin.raw_command(_make_ctx(state), command="version")
        assert "Open On-Chip Debugger" in result.response

    @patch.dict("os.environ", {}, clear=True)
    async def test_dangerous_command_blocked(self):
        state = SessionState(session=_make_session())
        mixin = RawMixin()
        result = await mixin.raw_command(_make_ctx(state), command="shutdown")
        assert "Blocked" in result.response

    @patch.dict("os.environ", {"MCJTAG_ALLOW_RAW": "true"})
    async def test_dangerous_command_allowed_with_env(self):
        state = SessionState(session=_make_session())
        mixin = RawMixin()
        result = await mixin.raw_command(_make_ctx(state), command="shutdown")
        assert "Blocked" not in result.response

    async def test_no_session(self):
        state = SessionState()
        mixin = RawMixin()
        result = await mixin.raw_command(_make_ctx(state), command="version")
        assert "Not connected" in result.response


# ── Connection tools ──────────────────────────────────────────────────


class TestConnectionTools:
    async def test_disconnect_when_not_connected(self):
        state = SessionState()
        mixin = ConnectionMixin()
        result = await mixin.disconnect(_make_ctx(state))
        assert result.connected is False
        assert "Not connected" in result.message

    async def test_disconnect_cleans_state(self):
        session = _make_session()
        state = SessionState(session=session, managed_process=True)
        mixin = ConnectionMixin()
        result = await mixin.disconnect(_make_ctx(state))
        assert result.connected is False
        assert state.session is None
        assert state.managed_process is False
        session.close.assert_called_once()

    async def test_connect_when_already_connected(self):
        state = SessionState(session=_make_session())
        mixin = ConnectionMixin()
        result = await mixin.connect(_make_ctx(state))
        assert "Already connected" in result.message


# ── Diagnostics tool ─────────────────────────────────────────────────


class TestDiagnosticsTool:
    async def test_not_connected(self):
        state = SessionState()
        mixin = DiagnosticsMixin()
        result = await mixin.probe_diagnostics(_make_ctx(state))
        assert result.connected is False
        assert len(result.checks) == 1
        assert result.checks[0].name == "connection"
        assert result.checks[0].ok is False

    async def test_connected_with_target(self):
        session = _make_session()

        # Mock the command method to return realistic OpenOCD output
        async def mock_command(cmd):
            responses = {
                "version": "Open On-Chip Debugger 0.12.0",
                "adapter speed": "adapter speed: 1000 kHz",
                "transport select": "swd",
                "targets": (
                    "    TargetName         Type       Endian TapName\n"
                    "--  ------------------ ---------- ------ ---\n"
                    " 0* stm32f1x.cpu       cortex_m   little stm32f1x.cpu"
                ),
            }
            return responses.get(cmd, "")

        session.command = AsyncMock(side_effect=mock_command)
        session.memory.read_u32 = AsyncMock(return_value=[0x411FC231])
        session.flash.banks = AsyncMock(
            return_value=[
                SimpleNamespace(
                    index=0,
                    name="stm32f1x.flash",
                    base=0x08000000,
                    size=65536,
                    target="stm32f1x.cpu",
                ),
            ]
        )

        state = SessionState(session=session)
        mixin = DiagnosticsMixin()
        result = await mixin.probe_diagnostics(_make_ctx(state))

        assert result.connected is True
        check_names = [c.name for c in result.checks]
        assert "connection" in check_names
        assert "openocd_version" in check_names
        assert "target" in check_names
        assert "memory_access" in check_names
        assert "flash" in check_names
        assert all(c.ok for c in result.checks if c.name not in ("svd",))
        assert "target found" in result.summary

    async def test_connected_no_target(self):
        session = _make_session()

        async def mock_command(cmd):
            responses = {
                "version": "Open On-Chip Debugger 0.12.0",
                "adapter speed": "adapter speed: 1000 kHz",
                "transport select": "swd",
                "targets": "",
            }
            return responses.get(cmd, "")

        session.command = AsyncMock(side_effect=mock_command)

        state = SessionState(session=session)
        mixin = DiagnosticsMixin()
        result = await mixin.probe_diagnostics(_make_ctx(state))

        assert result.connected is True
        target_check = next(c for c in result.checks if c.name == "target")
        assert target_check.ok is False
        assert "no target" in result.summary
