"""Tests for :mod:`mgf.vm.forensics.capture.network` (Phase 17, FN-A2.1).

Subprocess fully mocked — never spawns real tcpdump. Covers
start/stop/status idempotency, BPF + rotation argv assembly,
PID-file lifecycle, missing-tcpdump path, and the stale-PID case
where the process died without cleanup.
"""

from __future__ import annotations

import errno
import logging
import os
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

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

from mgf.vm.forensics import (
    Artifact,
    ArtifactKind,
    list_network_captures,
    network_capture_status,
    start_network_capture,
    stop_network_capture,
)

if TYPE_CHECKING:
    from pathlib import Path


class _FakePopen:
    """Minimal Popen stand-in for capture tests."""

    def __init__(self, pid: int = 12345) -> None:
        self.pid = pid

    def wait(self, timeout: float | None = None) -> int:
        return 0


def _seed_case(tmp_path: Path) -> Path:
    case_dir = tmp_path / "vm-1"
    (case_dir / "network").mkdir(parents=True, exist_ok=True)
    return case_dir


# ---------------------------------------------------------------------------
# start
# ---------------------------------------------------------------------------


class TestStartNetworkCapture:
    def test_argv_includes_bridge_and_pcap_path(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        argv_seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: Any) -> _FakePopen:
            argv_seen.append(argv)
            return _FakePopen(pid=999)

        handle = start_network_capture(
            case_dir=case_dir,
            bridge="virbr-vman-q",
            popen_fn=popen,  # type: ignore[arg-type]
            now=datetime(2026, 4, 26, 12, 0, tzinfo=UTC),
        )
        assert "-i" in argv_seen[0]
        assert "virbr-vman-q" in argv_seen[0]
        assert "-w" in argv_seen[0]
        assert str(handle.pcap_path) in argv_seen[0]
        assert handle.pid == 999
        assert handle.bridge == "virbr-vman-q"

    def test_bpf_passed_as_positional(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        argv_seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: Any) -> _FakePopen:
            argv_seen.append(argv)
            return _FakePopen()

        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            bpf="tcp port 443",
            popen_fn=popen,  # type: ignore[arg-type]
        )
        assert argv_seen[0][-1] == "tcp port 443"

    def test_rotation_passes_minus_C(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        argv_seen: list[list[str]] = []

        def popen(argv: list[str], **_kw: Any) -> _FakePopen:
            argv_seen.append(argv)
            return _FakePopen()

        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            rotate_mb=50,
            popen_fn=popen,  # type: ignore[arg-type]
        )
        assert "-C" in argv_seen[0]
        idx = argv_seen[0].index("-C")
        assert argv_seen[0][idx + 1] == "50"

    def test_pid_and_meta_files_written(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        handle = start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=42),  # type: ignore[arg-type]
            now=datetime(2026, 4, 26, 12, 0, tzinfo=UTC),
        )
        assert handle.pid_path.exists()
        assert handle.pid_path.read_text(encoding="utf-8") == "42"
        assert handle.meta_path.exists()

    def test_pid_and_meta_mode_0o600(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        handle = start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
        )
        assert handle.pid_path.stat().st_mode & 0o777 == 0o600
        assert handle.meta_path.stat().st_mode & 0o777 == 0o600

    def test_explicit_capture_id(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        handle = start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="my-id",
            popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
        )
        assert handle.capture_id == "my-id"
        assert handle.pcap_path.name == "my-id.pcap"

    def test_refuses_existing_pid_file(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        # First start succeeds. Use the test process's own PID so the
        # second start's RA-11 liveness probe sees an alive PID; with
        # ``pid_is_tcpdump_fn=lambda _: True`` the identity check then
        # treats it as a real running tcpdump (the refusal path).
        own_pid = os.getpid()
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="dup",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=own_pid),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: True,
        )
        # Second start with same id refuses — the existing PID file is
        # alive AND identity-check says "yes, that's a tcpdump".
        with pytest.raises(OperationError, match="already has a PID"):
            start_network_capture(
                case_dir=case_dir,
                bridge="virbr0",
                capture_id="dup",
                popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
                pid_is_tcpdump_fn=lambda _: True,
            )

    def test_missing_tcpdump_raises_actionable(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)

        def boom(*_a: Any, **_kw: Any) -> _FakePopen:
            raise FileNotFoundError("tcpdump")

        with pytest.raises(OperationError, match="apt install tcpdump"):
            start_network_capture(
                case_dir=case_dir,
                bridge="virbr0",
                popen_fn=boom,  # type: ignore[arg-type]
            )


# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------


class TestNetworkCaptureStatus:
    def test_unknown_capture_raises(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        with pytest.raises(OperationError, match="no capture"):
            network_capture_status(case_dir=case_dir, capture_id="ghost")

    def test_alive_capture_reports_running(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        # Use the test process's own PID — guaranteed alive.
        own_pid = os.getpid()
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="live",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=own_pid),  # type: ignore[arg-type]
        )
        status = network_capture_status(case_dir=case_dir, capture_id="live")
        assert status.running is True
        assert status.pid == own_pid

    def test_dead_pid_reports_not_running(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        # Pick a PID that is virtually guaranteed not to exist.
        # 2^31 - 1 is above any realistic PID range.
        ghost_pid = 2_147_483_646
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="dead",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=ghost_pid),  # type: ignore[arg-type]
        )
        # Touch the pcap so it exists for the size check.
        (case_dir / "network" / "dead.pcap").write_bytes(b"")
        status = network_capture_status(case_dir=case_dir, capture_id="dead")
        assert status.running is False
        assert status.pid == ghost_pid

    def test_bytes_captured_reflects_pcap_size(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="sized",
            popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
        )
        (case_dir / "network" / "sized.pcap").write_bytes(b"x" * 1024)
        status = network_capture_status(case_dir=case_dir, capture_id="sized")
        assert status.bytes_captured == 1024


# ---------------------------------------------------------------------------
# stop
# ---------------------------------------------------------------------------


class TestStopNetworkCapture:
    def test_stop_alive_capture_returns_artifact(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        kill_calls: list[tuple[int, int]] = []

        def kill(pid: int, sig: int) -> None:
            kill_calls.append((pid, sig))
            # Pretend the process exits immediately.

        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="x",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=99),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: True,
        )
        # Touch pcap so finalize finds something.
        (case_dir / "network" / "x.pcap").write_bytes(b"PCAP-PAYLOAD")

        artifact = stop_network_capture(
            case_dir=case_dir,
            capture_id="x",
            kill_fn=kill,
            sleep_fn=lambda _s: None,
            # Identity check claims the PID is a live tcpdump — happy
            # path. RA-11's stale-PID branch is covered separately by
            # ``TestStalePidIdentityCheck`` below.
            pid_is_tcpdump_fn=lambda _: True,
        )
        assert isinstance(artifact, Artifact)
        assert artifact.kind == ArtifactKind.NETWORK_PCAP
        assert artifact.size_bytes == len(b"PCAP-PAYLOAD")
        assert artifact.sha256 != ""
        # PID file cleaned up.
        assert not (case_dir / "network" / "x.pid").exists()
        # SIGTERM was sent.
        assert kill_calls[0][1] == 15  # SIGTERM

    def test_stop_idempotent_when_pid_missing(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        # Manually create a pcap without a PID file (simulate
        # already-stopped capture).
        (case_dir / "network" / "x.pcap").write_bytes(b"data")
        artifact = stop_network_capture(
            case_dir=case_dir,
            capture_id="x",
            sleep_fn=lambda _s: None,
        )
        assert artifact.size_bytes == 4

    def test_stop_with_no_pcap_raises(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        with pytest.raises(OperationError, match="no pcap"):
            stop_network_capture(
                case_dir=case_dir,
                capture_id="ghost",
                sleep_fn=lambda _s: None,
            )

    def test_stop_handles_already_dead_process(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)

        def kill(pid: int, sig: int) -> None:
            raise OSError(errno.ESRCH, "No such process")

        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="dead",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=12345),  # type: ignore[arg-type]
        )
        (case_dir / "network" / "dead.pcap").write_bytes(b"data")

        # ESRCH → process gone — should NOT raise.
        artifact = stop_network_capture(
            case_dir=case_dir,
            capture_id="dead",
            kill_fn=kill,
            sleep_fn=lambda _s: None,
        )
        assert artifact.kind == ArtifactKind.NETWORK_PCAP

    def test_stop_eperm_raises_actionable(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)

        def kill(pid: int, sig: int) -> None:
            raise OSError(errno.EPERM, "Operation not permitted")

        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="rooted",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=12345),  # type: ignore[arg-type]
        )
        (case_dir / "network" / "rooted.pcap").write_bytes(b"data")

        with pytest.raises(OperationError, match="sudo"):
            stop_network_capture(
                case_dir=case_dir,
                capture_id="rooted",
                kill_fn=kill,
                sleep_fn=lambda _s: None,
            )


# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------


class TestListNetworkCaptures:
    def test_empty_dir_returns_empty(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        assert list_network_captures(case_dir) == ()

    def test_lists_multiple(self, tmp_path: Path) -> None:
        case_dir = _seed_case(tmp_path)
        for cid in ("a", "b", "c"):
            start_network_capture(
                case_dir=case_dir,
                bridge="virbr0",
                capture_id=cid,
                popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
            )
            (case_dir / "network" / f"{cid}.pcap").write_bytes(b"")
        result = list_network_captures(case_dir)
        ids = {s.capture_id for s in result}
        assert ids == {"a", "b", "c"}


# ---------------------------------------------------------------------------
# RA-11 — PID identity check before signalling
# ---------------------------------------------------------------------------


class TestStalePidIdentityCheck:
    """Pin the RA-11 contract: ``stop_network_capture`` does NOT
    signal an unrelated process whose PID happens to match a stale
    ``.pid`` file (e.g. after a host reboot recycled the PID); and
    ``start_network_capture`` silently reclaims a stale PID file
    instead of refusing the new run.

    The hazard the audit identified: ``stop`` previously SIGTERMed
    (and then SIGKILLed) whatever process now held the recycled PID,
    and ``start`` refused to launch because the dead capture's file
    still existed.
    """

    def test_stop_refuses_to_signal_a_live_non_tcpdump_pid(
        self,
        tmp_path: Path,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """Use the test process's own PID — guaranteed alive but
        guaranteed NOT to be tcpdump."""
        case_dir = _seed_case(tmp_path)
        kill_calls: list[tuple[int, int]] = []

        def kill(pid: int, sig: int) -> None:
            kill_calls.append((pid, sig))

        own_pid = os.getpid()
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="x",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=own_pid),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: True,  # initial start is "real"
        )
        (case_dir / "network" / "x.pcap").write_bytes(b"PCAP")

        caplog.set_level(logging.WARNING, logger="mgf.vm.forensics.capture.network")
        artifact = stop_network_capture(
            case_dir=case_dir,
            capture_id="x",
            kill_fn=kill,
            sleep_fn=lambda _s: None,
            # Identity check now says: alive yes, tcpdump NO.
            pid_is_tcpdump_fn=lambda _: False,
        )

        # The bystander was NOT signalled.
        assert kill_calls == [], f"stop signalled a non-tcpdump pid: {kill_calls!r}"
        # Artifact still built from the on-disk pcap (already-stopped path).
        assert artifact.kind == ArtifactKind.NETWORK_PCAP
        # Stale PID file was cleaned up.
        assert not (case_dir / "network" / "x.pid").exists()
        # WARNING records the stale-file event with structured fields.
        events = [
            r
            for r in caplog.records
            if r.name == "mgf.vm.forensics.capture.network" and r.levelno >= logging.WARNING
        ]
        assert events, "expected a WARNING about the stale PID file"
        rec = events[0]
        assert getattr(rec, "event", None) == "network_capture.stale_pid_file"
        assert getattr(rec, "capture_id", None) == "x"
        assert getattr(rec, "pid", None) == own_pid

    def test_start_reclaims_stale_pid_file_instead_of_refusing(
        self,
        tmp_path: Path,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        case_dir = _seed_case(tmp_path)
        # Stage a stale PID file pointing at the test process (alive,
        # not tcpdump).
        own_pid = os.getpid()
        network_dir = case_dir / "network"
        network_dir.mkdir(parents=True, exist_ok=True)
        (network_dir / "y.pid").write_text(str(own_pid), encoding="utf-8")

        caplog.set_level(logging.WARNING, logger="mgf.vm.forensics.capture.network")
        # Must NOT raise — the stale file is silently reclaimed.
        handle = start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="y",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=12345),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: False,  # the stale pid is "not tcpdump"
        )
        assert handle.capture_id == "y"
        # Fresh PID file points at the new tcpdump pid, not the stale one.
        assert (network_dir / "y.pid").read_text(encoding="utf-8").strip() == "12345"
        # WARNING records the reclaim.
        events = [
            r
            for r in caplog.records
            if r.name == "mgf.vm.forensics.capture.network"
            and r.levelno >= logging.WARNING
            and getattr(r, "event", None) == "network_capture.stale_pid_file"
        ]
        assert events, "expected a stale-PID-file WARNING from start"

    def test_start_still_refuses_when_pid_is_a_live_tcpdump(
        self,
        tmp_path: Path,
    ) -> None:
        """Regression pin — RA-11 only reclaims STALE files; a real
        running tcpdump must still trigger the existing refusal."""
        case_dir = _seed_case(tmp_path)
        # First start writes a "real" tcpdump pid file.
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="z",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=os.getpid()),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: True,
        )
        # Second start with same id and identity-check claiming alive
        # tcpdump must refuse.
        with pytest.raises(OperationError, match="already has a PID"):
            start_network_capture(
                case_dir=case_dir,
                bridge="virbr0",
                capture_id="z",
                popen_fn=lambda *_a, **_kw: _FakePopen(),  # type: ignore[arg-type]
                pid_is_tcpdump_fn=lambda _: True,
            )

    def test_stop_still_signals_when_pid_is_a_live_tcpdump(
        self,
        tmp_path: Path,
    ) -> None:
        """Regression pin — healthy tcpdump captures are still
        signalled normally; the identity gate is a guard, not a
        bypass."""
        case_dir = _seed_case(tmp_path)
        kill_calls: list[tuple[int, int]] = []

        def kill(pid: int, sig: int) -> None:
            kill_calls.append((pid, sig))

        own_pid = os.getpid()
        start_network_capture(
            case_dir=case_dir,
            bridge="virbr0",
            capture_id="w",
            popen_fn=lambda *_a, **_kw: _FakePopen(pid=own_pid),  # type: ignore[arg-type]
            pid_is_tcpdump_fn=lambda _: True,
        )
        (case_dir / "network" / "w.pcap").write_bytes(b"P")

        stop_network_capture(
            case_dir=case_dir,
            capture_id="w",
            kill_fn=kill,
            sleep_fn=lambda _s: None,
            pid_is_tcpdump_fn=lambda _: True,  # alive AND tcpdump → signal
        )
        # SIGTERM was sent to the real (alleged) tcpdump.
        assert any(c[1] == 15 for c in kill_calls), kill_calls
