"""Live streaming views over a quarantine bridge (FN-A2.3).

Two read-only "what is happening on the bridge right now?" tools:

* :func:`live_network_tail` — wraps ``tcpdump -i <bridge> -nl`` and
  copies its stdout to the analyst's terminal. Equivalent to ``tail -f``
  for packets.
* :func:`live_network_stats` — wraps ``tcpdump -i <bridge> -w - -U`` so
  the kernel hands us a pcap stream on stdout, parses it with dpkt,
  maintains a :class:`LiveCounter`, and prints a refresh every
  ``interval`` seconds (default 1 Hz per FN-A0.1 #7).

Neither command writes anything to the case directory — they're purely
"watch the bridge", composable with the persistent
``capture network --start`` flow (which a separate process owns).

Sudo
-----

``tcpdump`` needs ``CAP_NET_RAW`` which is normally only granted to
root or via setuid. We always prefix with ``sudo -n`` so a missing
sudoers entry surfaces as a clean error (the spawned tcpdump exits
non-zero and the loop terminates) rather than blocking on an
interactive password prompt. ``sudo=False`` is the test-time path.

Cancellation
------------

Both functions return when the underlying subprocess exits OR when a
``stop_event`` is set. Ctrl-C in the terminal sends SIGINT to the
*group*, which kills tcpdump — the function then returns cleanly.
"""

from __future__ import annotations

import contextlib
import errno
import io
import logging
import signal
import struct
import subprocess
import sys
import time
from collections import Counter
from dataclasses import dataclass, field
from typing import IO, TYPE_CHECKING, Protocol

from mgf.vm.core.exceptions import OperationError

if TYPE_CHECKING:
    import threading
    from collections.abc import Callable


__all__ = [
    "DEFAULT_STATS_INTERVAL",
    "LiveCounter",
    "format_stats",
    "live_network_stats",
    "live_network_tail",
    "update_counter_from_pcap_chunk",
]


LOG = logging.getLogger("mgf.vm.forensics.live")


DEFAULT_STATS_INTERVAL = 1.0  # FN-A0.1 #7
_PCAP_GLOBAL_HEADER_SIZE = 24
_PCAP_RECORD_HEADER_SIZE = 16
# pcap magic number we accept (little-endian + microsecond resolution).
_PCAP_MAGIC = 0xA1B2C3D4


# ---------------------------------------------------------------------------
# Counters + formatting
# ---------------------------------------------------------------------------


@dataclass
class LiveCounter:
    """Rolling counters maintained by :func:`live_network_stats`.

    Mutated in place by :func:`update_counter_from_pcap_chunk`. Read
    by :func:`format_stats` to render the per-tick display.
    """

    packet_count: int = 0
    bytes_total: int = 0
    started_at: float = 0.0
    last_packet_at: float = 0.0
    by_l3: Counter[str] = field(default_factory=Counter)
    by_l4: Counter[str] = field(default_factory=Counter)
    pairs: Counter[tuple[str, str]] = field(default_factory=Counter)
    pcap_buffer: bytes = b""
    pcap_header_seen: bool = False
    disabled: bool = False  # set when we see an unsupported pcap magic


def format_stats(counter: LiveCounter, *, top_n: int = 5) -> str:
    """Render a multi-line rolling-stats display.

    Format is intentionally plain text — no ANSI cursor magic, no
    curses. Callers wrap each refresh with ``\\x1b[2J\\x1b[H`` if they
    want a "redraw in place" UX; tests assert on substrings.
    """
    if counter.packet_count == 0:
        return "(no packets yet)"

    duration = max(counter.last_packet_at - counter.started_at, 0.001)
    pps = counter.packet_count / duration
    bps = counter.bytes_total / duration

    lines = [
        f"packets={counter.packet_count:,}  bytes={counter.bytes_total:,}  "
        f"pps={pps:.1f}  Bps={bps:,.0f}",
    ]

    if counter.by_l3:
        l3 = "  ".join(f"{k}={v}" for k, v in counter.by_l3.most_common())
        lines.append(f"L3:   {l3}")
    if counter.by_l4:
        l4 = "  ".join(f"{k}={v}" for k, v in counter.by_l4.most_common())
        lines.append(f"L4:   {l4}")

    if counter.pairs and top_n > 0:
        lines.append(f"top {top_n} talkers:")
        for (src, dst), count in counter.pairs.most_common(top_n):
            lines.append(f"  {src:>15} → {dst:<15}  packets={count:,}")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# pcap parsing — pure function, easy to unit test
# ---------------------------------------------------------------------------


def update_counter_from_pcap_chunk(
    counter: LiveCounter,
    chunk: bytes,
    *,
    now: float | None = None,
) -> None:
    """Append ``chunk`` to the counter's internal buffer; consume any
    complete pcap records.

    Tolerant of partial reads — the unconsumed tail is buffered until
    the next call. The pcap global header (24 bytes) is consumed once
    on the first chunk; subsequent calls only see record headers.
    """
    if counter.disabled:
        return
    counter.pcap_buffer += chunk

    if not counter.pcap_header_seen:
        if len(counter.pcap_buffer) < _PCAP_GLOBAL_HEADER_SIZE:
            return
        magic = struct.unpack_from("<I", counter.pcap_buffer, 0)[0]
        if magic != _PCAP_MAGIC:
            # Unknown / big-endian / nanosecond pcap. Surface, but don't
            # crash the live loop — the user just sees "(no packets yet)".
            LOG.warning(
                "unsupported pcap magic; live stats disabled for this stream",
                extra={"magic": hex(magic)},
            )
            counter.pcap_buffer = b""
            counter.pcap_header_seen = True
            counter.disabled = True
            return
        counter.pcap_buffer = counter.pcap_buffer[_PCAP_GLOBAL_HEADER_SIZE:]
        counter.pcap_header_seen = True

    while len(counter.pcap_buffer) >= _PCAP_RECORD_HEADER_SIZE:
        ts_sec, ts_usec, captured_len, _orig_len = struct.unpack_from(
            "<IIII",
            counter.pcap_buffer,
            0,
        )
        record_total = _PCAP_RECORD_HEADER_SIZE + captured_len
        if len(counter.pcap_buffer) < record_total:
            break  # wait for the rest of this record
        payload = counter.pcap_buffer[_PCAP_RECORD_HEADER_SIZE:record_total]
        counter.pcap_buffer = counter.pcap_buffer[record_total:]

        ts = ts_sec + ts_usec / 1_000_000
        _ingest_packet(counter, payload, ts=ts)

    if now is not None and counter.packet_count == 0:
        # Lets format_stats produce a non-empty "duration" when the
        # caller asked us to record an "observed at" stamp without
        # any packets having flowed yet.
        counter.last_packet_at = now


def _ingest_packet(counter: LiveCounter, payload: bytes, *, ts: float) -> None:
    """Update counters for one packet. Best-effort — bad bytes are skipped."""
    counter.packet_count += 1
    counter.bytes_total += len(payload)
    if counter.started_at == 0.0:
        counter.started_at = ts
    counter.last_packet_at = ts

    # Ethernet → IPv4 / IPv6 / ARP. Anything else lands in "other".
    if len(payload) < 14:
        counter.by_l3["other"] += 1
        return
    ether_type = (payload[12] << 8) | payload[13]
    if ether_type == 0x0800:  # IPv4
        counter.by_l3["ipv4"] += 1
        _ingest_ipv4(counter, payload[14:])
    elif ether_type == 0x86DD:  # IPv6
        counter.by_l3["ipv6"] += 1
        _ingest_ipv6(counter, payload[14:])
    elif ether_type == 0x0806:  # ARP
        counter.by_l3["arp"] += 1
    else:
        counter.by_l3["other"] += 1


def _ingest_ipv4(counter: LiveCounter, ip: bytes) -> None:
    if len(ip) < 20:
        return
    proto = ip[9]
    src = ".".join(str(b) for b in ip[12:16])
    dst = ".".join(str(b) for b in ip[16:20])
    counter.pairs[(src, dst)] += 1
    counter.by_l4[_l4_name(proto)] += 1


def _ingest_ipv6(counter: LiveCounter, ip: bytes) -> None:
    if len(ip) < 40:
        return
    proto = ip[6]  # next header
    src = _format_ipv6(ip[8:24])
    dst = _format_ipv6(ip[24:40])
    counter.pairs[(src, dst)] += 1
    counter.by_l4[_l4_name(proto)] += 1


def _l4_name(proto: int) -> str:
    return {6: "tcp", 17: "udp", 1: "icmp", 58: "icmpv6"}.get(proto, f"proto-{proto}")


def _format_ipv6(addr: bytes) -> str:
    # Compact textual form is overkill for live stats; group bytes pairwise.
    return ":".join(f"{addr[i]:02x}{addr[i + 1]:02x}" for i in range(0, 16, 2))


# ---------------------------------------------------------------------------
# Live commands
# ---------------------------------------------------------------------------


class _PopenLike(Protocol):
    """Minimal subset of ``subprocess.Popen`` we depend on."""

    pid: int
    stdout: IO[bytes] | None

    def poll(self) -> int | None: ...
    def terminate(self) -> None: ...
    def wait(self, timeout: float | None = ...) -> int: ...


def live_network_tail(
    *,
    bridge: str,
    sudo: bool = True,
    tcpdump_path: str = "tcpdump",
    popen_fn: Callable[..., _PopenLike] | None = None,
    output: IO[str] | None = None,
    stop_event: threading.Event | None = None,
    chunk_size: int = 4096,
) -> None:
    """Stream ``tcpdump -i <bridge> -nl`` to ``output`` until cancelled.

    Returns when (a) tcpdump exits, (b) ``stop_event`` is set, or (c)
    the user Ctrl-C's the parent process (which propagates SIGINT to
    the tcpdump process group via ``start_new_session=False``).
    """
    if output is None:
        output = sys.stdout

    argv = _tcpdump_text_argv(bridge=bridge, sudo=sudo, tcpdump_path=tcpdump_path)
    proc = _spawn(argv, popen_fn=popen_fn)
    try:
        if proc.stdout is None:
            msg = "tcpdump spawned without a stdout pipe"
            raise OperationError(msg)
        while True:
            if stop_event is not None and stop_event.is_set():
                break
            chunk = proc.stdout.read(chunk_size)
            if not chunk:
                break
            output.write(chunk.decode("utf-8", errors="replace"))
            output.flush()
    finally:
        _terminate(proc)


def live_network_stats(
    *,
    bridge: str,
    interval: float = DEFAULT_STATS_INTERVAL,
    sudo: bool = True,
    tcpdump_path: str = "tcpdump",
    popen_fn: Callable[..., _PopenLike] | None = None,
    output: IO[str] | None = None,
    stop_event: threading.Event | None = None,
    clock_fn: Callable[[], float] = time.monotonic,
    chunk_size: int = 8192,
    top_n: int = 5,
) -> LiveCounter:
    """Stream pcap from tcpdump and print rolling counters every ``interval`` seconds.

    Returns the final :class:`LiveCounter` so callers (tests, scripts)
    can assert on the accumulated state. The interactive use case
    discards the return value — the user reads the printed output.
    """
    if output is None:
        output = sys.stdout

    argv = _tcpdump_pcap_argv(bridge=bridge, sudo=sudo, tcpdump_path=tcpdump_path)
    proc = _spawn(argv, popen_fn=popen_fn)

    counter = LiveCounter()
    last_render = clock_fn()
    output.write(_clear_screen())
    output.write(f"live stats — bridge={bridge}  interval={interval}s\n\n")
    output.flush()

    try:
        if proc.stdout is None:
            msg = "tcpdump spawned without a stdout pipe"
            raise OperationError(msg)
        while True:
            if stop_event is not None and stop_event.is_set():
                break
            chunk = proc.stdout.read(chunk_size)
            if not chunk:
                # Render one final frame so the user sees the totals.
                _render(counter, output, bridge=bridge, interval=interval, top_n=top_n)
                break
            update_counter_from_pcap_chunk(counter, chunk, now=clock_fn())
            now = clock_fn()
            if now - last_render >= interval:
                _render(counter, output, bridge=bridge, interval=interval, top_n=top_n)
                last_render = now
    finally:
        _terminate(proc)

    return counter


# ---------------------------------------------------------------------------
# Argv builders + subprocess plumbing
# ---------------------------------------------------------------------------


def _tcpdump_text_argv(*, bridge: str, sudo: bool, tcpdump_path: str) -> list[str]:
    argv = [tcpdump_path, "-i", bridge, "-nl"]
    return _maybe_sudo(argv, sudo=sudo)


def _tcpdump_pcap_argv(*, bridge: str, sudo: bool, tcpdump_path: str) -> list[str]:
    argv = [tcpdump_path, "-i", bridge, "-U", "-w", "-"]
    return _maybe_sudo(argv, sudo=sudo)


def _maybe_sudo(argv: list[str], *, sudo: bool) -> list[str]:
    return ["sudo", "-n", *argv] if sudo else list(argv)


def _spawn(
    argv: list[str],
    *,
    popen_fn: Callable[..., _PopenLike] | None,
) -> _PopenLike:
    fn = popen_fn if popen_fn is not None else subprocess.Popen
    try:
        return fn(
            argv,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
    except FileNotFoundError as e:
        msg = (
            f"tcpdump not found on PATH (looked at {argv[2] if argv[0] == 'sudo' else argv[0]!r}). "
            f"Install via `sudo apt install tcpdump`."
        )
        raise OperationError(msg) from e
    except OSError as e:
        if e.errno == errno.EACCES:
            msg = (
                "tcpdump cannot be spawned: permission denied. "
                "Configure passwordless sudo or run as root."
            )
            raise OperationError(msg) from e
        raise


def _terminate(proc: _PopenLike) -> None:
    """Best-effort: SIGTERM, then SIGKILL, then give up.

    Cleanup never raises — we're in a finally block and the loop body
    may already be unwinding from a user Ctrl-C.
    """
    if proc.poll() is not None:
        return
    with contextlib.suppress(OSError, ProcessLookupError):
        proc.terminate()
    try:
        proc.wait(timeout=2.0)
    except subprocess.TimeoutExpired:
        with contextlib.suppress(OSError, ProcessLookupError):
            # SIGKILL via the underlying interface — Popen has .kill but
            # the protocol doesn't, so call os.kill directly.
            import os

            os.kill(proc.pid, signal.SIGKILL)
        with contextlib.suppress(subprocess.TimeoutExpired, OSError):
            proc.wait(timeout=1.0)


def _render(
    counter: LiveCounter,
    output: IO[str],
    *,
    bridge: str,
    interval: float,
    top_n: int,
) -> None:
    output.write(_clear_screen())
    output.write(f"live stats — bridge={bridge}  interval={interval}s\n\n")
    output.write(format_stats(counter, top_n=top_n))
    output.write("\n")
    output.flush()


def _clear_screen() -> str:
    """ANSI clear-screen + cursor-home. Inert on plain pipes."""
    return "\x1b[2J\x1b[H"


# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------


def build_pcap_stream(packets: list[tuple[float, bytes]]) -> bytes:
    """Build a minimal pcap byte stream: global header + record per packet.

    Used by tests to produce input for :func:`update_counter_from_pcap_chunk`
    and :func:`live_network_stats` without depending on real tcpdump.
    """
    out = io.BytesIO()
    # Global header: magic, version_major, version_minor, thiszone, sigfigs,
    #                snaplen, network (1 = LINKTYPE_ETHERNET).
    out.write(struct.pack("<I", _PCAP_MAGIC))
    out.write(struct.pack("<HH", 2, 4))
    out.write(struct.pack("<II", 0, 0))
    out.write(struct.pack("<II", 65535, 1))
    for ts, payload in packets:
        sec = int(ts)
        usec = int((ts - sec) * 1_000_000)
        out.write(struct.pack("<IIII", sec, usec, len(payload), len(payload)))
        out.write(payload)
    return out.getvalue()


def build_ipv4_packet(
    *,
    src: str,
    dst: str,
    proto: int = 6,
    payload_size: int = 0,
) -> bytes:
    """Build a synthetic Ethernet/IPv4 packet for tests."""
    # Ethernet header (dst MAC + src MAC + type=0x0800).
    eth = b"\x00" * 6 + b"\x00" * 6 + struct.pack(">H", 0x0800)
    # IPv4 header — minimal, no options. Total length = 20 + payload_size.
    ip_total_len = 20 + payload_size
    ihl_ver = (4 << 4) | 5
    src_bytes = bytes(int(b) for b in src.split("."))
    dst_bytes = bytes(int(b) for b in dst.split("."))
    ip = (
        struct.pack(
            ">BBHHHBBH",
            ihl_ver,
            0,
            ip_total_len,
            0,
            0,
            64,
            proto,
            0,
        )
        + src_bytes
        + dst_bytes
    )
    return eth + ip + b"\x00" * payload_size
