"""Tests for collect_dmesg function and dmesg_collect MCP tool."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from vmcluster_mcp.models import DmesgResult, ToolResult
from vmcluster_mcp.observe.dmesg import collect_dmesg


# --- Test data ---

# Raw dmesg lines at various log levels
_RAW_INFO       = "<6>[    1.000001] Linux kernel started"
_RAW_NOTICE     = "<5>[    1.000002] Calibrating delay loop"
_RAW_WARNING    = "<4>[    2.000003] WARNING: something suspicious happened here"
_RAW_ERR        = "<3>[    3.000004] error: disk read failure"
_RAW_CRIT       = "<2>[    4.000005] critical: memory subsystem error"
_RAW_OOPS       = "<3>[    5.000006] Oops: general protection fault, probably for non-canonical address"
_RAW_PANIC      = "<1>[    6.000007] Kernel panic - not syncing: Fatal exception in interrupt"
_RAW_KASAN      = "<3>[    7.000008] KASAN: slab-out-of-bounds in kmalloc+0x10"
_RAW_UBSAN      = "<3>[    8.000009] UBSAN: array-index-out-of-bounds"
_GARBAGE_LINE   = "this is not a valid dmesg line at all"
_MARKER_LINE    = "<6>[   10.000001] vmcluster-marker-abc123"

_BASIC_DMESG_OUTPUT = "\n".join([
    _RAW_INFO,
    _RAW_NOTICE,
    _RAW_WARNING,
    _RAW_ERR,
])

_MIXED_DMESG_OUTPUT = "\n".join([
    _RAW_INFO,
    _RAW_OOPS,
    _RAW_PANIC,
    _RAW_KASAN,
    _RAW_UBSAN,
    _RAW_WARNING,
    _RAW_ERR,
    _RAW_CRIT,
])

_MARKER_WITH_CONTENT = "\n".join([
    _RAW_INFO,
    _RAW_NOTICE,
    _MARKER_LINE,
    _RAW_WARNING,
    _RAW_ERR,
])


def _make_ssh_result(stdout: str, exit_code: int = 0) -> MagicMock:
    r = MagicMock()
    r.exit_code = exit_code
    r.stdout = stdout
    r.stderr = ""
    return r


def _make_ssh_manager(stdout: str, exit_code: int = 0) -> MagicMock:
    mgr = MagicMock()
    mgr.execute = AsyncMock(return_value=_make_ssh_result(stdout, exit_code))
    return mgr


# -------------------------------------------------------------------------
# collect_dmesg function tests (unit)
# -------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_collect_basic_parses_lines():
    """collect_dmesg returns DmesgResult with parsed lines from SSH output."""
    mgr = _make_ssh_manager(_BASIC_DMESG_OUTPUT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key")

    assert isinstance(result, DmesgResult)
    assert len(result.lines) == 4
    texts = [l.text for l in result.lines]
    assert any("Linux kernel started" in t for t in texts)
    assert any("WARNING:" in t for t in texts)


@pytest.mark.asyncio
async def test_collect_marker_filters_before_marker():
    """Lines before and including the marker line are excluded from result."""
    mgr = _make_ssh_manager(_MARKER_WITH_CONTENT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key",
                                  since_marker="vmcluster-marker-abc123")

    assert isinstance(result, DmesgResult)
    # Only lines AFTER the marker should be included
    assert len(result.lines) == 2
    texts = [l.text for l in result.lines]
    assert any("WARNING:" in t for t in texts)
    assert any("disk read failure" in t for t in texts)
    # Lines before marker must not appear
    assert not any("Linux kernel started" in t for t in texts)
    assert not any("Calibrating" in t for t in texts)


@pytest.mark.asyncio
async def test_collect_marker_not_found_returns_empty():
    """When since_marker is not found in output, returns DmesgResult with empty lines."""
    mgr = _make_ssh_manager(_BASIC_DMESG_OUTPUT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key",
                                  since_marker="vmcluster-marker-nonexistent")

    assert isinstance(result, DmesgResult)
    assert result.lines == []
    assert result.oops_detected is False
    assert result.panic_detected is False
    assert result.warning_count == 0
    assert result.error_count == 0


@pytest.mark.asyncio
async def test_collect_filter_level_warn_plus():
    """filter_level='warn+' includes only warning, err, crit, alert, emerg lines."""
    output = "\n".join([_RAW_INFO, _RAW_NOTICE, _RAW_WARNING, _RAW_ERR, _RAW_CRIT])
    mgr = _make_ssh_manager(output)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key",
                                  filter_level="warn+")

    levels = {l.level for l in result.lines}
    # info and notice should be excluded
    assert "info" not in levels
    assert "notice" not in levels
    # warning, err, crit should be included
    assert "warning" in levels
    assert "err" in levels
    assert "crit" in levels
    assert len(result.lines) == 3


@pytest.mark.asyncio
async def test_collect_filter_level_err_plus():
    """filter_level='err+' includes only err, crit, alert, emerg lines."""
    output = "\n".join([_RAW_INFO, _RAW_WARNING, _RAW_ERR, _RAW_CRIT])
    mgr = _make_ssh_manager(output)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key",
                                  filter_level="err+")

    levels = {l.level for l in result.lines}
    assert "info" not in levels
    assert "warning" not in levels
    assert "err" in levels
    assert "crit" in levels
    assert len(result.lines) == 2


@pytest.mark.asyncio
async def test_collect_filter_level_all_includes_everything():
    """filter_level='all' (default) includes all parseable lines."""
    mgr = _make_ssh_manager(_BASIC_DMESG_OUTPUT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key",
                                  filter_level="all")

    assert len(result.lines) == 4


@pytest.mark.asyncio
async def test_collect_classification_applied():
    """Lines are classified: oops/panic/kasan/ubsan/warn/normal set on classified_as."""
    mgr = _make_ssh_manager(_MIXED_DMESG_OUTPUT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key")

    by_classification = {l.classified_as for l in result.lines}
    assert "oops" in by_classification
    assert "panic" in by_classification
    assert "kasan" in by_classification
    assert "ubsan" in by_classification
    assert "warn" in by_classification


@pytest.mark.asyncio
async def test_collect_result_summary_fields():
    """DmesgResult summary fields derived correctly from classified lines."""
    mgr = _make_ssh_manager(_MIXED_DMESG_OUTPUT)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key")

    assert result.oops_detected is True
    assert result.panic_detected is True
    assert result.warning_count == 1   # one WARNING: line
    assert result.error_count >= 1     # err, crit, oops, panic all have low level numbers


@pytest.mark.asyncio
async def test_collect_iso_format_fallback():
    """If dmesg --time-format=iso returns non-zero exit, retries with plain dmesg -r."""
    mgr = MagicMock()
    iso_fail = _make_ssh_result("", exit_code=1)
    plain_ok = _make_ssh_result(_RAW_INFO, exit_code=0)
    mgr.execute = AsyncMock(side_effect=[iso_fail, plain_ok])

    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key")

    assert mgr.execute.call_count == 2
    # First call should have used --time-format=iso
    first_cmd = mgr.execute.call_args_list[0].kwargs.get("command", "")
    assert "--time-format=iso" in first_cmd
    # Second call should be plain dmesg -r
    second_cmd = mgr.execute.call_args_list[1].kwargs.get("command", "")
    assert "--time-format=iso" not in second_cmd
    # Result should have parsed the plain output
    assert len(result.lines) == 1


@pytest.mark.asyncio
async def test_collect_unparseable_lines_skipped():
    """Lines that fail parsing (garbage) are silently skipped."""
    output = "\n".join([_RAW_INFO, _GARBAGE_LINE, _RAW_ERR, ""])
    mgr = _make_ssh_manager(output)
    result = await collect_dmesg(mgr, "10.0.0.1", 22, "root", "/key")

    # Only 2 parseable lines, garbage is skipped
    assert len(result.lines) == 2


# -------------------------------------------------------------------------
# dmesg_collect TOOL tests (integration-style)
# -------------------------------------------------------------------------


def _make_node(name: str, ip: str) -> MagicMock:
    node = MagicMock()
    node.name = name
    node.ip = ip
    return node


def _make_topology(node_specs=None):
    topo = MagicMock()
    topo.cluster_name = "test-cluster"
    topo.ssh.user = "root"
    topo.ssh.key_path = "/etc/vmcluster/ssh/id_ed25519"
    if node_specs is None:
        topo.nodes = [
            _make_node("node1", "10.0.0.1"),
            _make_node("node2", "10.0.0.2"),
        ]
    else:
        topo.nodes = node_specs
    return topo


@pytest.mark.asyncio
async def test_tool_dmesg_collect_returns_per_node_dict():
    """dmesg_collect tool returns ToolResult[dict[str, DmesgResult]] for all nodes."""
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(_BASIC_DMESG_OUTPUT))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__("test-cluster")

    assert result.success is True
    assert result.result is not None
    assert set(result.result.keys()) == {"node1", "node2"}
    assert isinstance(result.result["node1"], DmesgResult)
    assert isinstance(result.result["node2"], DmesgResult)


@pytest.mark.asyncio
async def test_tool_dmesg_collect_single_node_subset():
    """dmesg_collect with nodes=['node1'] only collects from node1."""
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(_BASIC_DMESG_OUTPUT))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__("test-cluster", ["node1"])

    assert result.success is True
    assert set(result.result.keys()) == {"node1"}
    # Only one node targeted, so execute called for 1 node (ISO attempt only, may have 1 or more)
    assert mock_mgr.execute.call_count >= 1


@pytest.mark.asyncio
async def test_tool_dmesg_collect_passes_since_marker():
    """dmesg_collect tool with since_marker returns only post-marker lines per node."""
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(_MARKER_WITH_CONTENT))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__(
            "test-cluster", since_marker="vmcluster-marker-abc123"
        )

    assert result.success is True
    # Both nodes should have only post-marker lines
    for node_result in result.result.values():
        assert len(node_result.lines) == 2


@pytest.mark.asyncio
async def test_tool_dmesg_collect_invalid_filter_level():
    """dmesg_collect with invalid filter_level returns ToolResult(success=False)."""
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(_BASIC_DMESG_OUTPUT))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__(
            "test-cluster", filter_level="invalid-level"
        )

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


@pytest.mark.asyncio
async def test_tool_dmesg_collect_invalid_node_name():
    """dmesg_collect with unknown node name returns ToolResult(success=False) with NODE_NOT_FOUND."""
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(_BASIC_DMESG_OUTPUT))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__(
            "test-cluster", ["nonexistent-node"]
        )

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


@pytest.mark.asyncio
async def test_tool_dmesg_collect_ssh_failure_one_node_graceful():
    """SSH failure on one node is handled gracefully (best-effort D-17)."""
    call_count = 0

    async def _side_effect(**kwargs):
        nonlocal call_count
        call_count += 1
        ip = kwargs.get("ip", "")
        if ip == "10.0.0.2":
            raise OSError("Connection refused")
        return _make_ssh_result(_BASIC_DMESG_OUTPUT)

    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = _side_effect
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__("test-cluster")

    # At least node1 succeeded
    assert result.success is True
    assert "node1" in result.result


@pytest.mark.asyncio
async def test_tool_dmesg_collect_warn_plus_filter():
    """dmesg_collect with filter_level='warn+' returns only warning+ lines per node."""
    output = "\n".join([_RAW_INFO, _RAW_NOTICE, _RAW_WARNING, _RAW_ERR])
    with (
        patch("vmcluster_mcp.observe.load_topology", return_value=_make_topology()),
        patch("vmcluster_mcp.observe._ssh_manager") as mock_mgr,
    ):
        mock_mgr.execute = AsyncMock(return_value=_make_ssh_result(output))
        from vmcluster_mcp.observe import dmesg_collect

        result: ToolResult = await dmesg_collect.__wrapped__(
            "test-cluster", filter_level="warn+"
        )

    assert result.success is True
    for node_result in result.result.values():
        levels = {l.level for l in node_result.lines}
        assert "info" not in levels
        assert "notice" not in levels
        assert len(node_result.lines) == 2  # warning + err
