"""Network capture — wraps ``tcpdump`` for Phase 17 forensics.

Captures are long-running by nature: ``start`` spawns tcpdump as a
detached child, ``stop`` SIGTERMs it, ``status`` peeks. State lives
in a small text file under the case dir's ``network/`` subdir so a
fresh CLI invocation can find an in-flight capture without a
parent-process relationship.

Per FN-A0.1 decision #3: bare subprocess + PID file. Captures do
NOT survive host reboot (a future Phase 17-C could grow a long-lived
daemon if a consumer needs reboot survival).
"""

from __future__ import annotations

import errno
import json
import logging
import os
import signal
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.vm.core.common.atomic_io import atomic_write
from mgf.vm.core.exceptions import OperationError

from mgf.vm.forensics.case import Artifact, ArtifactKind

if TYPE_CHECKING:
    from collections.abc import Callable


__all__ = [
    "NetworkCaptureHandle",
    "NetworkCaptureStatus",
    "list_network_captures",
    "network_capture_status",
    "start_network_capture",
    "stop_network_capture",
]


LOG = logging.getLogger("mgf.vm.forensics.capture.network")

_DEFAULT_ROTATE_MB = 100  # tcpdump's -C default; 100 MB per rotated file


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class NetworkCaptureHandle:
    """Returned by :func:`start_network_capture`.

    All paths are absolute. ``pcap_path`` is the *primary* capture
    file; rotation produces ``capture-001.pcap``, ``capture-002.pcap``,
    etc., alongside it.
    """

    capture_id: str
    pid: int
    pcap_path: Path
    pid_path: Path
    meta_path: Path
    started_at: datetime
    bridge: str
    bpf: str


@dataclass(frozen=True)
class NetworkCaptureStatus:
    """Returned by :func:`network_capture_status`.

    ``running=False`` AND ``pid is not None`` means the PID file was
    stale (process died without cleanup); the caller can treat the
    capture as ended and finalise the artifact.
    """

    capture_id: str
    running: bool
    pid: int | None
    pcap_path: Path
    pid_path: Path
    meta_path: Path
    started_at: datetime | None
    bridge: str
    bpf: str
    bytes_captured: int  # current size of pcap file


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


def start_network_capture(
    *,
    case_dir: Path,
    bridge: str,
    bpf: str = "",
    rotate_mb: int = 0,
    capture_id: str | None = None,
    tcpdump_path: str = "tcpdump",
    popen_fn: Callable[..., subprocess.Popen[bytes]] | None = None,
    pid_is_tcpdump_fn: Callable[[int], bool] | None = None,
    now: datetime | None = None,
) -> NetworkCaptureHandle:
    """Spawn ``tcpdump`` as a detached child and persist its PID.

    ``capture_id`` defaults to a timestamped slug
    (``capture-YYYYmmddTHHMMSS``); pass an explicit value when
    you need deterministic naming (e.g., tests).

    ``rotate_mb`` of 0 disables rotation (single file). Any positive
    value passes ``-C N`` to tcpdump.

    The function returns immediately after the spawn — it does NOT
    wait for tcpdump to print its banner. Callers that want to be
    sure the capture is recording can poll :func:`network_capture_status`.
    """
    network_dir = case_dir / "network"
    network_dir.mkdir(parents=True, exist_ok=True)

    started = now or datetime.now(UTC)
    cap_id = capture_id or _generate_capture_id(started)

    pcap_path = network_dir / f"{cap_id}.pcap"
    pid_path = network_dir / f"{cap_id}.pid"
    meta_path = network_dir / f"{cap_id}.meta.json"

    # Resolve the identity check at call time so test-side
    # ``pid_is_tcpdump_fn=lambda _: True`` (or False) takes effect
    # without binding at def-time.
    identity_check = pid_is_tcpdump_fn if pid_is_tcpdump_fn is not None else _pid_is_tcpdump
    if pid_path.exists():
        # Validate the existing PID file's identity before refusing.
        # A stale ``.pid`` from a pre-reboot or pre-crash capture
        # whose PID got recycled into something unrelated should be
        # silently reclaimed, not block the new run (RA-11).
        if _pid_file_points_at_live_tcpdump(pid_path, identity_check):
            msg = (
                f"capture {cap_id!r} already has a PID file at {pid_path}. "
                f"Stop it first via `forensics capture network --stop` "
                f"or pass a different --id."
            )
            raise OperationError(msg)
        LOG.warning(
            "reclaiming stale PID file at %s",
            pid_path,
            extra={
                "capture_id": cap_id,
                "event": "network_capture.stale_pid_file",
            },
        )
        pid_path.unlink(missing_ok=True)

    argv: list[str] = [
        tcpdump_path,
        "-i",
        bridge,
        "-w",
        str(pcap_path),
        "-n",
        # -U flushes the pcap file after each packet so the file is
        # readable while the capture is running. Mild perf cost,
        # huge UX win.
        "-U",
    ]
    if rotate_mb > 0:
        argv.extend(["-C", str(rotate_mb)])
    if bpf:
        # BPF filter goes at the end of the argv as positional args.
        argv.append(bpf)

    # Resolve popen_fn at call time so test-side `patch(subprocess.Popen)`
    # actually intercepts (a default of `subprocess.Popen` would bind at
    # def-time, before any patch).
    actual_popen = popen_fn if popen_fn is not None else subprocess.Popen
    try:
        # Detach: stdout / stderr to /dev/null; new process group so
        # SIGINT to the CLI doesn't propagate.
        proc = actual_popen(
            argv,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
    except FileNotFoundError as e:
        msg = (
            f"tcpdump not found on PATH (looked at {tcpdump_path!r}). "
            f"Install via `sudo apt install tcpdump`."
        )
        raise OperationError(msg) from e

    metadata: dict[str, Any] = {
        "capture_id": cap_id,
        "pid": proc.pid,
        "bridge": bridge,
        "bpf": bpf,
        "rotate_mb": rotate_mb,
        "started_at": started.isoformat(),
        "tcpdump_path": tcpdump_path,
        "argv": list(argv),
    }
    # Atomic write via the shared helper (RA-01) chmods the temp file
    # BEFORE the rename, closing the window where the chmod-after-write
    # order previously left these world-readable. Captures are
    # write-once + the readers (_load_meta, network_capture_status)
    # degrade gracefully on a missing file, so ``fsync_dir=False`` is
    # fine — losing the rename across power loss is no worse than
    # the capture never having existed.
    atomic_write(pid_path, str(proc.pid), mode=0o600)
    atomic_write(meta_path, json.dumps(metadata, indent=2), mode=0o600)

    return NetworkCaptureHandle(
        capture_id=cap_id,
        pid=proc.pid,
        pcap_path=pcap_path,
        pid_path=pid_path,
        meta_path=meta_path,
        started_at=started,
        bridge=bridge,
        bpf=bpf,
    )


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


def stop_network_capture(
    *,
    case_dir: Path,
    capture_id: str,
    kill_fn: Callable[[int, int], None] = os.kill,
    sleep_fn: Callable[[float], None] = time.sleep,
    settle_seconds: float = 2.0,
    settle_poll_seconds: float = 0.1,
    pid_is_tcpdump_fn: Callable[[int], bool] | None = None,
    now: datetime | None = None,
) -> Artifact:
    """SIGTERM the named capture, wait for exit, return the artifact.

    Idempotent: if the PID file is missing AND the pcap exists, we
    treat the capture as already stopped and just rebuild the
    Artifact from the on-disk pcap. If neither exists, raises
    :class:`OperationError`.

    Identity-gated (RA-11): if the PID file points at a process that
    is alive but **not** ``tcpdump`` — typically a recycled PID after
    a reboot or PID-table wrap — we refuse to signal the bystander.
    The PID file is treated as stale (unlinked + logged) and the
    function returns through the already-stopped path.
    """
    network_dir = case_dir / "network"
    pid_path = network_dir / f"{capture_id}.pid"
    pcap_path = network_dir / f"{capture_id}.pcap"
    meta_path = network_dir / f"{capture_id}.meta.json"

    pid: int | None = None
    if pid_path.exists():
        try:
            pid = int(pid_path.read_text(encoding="utf-8").strip())
        except (OSError, ValueError):
            LOG.warning(
                "capture PID file unreadable; treating as stopped",
                extra={"path": str(pid_path)},
                exc_info=True,
            )
            pid = None

    # RA-11 identity gate: if the PID is alive but not tcpdump, the
    # file is stale (recycled PID). Refuse to signal the bystander —
    # unlink the file and fall through to the already-stopped path.
    # Resolve at call time so test injection takes effect.
    identity_check = pid_is_tcpdump_fn if pid_is_tcpdump_fn is not None else _pid_is_tcpdump
    if pid is not None and _process_alive(pid) and not identity_check(pid):
        LOG.warning(
            "stale PID file: pid %d is alive but not tcpdump; treating capture as already stopped",
            pid,
            extra={
                "capture_id": capture_id,
                "pid": pid,
                "event": "network_capture.stale_pid_file",
            },
        )
        pid_path.unlink(missing_ok=True)
        pid = None

    if pid is not None:
        try:
            kill_fn(pid, signal.SIGTERM)
        except OSError as e:
            if e.errno == errno.ESRCH:
                # Process already gone. Stale PID file. Continue.
                pid = None
            elif e.errno == errno.EPERM:
                msg = (
                    f"refusing to stop capture {capture_id!r}: "
                    f"PID {pid} is not killable from this user. "
                    f"Was tcpdump started under sudo?"
                )
                raise OperationError(msg) from e
            else:
                raise OperationError(
                    f"failed to SIGTERM tcpdump PID {pid}: {e}",
                ) from e

    # Poll for the process to actually exit before we finalise.
    if pid is not None:
        elapsed = 0.0
        while elapsed < settle_seconds:
            if not _process_alive(pid):
                break
            sleep_fn(settle_poll_seconds)
            elapsed += settle_poll_seconds
        else:
            # Hard kill if SIGTERM didn't take.
            try:
                kill_fn(pid, signal.SIGKILL)
            except OSError:
                LOG.warning("SIGKILL also failed", extra={"pid": pid}, exc_info=True)

    # Clean up the PID file (idempotent — missing is fine).
    pid_path.unlink(missing_ok=True)

    # Build the Artifact.
    if not pcap_path.exists():
        msg = (
            f"no pcap file at {pcap_path} for capture {capture_id!r}. "
            f"The capture may never have started."
        )
        raise OperationError(msg)

    metadata = _load_meta(meta_path)
    captured_at = now or datetime.now(UTC)
    artifact_metadata: dict[str, Any] = {
        "capture_id": capture_id,
        "bridge": metadata.get("bridge", ""),
        "bpf": metadata.get("bpf", ""),
        "rotate_mb": metadata.get("rotate_mb", 0),
        "started_at": metadata.get("started_at", captured_at.isoformat()),
    }
    return Artifact(
        kind=ArtifactKind.NETWORK_PCAP,
        path=pcap_path,
        sha256=_hash_file(pcap_path),
        size_bytes=pcap_path.stat().st_size,
        captured_at=captured_at,
        captured_by=metadata.get("tcpdump_path", "tcpdump"),
        metadata=artifact_metadata,
    )


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


def network_capture_status(
    *,
    case_dir: Path,
    capture_id: str,
) -> NetworkCaptureStatus:
    """Read the PID file + check whether the process is alive."""
    network_dir = case_dir / "network"
    pid_path = network_dir / f"{capture_id}.pid"
    pcap_path = network_dir / f"{capture_id}.pcap"
    meta_path = network_dir / f"{capture_id}.meta.json"

    if not pid_path.exists() and not pcap_path.exists():
        msg = f"no capture named {capture_id!r}"
        raise OperationError(msg)

    pid: int | None = None
    if pid_path.exists():
        try:
            pid = int(pid_path.read_text(encoding="utf-8").strip())
        except (OSError, ValueError):
            pid = None

    running = pid is not None and _process_alive(pid)
    metadata = _load_meta(meta_path)
    started_at_raw = metadata.get("started_at")
    started_at: datetime | None = None
    if started_at_raw:
        try:
            started_at = datetime.fromisoformat(str(started_at_raw))
        except ValueError:
            started_at = None

    bytes_captured = pcap_path.stat().st_size if pcap_path.exists() else 0

    return NetworkCaptureStatus(
        capture_id=capture_id,
        running=running,
        pid=pid,
        pcap_path=pcap_path,
        pid_path=pid_path,
        meta_path=meta_path,
        started_at=started_at,
        bridge=str(metadata.get("bridge", "")),
        bpf=str(metadata.get("bpf", "")),
        bytes_captured=bytes_captured,
    )


def list_network_captures(case_dir: Path) -> tuple[NetworkCaptureStatus, ...]:
    """Enumerate every capture under ``<case>/network/``.

    Walks the network/ subdir for ``*.meta.json`` files; PID files
    without metadata are ignored (stray state from earlier crashed
    captures).
    """
    network_dir = case_dir / "network"
    if not network_dir.exists():
        return ()
    captures = []
    for meta_path in sorted(network_dir.glob("*.meta.json")):
        cap_id = meta_path.name.removesuffix(".meta.json")
        try:
            captures.append(
                network_capture_status(case_dir=case_dir, capture_id=cap_id),
            )
        except OperationError:
            continue
    return tuple(captures)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _generate_capture_id(now: datetime) -> str:
    return f"capture-{now.strftime('%Y%m%dT%H%M%S')}"


def _process_alive(pid: int) -> bool:
    """Return True if ``pid`` is still running.

    Uses ``kill(pid, 0)`` — POSIX semantics: signal 0 doesn't deliver
    a signal but does the permission + existence check. ESRCH means
    the process is gone; EPERM means it's alive but we can't signal
    it (which is "alive" for our purposes).
    """
    try:
        os.kill(pid, 0)
    except OSError as e:
        if e.errno == errno.ESRCH:
            return False
        if e.errno == errno.EPERM:
            return True
        raise
    return True


def _pid_is_tcpdump(pid: int) -> bool:
    """Return True iff ``/proc/<pid>/comm`` reads as ``tcpdump``.

    Liveness alone (``kill -0``) is not enough — a recycled PID after
    a reboot or PID-table wrap is "alive" but belongs to a different
    process. RA-11 added this identity probe so
    :func:`stop_network_capture` doesn't SIGTERM an innocent
    bystander and :func:`start_network_capture` silently reclaims
    its own stale ``.pid`` file instead of refusing the new run.

    Best-effort. On platforms without ``/proc`` (macOS, Windows), or
    if the comm file is unreadable (race, permission), returns
    ``False`` — the safe default: do not signal.
    """
    try:
        comm = (Path("/proc") / str(pid) / "comm").read_text(encoding="utf-8").strip()
    except OSError:
        return False
    return comm == "tcpdump"


def _pid_file_points_at_live_tcpdump(
    pid_path: Path,
    pid_is_tcpdump_fn: Callable[[int], bool],
) -> bool:
    """Return True iff ``pid_path`` contains a PID that is alive AND tcpdump.

    Used by :func:`start_network_capture` to distinguish a real
    running capture (refuse the new run) from a stale ``.pid``
    file (silently reclaim).
    """
    try:
        pid = int(pid_path.read_text(encoding="utf-8").strip())
    except (OSError, ValueError):
        return False
    return _process_alive(pid) and pid_is_tcpdump_fn(pid)


def _load_meta(meta_path: Path) -> dict[str, Any]:
    if not meta_path.exists():
        return {}
    try:
        raw = json.loads(meta_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    if not isinstance(raw, dict):
        return {}
    return raw


def _hash_file(path: Path, *, chunk_size: int = 1 << 16) -> str:
    """Streaming SHA-256 — same idiom as manifest.py."""
    import hashlib

    h = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()
