"""Tests for :mod:`mgf.vm.forensics.live` (Phase 17, FN-Chunk-5b).

Covers the pure-function pcap parser, the rolling-counter formatter,
and both live commands with subprocess fully mocked. Real ``tcpdump``
is never spawned.
"""

from __future__ import annotations

import contextlib
import io
import struct
import subprocess
import threading
from typing import IO, TYPE_CHECKING

import pytest
from mgf.vm.core.exceptions import OperationError

from mgf.vm.forensics.live import (
    DEFAULT_STATS_INTERVAL,
    LiveCounter,
    build_ipv4_packet,
    build_pcap_stream,
    format_stats,
    live_network_stats,
    live_network_tail,
    update_counter_from_pcap_chunk,
)

if TYPE_CHECKING:
    from collections.abc import Iterator


# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------


class _FakePopen:
    """Stand-in for subprocess.Popen with a controllable stdout."""

    def __init__(self, stdout_bytes: bytes = b"") -> None:
        self.pid = 4242
        self.stdout: IO[bytes] | None = io.BytesIO(stdout_bytes)
        self._poll_value: int | None = None
        self.terminate_calls = 0
        self.wait_calls = 0
        self._exit_code = 0

    def poll(self) -> int | None:
        return self._poll_value

    def terminate(self) -> None:
        self.terminate_calls += 1
        self._poll_value = self._exit_code

    def wait(self, timeout: float | None = None) -> int:
        self.wait_calls += 1
        del timeout
        self._poll_value = self._exit_code
        return self._exit_code


class _Clock:
    """Manual clock that returns successive values from a list."""

    def __init__(self, values: list[float]) -> None:
        self._values: Iterator[float] = iter(values)
        self._last = 0.0

    def __call__(self) -> float:
        with contextlib.suppress(StopIteration):
            self._last = next(self._values)
        return self._last


# ---------------------------------------------------------------------------
# update_counter_from_pcap_chunk — pure function
# ---------------------------------------------------------------------------


class TestUpdateCounter:
    def test_consumes_global_header_then_records(self) -> None:
        # 3 IPv4 packets from 10.0.0.1 → 10.0.0.2.
        pkts = [
            (1.0, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")),
            (1.5, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")),
            (2.0, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")),
        ]
        stream = build_pcap_stream(pkts)
        counter = LiveCounter()

        update_counter_from_pcap_chunk(counter, stream)

        assert counter.packet_count == 3
        assert counter.by_l3["ipv4"] == 3
        assert counter.by_l4["tcp"] == 3
        assert counter.pairs[("10.0.0.1", "10.0.0.2")] == 3
        assert counter.started_at == pytest.approx(1.0, abs=0.001)
        assert counter.last_packet_at == pytest.approx(2.0, abs=0.001)

    def test_handles_partial_chunks(self) -> None:
        pkts = [
            (1.0, build_ipv4_packet(src="1.1.1.1", dst="2.2.2.2", proto=17)),
            (1.5, build_ipv4_packet(src="1.1.1.1", dst="2.2.2.2", proto=17)),
        ]
        stream = build_pcap_stream(pkts)

        counter = LiveCounter()
        # Feed it in 7-byte chunks to verify the buffer logic.
        for i in range(0, len(stream), 7):
            update_counter_from_pcap_chunk(counter, stream[i : i + 7])

        assert counter.packet_count == 2
        assert counter.by_l4["udp"] == 2

    def test_unsupported_magic_disables_counter_silently(self) -> None:
        counter = LiveCounter()
        bogus = b"\x00" * 32  # Not the pcap magic
        update_counter_from_pcap_chunk(counter, bogus)
        assert counter.packet_count == 0
        # Feeding more bytes after the bad magic should still not count.
        update_counter_from_pcap_chunk(counter, b"\x00" * 100)
        assert counter.packet_count == 0

    def test_arp_counted_as_l3(self) -> None:
        # ARP frame: 14-byte Ethernet header with type=0x0806.
        eth_arp = b"\x00" * 12 + struct.pack(">H", 0x0806) + b"\x00" * 28
        stream = build_pcap_stream([(1.0, eth_arp)])
        counter = LiveCounter()
        update_counter_from_pcap_chunk(counter, stream)
        assert counter.by_l3["arp"] == 1

    def test_truncated_record_holds_until_more_bytes_arrive(self) -> None:
        pkts = [(1.0, build_ipv4_packet(src="1.1.1.1", dst="2.2.2.2"))]
        stream = build_pcap_stream(pkts)
        counter = LiveCounter()

        # Feed everything except the last byte.
        update_counter_from_pcap_chunk(counter, stream[:-1])
        assert counter.packet_count == 0

        # Now feed the last byte.
        update_counter_from_pcap_chunk(counter, stream[-1:])
        assert counter.packet_count == 1


# ---------------------------------------------------------------------------
# format_stats
# ---------------------------------------------------------------------------


class TestFormatStats:
    def test_no_packets_message(self) -> None:
        assert format_stats(LiveCounter()) == "(no packets yet)"

    def test_renders_summary_l3_l4_and_top_pairs(self) -> None:
        counter = LiveCounter()
        stream = build_pcap_stream(
            [
                (1.0, build_ipv4_packet(src="10.0.0.1", dst="8.8.8.8")),
                (1.1, build_ipv4_packet(src="10.0.0.1", dst="8.8.8.8")),
                (1.2, build_ipv4_packet(src="10.0.0.2", dst="1.1.1.1", proto=17)),
            ]
        )
        update_counter_from_pcap_chunk(counter, stream)

        rendered = format_stats(counter, top_n=5)
        assert "packets=3" in rendered
        assert "ipv4=3" in rendered
        assert "tcp=2" in rendered
        assert "udp=1" in rendered
        assert "10.0.0.1" in rendered
        assert "8.8.8.8" in rendered

    def test_top_n_limits_pair_output(self) -> None:
        counter = LiveCounter()
        pkts = [
            (1.0 + i / 10, build_ipv4_packet(src=f"10.0.0.{i}", dst="9.9.9.9")) for i in range(1, 6)
        ]
        update_counter_from_pcap_chunk(counter, build_pcap_stream(pkts))
        rendered = format_stats(counter, top_n=2)
        # Only 2 pair lines plus the "top N talkers:" header.
        pair_lines = [line for line in rendered.splitlines() if "→" in line]
        assert len(pair_lines) == 2


# ---------------------------------------------------------------------------
# live_network_tail
# ---------------------------------------------------------------------------


class TestLiveNetworkTail:
    def test_copies_stdout_to_output(self) -> None:
        proc = _FakePopen(stdout_bytes=b"line one\nline two\n")
        out = io.StringIO()

        live_network_tail(
            bridge="virbr-vman-q",
            popen_fn=lambda *_a, **_kw: proc,
            output=out,
        )
        assert out.getvalue() == "line one\nline two\n"
        assert proc.terminate_calls >= 0  # no-op since proc already EOF'd
        # Cleanup ran via finally.

    def test_argv_includes_bridge_and_sudo(self) -> None:
        seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: object) -> _FakePopen:
            seen.append(list(argv))
            return _FakePopen(stdout_bytes=b"")

        live_network_tail(
            bridge="br0",
            popen_fn=popen,  # type: ignore[arg-type]
            output=io.StringIO(),
        )
        assert seen[0] == ["sudo", "-n", "tcpdump", "-i", "br0", "-nl"]

    def test_no_sudo_argv(self) -> None:
        seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: object) -> _FakePopen:
            seen.append(list(argv))
            return _FakePopen(stdout_bytes=b"")

        live_network_tail(
            bridge="br0",
            sudo=False,
            popen_fn=popen,  # type: ignore[arg-type]
            output=io.StringIO(),
        )
        assert seen[0] == ["tcpdump", "-i", "br0", "-nl"]

    def test_stop_event_breaks_loop(self) -> None:
        # Stop event is already set, so the loop's first iteration
        # exits BEFORE reading from stdout. The "infinite stream" we'd
        # otherwise consume is never touched.
        stop = threading.Event()
        stop.set()
        proc = _FakePopen(stdout_bytes=b"infinite stream")
        out = io.StringIO()
        live_network_tail(
            bridge="br0",
            popen_fn=lambda *_a, **_kw: proc,
            output=out,
            stop_event=stop,
        )
        # No bytes copied because we broke before the first read.
        assert out.getvalue() == ""
        # Cleanup ran (proc was still "running" by the fake's poll).
        assert proc.terminate_calls == 1

    def test_filenotfound_raises_actionable(self) -> None:
        def popen(*_a: object, **_kw: object) -> _FakePopen:
            raise FileNotFoundError(2, "No such file or directory", "tcpdump")

        with pytest.raises(OperationError) as exc:
            live_network_tail(
                bridge="br0",
                popen_fn=popen,
                output=io.StringIO(),
            )
        assert "tcpdump" in str(exc.value)
        assert "apt install" in str(exc.value)


# ---------------------------------------------------------------------------
# live_network_stats
# ---------------------------------------------------------------------------


class TestLiveNetworkStats:
    def test_argv_uses_w_dash_for_pcap_stream(self) -> None:
        seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: object) -> _FakePopen:
            seen.append(list(argv))
            return _FakePopen(stdout_bytes=b"")

        live_network_stats(
            bridge="br0",
            popen_fn=popen,  # type: ignore[arg-type]
            output=io.StringIO(),
            clock_fn=_Clock([0.0, 1.5]),
        )
        assert seen[0] == [
            "sudo",
            "-n",
            "tcpdump",
            "-i",
            "br0",
            "-U",
            "-w",
            "-",
        ]

    def test_accumulates_counters_from_stream(self) -> None:
        pkts = [
            (1.0, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")),
            (1.5, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")),
            (2.0, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2", proto=17)),
        ]
        stream = build_pcap_stream(pkts)
        proc = _FakePopen(stdout_bytes=stream)
        out = io.StringIO()

        counter = live_network_stats(
            bridge="br0",
            popen_fn=lambda *_a, **_kw: proc,
            output=out,
            clock_fn=_Clock([0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]),
            interval=0.5,
        )
        assert counter.packet_count == 3
        assert counter.by_l4["tcp"] == 2
        assert counter.by_l4["udp"] == 1
        # Output got a final render at minimum.
        rendered = out.getvalue()
        assert "packets=3" in rendered
        assert "br0" in rendered

    def test_renders_at_interval_boundary(self) -> None:
        # Build a stream large enough that the loop iterates multiple
        # times. With interval=0.1 and a clock that ticks 0.2 per read,
        # we should render between reads.
        pkts = [
            (1.0 + i * 0.01, build_ipv4_packet(src="10.0.0.1", dst="10.0.0.2")) for i in range(5)
        ]
        stream = build_pcap_stream(pkts)
        proc = _FakePopen(stdout_bytes=stream)
        out = io.StringIO()

        # 0.0 = initial, 0.0 = pre-loop last_render, then ticks at 0.2 each.
        clock_values = [0.0, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
        live_network_stats(
            bridge="br0",
            popen_fn=lambda *_a, **_kw: proc,
            output=out,
            clock_fn=_Clock(clock_values),
            interval=0.1,
        )
        # Multiple "live stats — bridge=br0" headers signal multiple renders.
        rendered = out.getvalue()
        assert rendered.count("live stats — bridge=br0") >= 2

    def test_default_interval_is_one_hz(self) -> None:
        assert DEFAULT_STATS_INTERVAL == 1.0


# ---------------------------------------------------------------------------
# Edge: Popen without a stdout pipe
# ---------------------------------------------------------------------------


class _PopenNoStdout(_FakePopen):
    def __init__(self) -> None:
        super().__init__(stdout_bytes=b"")
        self.stdout = None


class TestNoStdoutPipe:
    def test_tail_raises_clean_error(self) -> None:
        with pytest.raises(OperationError, match="without a stdout pipe"):
            live_network_tail(
                bridge="br0",
                popen_fn=lambda *_a, **_kw: _PopenNoStdout(),
                output=io.StringIO(),
            )

    def test_stats_raises_clean_error(self) -> None:
        with pytest.raises(OperationError, match="without a stdout pipe"):
            live_network_stats(
                bridge="br0",
                popen_fn=lambda *_a, **_kw: _PopenNoStdout(),
                output=io.StringIO(),
            )


# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------


class TestCleanup:
    def test_terminate_called_when_proc_still_running(self) -> None:
        # poll returns None ⇒ still running. terminate transitions it
        # to "exited" via the fake.
        class _Running(_FakePopen):
            def __init__(self) -> None:
                super().__init__(stdout_bytes=b"chunk\n")
                # Don't auto-EOF; leave poll==None until terminate fires.

        proc = _Running()
        # We rely on read returning b"chunk\n" then b"" (EOF).
        live_network_tail(
            bridge="br0",
            popen_fn=lambda *_a, **_kw: proc,
            output=io.StringIO(),
        )
        # The fake's terminate() flips poll to non-None, so cleanup
        # ran exactly once.
        assert proc.terminate_calls == 1

    def test_no_terminate_when_proc_already_exited(self) -> None:
        class _Exited(_FakePopen):
            def __init__(self) -> None:
                super().__init__(stdout_bytes=b"")
                # Mark as already exited.
                self._poll_value = 0  # type: ignore[assignment]

        proc = _Exited()
        live_network_tail(
            bridge="br0",
            popen_fn=lambda *_a, **_kw: proc,
            output=io.StringIO(),
        )
        assert proc.terminate_calls == 0


# ---------------------------------------------------------------------------
# Sanity: real subprocess module never imported by the live module path
# ---------------------------------------------------------------------------


class TestSubprocessIsolation:
    def test_no_real_popen_invocation(self) -> None:
        """If popen_fn= is passed, subprocess.Popen must never be called."""
        # We stub at the test boundary by passing popen_fn — verify
        # subprocess.Popen was not invoked side-channel.
        invoked: list[bool] = []

        original = subprocess.Popen

        def trap(*_a: object, **_kw: object) -> object:
            invoked.append(True)
            return original(*_a, **_kw)  # type: ignore[no-any-return]

        # Patch subprocess.Popen via monkey to verify it's never reached.
        # We don't use monkeypatch here since the function takes popen_fn
        # explicitly; this is a defence-in-depth check.
        try:
            subprocess.Popen = trap  # type: ignore[misc]
            live_network_tail(
                bridge="br0",
                popen_fn=lambda *_a, **_kw: _FakePopen(stdout_bytes=b""),
                output=io.StringIO(),
            )
        finally:
            subprocess.Popen = original  # type: ignore[misc]
        assert invoked == []
