"""Tests for tc netem command builder, tap interface discovery, and net_impair tool."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from vmcluster_mcp.net.fault import (
    apply_impairment,
    build_impairment_cmd,
    discover_tap_interface,
    remove_impairment,
)

# ---------------------------------------------------------------------------
# Sample domain XML fixtures
# ---------------------------------------------------------------------------

_DOMAIN_XML_WITH_BRIDGE = """
<domain type='kvm'>
  <name>cluster1_node1</name>
  <devices>
    <interface type='bridge'>
      <mac address='52:54:00:ab:cd:ef'/>
      <source bridge='virbr0'/>
      <target dev='vnet3'/>
      <model type='virtio'/>
    </interface>
  </devices>
</domain>
"""

_DOMAIN_XML_MULTI_INTERFACE = """
<domain type='kvm'>
  <name>cluster1_node1</name>
  <devices>
    <interface type='bridge'>
      <source bridge='virbr1'/>
      <target dev='vnet0'/>
    </interface>
    <interface type='bridge'>
      <source bridge='virbr0'/>
      <target dev='vnet3'/>
    </interface>
  </devices>
</domain>
"""

_DOMAIN_XML_NO_BRIDGE = """
<domain type='kvm'>
  <name>cluster1_node1</name>
  <devices>
    <interface type='network'>
      <source network='default'/>
    </interface>
  </devices>
</domain>
"""


# ---------------------------------------------------------------------------
# discover_tap_interface
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_discover_tap_interface_returns_tap_device_name():
    """discover_tap_interface parses domain XML to find the tap device for a bridge."""
    mock_client = MagicMock()
    mock_client.lookup_domain = AsyncMock(return_value=MagicMock())
    mock_client.get_domain_xml = AsyncMock(return_value=_DOMAIN_XML_WITH_BRIDGE)

    result = await discover_tap_interface(mock_client, "cluster1", "node1", "virbr0")

    assert result == "vnet3"


@pytest.mark.asyncio
async def test_discover_tap_interface_selects_correct_bridge_in_multi_interface():
    """discover_tap_interface returns the tap for the matching bridge when multiple exist."""
    mock_client = MagicMock()
    mock_client.lookup_domain = AsyncMock(return_value=MagicMock())
    mock_client.get_domain_xml = AsyncMock(return_value=_DOMAIN_XML_MULTI_INTERFACE)

    result = await discover_tap_interface(mock_client, "cluster1", "node1", "virbr0")

    assert result == "vnet3"


@pytest.mark.asyncio
async def test_discover_tap_interface_raises_when_domain_not_found():
    """discover_tap_interface raises RuntimeError if the domain does not exist."""
    mock_client = MagicMock()
    mock_client.lookup_domain = AsyncMock(return_value=None)

    with pytest.raises(RuntimeError, match="not found"):
        await discover_tap_interface(mock_client, "cluster1", "node1", "virbr0")


@pytest.mark.asyncio
async def test_discover_tap_interface_raises_when_bridge_not_in_domain_xml():
    """discover_tap_interface raises RuntimeError if the bridge is absent from the domain XML."""
    mock_client = MagicMock()
    mock_client.lookup_domain = AsyncMock(return_value=MagicMock())
    mock_client.get_domain_xml = AsyncMock(return_value=_DOMAIN_XML_NO_BRIDGE)

    with pytest.raises(RuntimeError, match="No tap device"):
        await discover_tap_interface(mock_client, "cluster1", "node1", "virbr0")


# ---------------------------------------------------------------------------
# build_impairment_cmd
# ---------------------------------------------------------------------------


def test_build_impairment_cmd_starts_with_tc_netem():
    """Command must begin with tc qdisc add dev {tap} root netem."""
    cmd = build_impairment_cmd("vnet3", latency_ms=10)
    assert cmd[:7] == ["tc", "qdisc", "add", "dev", "vnet3", "root", "netem"]


def test_build_impairment_cmd_latency_and_jitter():
    """latency_ms=100, jitter_ms=10 → delay 100ms 10ms in the command."""
    cmd = build_impairment_cmd("vnet3", latency_ms=100, jitter_ms=10)
    assert "delay" in cmd
    delay_idx = cmd.index("delay")
    assert cmd[delay_idx + 1] == "100ms"
    assert cmd[delay_idx + 2] == "10ms"


def test_build_impairment_cmd_loss():
    """loss_pct=5.0 → loss 5.0% in the command."""
    cmd = build_impairment_cmd("vnet3", loss_pct=5.0)
    assert "loss" in cmd
    assert cmd[cmd.index("loss") + 1] == "5.0%"


def test_build_impairment_cmd_corrupt():
    """corrupt_pct=1.5 → corrupt 1.5% in the command."""
    cmd = build_impairment_cmd("vnet3", corrupt_pct=1.5)
    assert "corrupt" in cmd
    assert cmd[cmd.index("corrupt") + 1] == "1.5%"


def test_build_impairment_cmd_reorder():
    """reorder_pct=2.5 → reorder 2.5% in the command."""
    cmd = build_impairment_cmd("vnet3", reorder_pct=2.5)
    assert "reorder" in cmd
    assert cmd[cmd.index("reorder") + 1] == "2.5%"


def test_build_impairment_cmd_combined():
    """Combined latency, loss, and corrupt all appear in a single command."""
    cmd = build_impairment_cmd("vnet3", latency_ms=100, loss_pct=5.0, corrupt_pct=1.0)
    assert "delay" in cmd
    assert "loss" in cmd
    assert "corrupt" in cmd


def test_build_impairment_cmd_omits_zero_latency():
    """Zero latency_ms must not generate a 'delay' token (D-09)."""
    cmd = build_impairment_cmd("vnet3", latency_ms=0, loss_pct=3.0)
    assert "delay" not in cmd


def test_build_impairment_cmd_omits_zero_jitter_when_latency_nonzero():
    """Zero jitter_ms is omitted even when latency_ms is non-zero (D-09)."""
    cmd = build_impairment_cmd("vnet3", latency_ms=50, jitter_ms=0)
    assert "delay" in cmd
    delay_idx = cmd.index("delay")
    # Token immediately after "50ms" must not exist as "0ms"
    after_delay = cmd[delay_idx + 1:]
    assert "0ms" not in after_delay


def test_build_impairment_cmd_omits_zero_loss():
    """Zero loss_pct must not generate a 'loss' token (D-09)."""
    cmd = build_impairment_cmd("vnet3", latency_ms=10, loss_pct=0.0)
    assert "loss" not in cmd


def test_build_impairment_cmd_omits_zero_corrupt():
    """Zero corrupt_pct must not generate a 'corrupt' token (D-09)."""
    cmd = build_impairment_cmd("vnet3", latency_ms=10, corrupt_pct=0.0)
    assert "corrupt" not in cmd


def test_build_impairment_cmd_omits_zero_reorder():
    """Zero reorder_pct must not generate a 'reorder' token (D-09)."""
    cmd = build_impairment_cmd("vnet3", latency_ms=10, reorder_pct=0.0)
    assert "reorder" not in cmd


# ---------------------------------------------------------------------------
# apply_impairment
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_apply_impairment_calls_run_host_cmd():
    """apply_impairment calls _run_host_cmd with the provided tc command."""
    cmd = build_impairment_cmd("vnet3", latency_ms=50)
    mock_run = AsyncMock(return_value=(0, ""))
    with patch("vmcluster_mcp.net.fault._run_host_cmd", mock_run):
        await apply_impairment(cmd)
    mock_run.assert_called_once_with(cmd)


@pytest.mark.asyncio
async def test_apply_impairment_raises_on_failure():
    """apply_impairment raises RuntimeError when tc command returns non-zero exit code."""
    cmd = build_impairment_cmd("vnet3", latency_ms=50)
    mock_run = AsyncMock(return_value=(1, "RTNETLINK: Operation not permitted"))
    with patch("vmcluster_mcp.net.fault._run_host_cmd", mock_run):
        with pytest.raises(RuntimeError, match="tc command failed"):
            await apply_impairment(cmd)


# ---------------------------------------------------------------------------
# remove_impairment
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_remove_impairment_issues_tc_qdisc_del():
    """remove_impairment calls tc qdisc del dev {tap} root."""
    mock_run = AsyncMock(return_value=(0, ""))
    with patch("vmcluster_mcp.net.fault._run_host_cmd", mock_run):
        result = await remove_impairment("vnet3")
    call_args = mock_run.call_args[0][0]
    assert call_args == ["tc", "qdisc", "del", "dev", "vnet3", "root"]
    assert result is True


@pytest.mark.asyncio
async def test_remove_impairment_returns_false_when_already_absent():
    """remove_impairment returns False when tc reports no qdisc exists."""
    mock_run = AsyncMock(return_value=(2, "RTNETLINK answers: No such file or directory"))
    with patch("vmcluster_mcp.net.fault._run_host_cmd", mock_run):
        result = await remove_impairment("vnet3")
    assert result is False


# ---------------------------------------------------------------------------
# net_impair tool
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_topology_single_node():
    from vmcluster_mcp.topology.schema import NetworkSpec, NodeSpec, SshSpec, TopologySpec

    return TopologySpec(
        cluster_name="cluster1",
        base_image="/images/base.qcow2",
        overlay_dir="/overlays",
        network=NetworkSpec(name="cluster1-net", bridge="virbr0", subnet="10.0.0.0/24"),
        nodes=[
            NodeSpec(name="node1", role="worker", vcpus=2, memory_mb=2048, ip="10.0.0.1"),
            NodeSpec(name="node2", role="worker", vcpus=2, memory_mb=2048, ip="10.0.0.2"),
        ],
        ssh=SshSpec(key_path="/etc/vmcluster/ssh/id_ed25519"),
    )


@pytest.mark.asyncio
async def test_net_impair_returns_error_when_all_params_zero():
    """net_impair returns ToolResult(success=False) when all impairment params are zero."""
    from vmcluster_mcp.net import net_impair

    result = await net_impair(
        cluster_name="cluster1",
        source_node="node1",
        latency_ms=0,
        jitter_ms=0,
        loss_pct=0.0,
        corrupt_pct=0.0,
        reorder_pct=0.0,
    )

    assert result.success is False
    assert result.error is not None
    assert result.error.code == "NO_PARAMETERS"


@pytest.mark.asyncio
async def test_net_impair_returns_error_when_source_node_not_found(mock_topology_single_node):
    """net_impair returns ToolResult(success=False) when source_node is not in topology."""
    from vmcluster_mcp.net import net_impair

    with patch("vmcluster_mcp.net.load_topology", return_value=mock_topology_single_node):
        result = await net_impair(
            cluster_name="cluster1",
            source_node="node_unknown",
            latency_ms=50,
        )

    assert result.success is False
    assert result.error.code == "NODE_NOT_FOUND"


@pytest.mark.asyncio
async def test_net_impair_registers_fault_and_returns_handle(mock_topology_single_node):
    """net_impair discovers tap, applies tc, registers in FaultRegistry, returns handle."""
    from vmcluster_mcp.models import NetFaultHandle
    from vmcluster_mcp.net import net_impair

    mock_handle = NetFaultHandle(
        handle_id="test-handle-id",
        fault_type="impairment",
        affected_nodes=["node1"],
        created_at="2026-01-01T00:00:00+00:00",
    )

    with (
        patch("vmcluster_mcp.net.load_topology", return_value=mock_topology_single_node),
        patch("vmcluster_mcp.net.discover_tap_interface", new_callable=AsyncMock, return_value="vnet3"),
        patch("vmcluster_mcp.net.apply_impairment", new_callable=AsyncMock),
        patch("vmcluster_mcp.net._registry.register", return_value=mock_handle) as mock_register,
    ):
        result = await net_impair(
            cluster_name="cluster1",
            source_node="node1",
            latency_ms=100,
            jitter_ms=10,
        )

    assert result.success is True
    assert result.result == mock_handle
    mock_register.assert_called_once()
    call_kwargs = mock_register.call_args.kwargs
    assert call_kwargs["fault_type"] == "impairment"
    assert call_kwargs["rule_identifiers"] == {"interface": "vnet3"}


@pytest.mark.asyncio
async def test_net_impair_stores_tap_as_rule_identifier(mock_topology_single_node):
    """net_impair stores the tap device as rule_identifiers['interface'] for later heal."""
    from vmcluster_mcp.models import NetFaultHandle
    from vmcluster_mcp.net import net_impair

    captured_kwargs = {}

    def capture_register(**kwargs):
        captured_kwargs.update(kwargs)
        return NetFaultHandle(
            handle_id="h1", fault_type="impairment",
            affected_nodes=["node1"], created_at="2026-01-01T00:00:00+00:00",
        )

    with (
        patch("vmcluster_mcp.net.load_topology", return_value=mock_topology_single_node),
        patch("vmcluster_mcp.net.discover_tap_interface", new_callable=AsyncMock, return_value="vnet5"),
        patch("vmcluster_mcp.net.apply_impairment", new_callable=AsyncMock),
        patch("vmcluster_mcp.net._registry.register", side_effect=capture_register),
    ):
        await net_impair(cluster_name="cluster1", source_node="node1", loss_pct=10.0)

    assert captured_kwargs["rule_identifiers"]["interface"] == "vnet5"
