#!/usr/bin/env python3
"""In-guest telemetry agent — runs INSIDE the VM.

Writes one JSON-lines row per tick to a virtio-serial port that the
host has wired up as ``cis490.guest.agent``. The host-side collector
(`collectors.guest_agent`) reads these rows and stamps them with the
host's monotonic clock before persisting to ``telemetry-guest.jsonl``.

Stdlib only — no `psutil`, no extra deps to bake into the guest. Every
field is read from /proc on the guest, so this works on busybox-based
Alpine, on Cirros, and on Metasploitable2 unchanged.

Wire path inside the guest:
    /dev/virtio-ports/cis490.guest.agent

The host side opens the matching unix socket on the hypervisor.
The protocol is intentionally trivial: the agent emits newline-
delimited JSON; the host emits nothing back. One direction.

This source is the **deployable** side — every row is tagged
``available_in_deployment: true``. See docs/threat-model.md.
"""

from __future__ import annotations

import argparse
import json
import os
import platform
import sys
import time
from typing import Any


SOURCE = "guest_agent"
AVAILABLE_IN_DEPLOYMENT = True
DEFAULT_PORT = "/dev/virtio-ports/cis490.guest.agent"
DEFAULT_INTERVAL_MS = 100  # 10 Hz
DEFAULT_TOP_N = 8


# ---------- /proc parsers ---------------------------------------------------


def _read(path: str) -> str | None:
    try:
        with open(path, "rb") as f:
            return f.read().decode("ascii", errors="replace")
    except (FileNotFoundError, PermissionError):
        return None


def read_loadavg() -> tuple[float, float, float] | None:
    text = _read("/proc/loadavg")
    if text is None:
        return None
    parts = text.split()
    return float(parts[0]), float(parts[1]), float(parts[2])


def read_meminfo() -> dict[str, int]:
    text = _read("/proc/meminfo")
    out: dict[str, int] = {}
    if text is None:
        return out
    for line in text.splitlines():
        k, _, rest = line.partition(":")
        v = rest.strip()
        if v.endswith(" kB"):
            try:
                out[k] = int(v[:-3]) * 1024
            except ValueError:
                pass
    return out


def read_cpu_total() -> dict[str, int] | None:
    """First line of /proc/stat: aggregate cpu user/nice/sys/idle/...
    in jiffies since boot."""
    text = _read("/proc/stat")
    if text is None:
        return None
    line = text.splitlines()[0]
    fields = line.split()
    # cpu user nice system idle iowait irq softirq steal guest guest_nice
    if not fields or fields[0] != "cpu":
        return None
    nums = [int(x) for x in fields[1:]]
    pad = nums + [0] * max(0, 10 - len(nums))
    return {
        "user":      pad[0],
        "nice":      pad[1],
        "system":    pad[2],
        "idle":      pad[3],
        "iowait":    pad[4],
        "irq":       pad[5],
        "softirq":   pad[6],
        "steal":     pad[7],
        "guest":     pad[8],
        "guest_nice":pad[9],
    }


def read_thermal_milli_c() -> int | None:
    """Best-effort: /sys/class/thermal/thermal_zone0/temp."""
    text = _read("/sys/class/thermal/thermal_zone0/temp")
    if text is None:
        return None
    try:
        return int(text.strip())
    except ValueError:
        return None


def read_net_devs() -> dict[str, dict[str, int]]:
    """Parse /proc/net/dev → {iface: {rx_bytes, tx_bytes, rx_pkts, tx_pkts}}."""
    text = _read("/proc/net/dev")
    out: dict[str, dict[str, int]] = {}
    if text is None:
        return out
    lines = text.splitlines()
    for line in lines[2:]:
        if ":" not in line:
            continue
        name, _, rest = line.partition(":")
        name = name.strip()
        if name == "lo":
            continue
        cols = rest.split()
        if len(cols) < 16:
            continue
        out[name] = {
            "rx_bytes": int(cols[0]),
            "rx_pkts":  int(cols[1]),
            "tx_bytes": int(cols[8]),
            "tx_pkts":  int(cols[9]),
        }
    return out


def read_listen_ports() -> list[int]:
    """TCP listen sockets from /proc/net/tcp + tcp6. State 0A = LISTEN."""
    out: set[int] = set()
    for path in ("/proc/net/tcp", "/proc/net/tcp6"):
        text = _read(path)
        if not text:
            continue
        for line in text.splitlines()[1:]:
            cols = line.split()
            if len(cols) < 4:
                continue
            if cols[3] != "0A":
                continue
            local = cols[1]  # "ADDR:PORT" with PORT in hex
            _, _, port_hex = local.rpartition(":")
            try:
                out.add(int(port_hex, 16))
            except ValueError:
                pass
    return sorted(out)


def read_top_procs(top_n: int) -> list[dict[str, Any]]:
    """Top-N processes by RSS. Cheap O(N) scan of /proc."""
    procs: list[dict[str, Any]] = []
    try:
        entries = os.listdir("/proc")
    except OSError:
        return procs
    for ent in entries:
        if not ent.isdigit():
            continue
        pid = int(ent)
        stat = _read(f"/proc/{pid}/stat")
        if stat is None:
            continue
        try:
            rparen = stat.rindex(")")
            comm = stat[stat.index("(") + 1 : rparen]
            fields = stat[rparen + 2:].split()
            utime = int(fields[11])
            stime = int(fields[12])
            rss_pages = int(fields[21])
        except (ValueError, IndexError):
            continue
        procs.append({
            "pid": pid,
            "comm": comm[:32],
            "cpu_jiffies": utime + stime,
            "rss_bytes": rss_pages * os.sysconf("SC_PAGESIZE"),
        })
    procs.sort(key=lambda p: p["rss_bytes"], reverse=True)
    return procs[:top_n]


# ---------- one tick --------------------------------------------------------


def collect_once(top_n: int = DEFAULT_TOP_N) -> dict[str, Any]:
    mem = read_meminfo()
    cpu = read_cpu_total()
    load = read_loadavg()
    return {
        "t_guest_mono_ns": time.monotonic_ns(),
        "t_guest_wall_ns": time.time_ns(),
        "source": SOURCE,
        "available_in_deployment": AVAILABLE_IN_DEPLOYMENT,
        "kernel": platform.release(),
        "cpu_total_jiffies": cpu,
        "load_1m_5m_15m": list(load) if load else None,
        "mem_total_bytes":     (mem.get("MemTotal") or 0),
        "mem_available_bytes": (mem.get("MemAvailable") or 0),
        "mem_buffers_bytes":   (mem.get("Buffers") or 0),
        "mem_cached_bytes":    (mem.get("Cached") or 0),
        "swap_used_bytes":     (mem.get("SwapTotal", 0) - mem.get("SwapFree", 0)),
        "thermal_milli_c": read_thermal_milli_c(),
        "net": read_net_devs(),
        "listen_ports": read_listen_ports(),
        "top_procs": read_top_procs(top_n),
    }


# ---------- main loop -------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="cis490-guest-agent")
    p.add_argument("--port", default=DEFAULT_PORT,
                   help="virtio-serial port path inside the guest")
    p.add_argument("--interval-ms", type=int, default=DEFAULT_INTERVAL_MS)
    p.add_argument("--top-n", type=int, default=DEFAULT_TOP_N)
    p.add_argument("--once", action="store_true",
                   help="emit a single row and exit (for smoke tests)")
    args = p.parse_args(argv)

    if args.once:
        sys.stdout.write(json.dumps(collect_once(args.top_n)) + "\n")
        sys.stdout.flush()
        return 0

    # Open the virtio-serial port. The host wires this up via QEMU's
    # virtserialport device; if it's missing, either virtio_console
    # isn't loaded in the guest kernel, the device wasn't included on
    # the QEMU command line, or udev hasn't created the symlink yet.
    # Exit loudly so OpenRC re-runs us (per service config) and so
    # the failure is visible in /var/log/cis490-agent.log instead of
    # being absorbed by a silent stdout fallback. Refs PIPELINE.md
    # §1 — a host that can't meet the bar must say so loudly, not
    # silently downgrade to a half-running state.
    if not os.path.exists(args.port):
        sys.stderr.write(
            f"[cis490-agent] FATAL: virtio-serial port {args.port} not "
            f"present. Check (a) virtio_console kernel module is loaded "
            f"inside the guest, (b) the QEMU command line includes "
            f"-device virtserialport,name=cis490.guest.agent, (c) udev "
            f"is creating /dev/virtio-ports/* symlinks. Exiting nonzero "
            f"so this failure is observable rather than silently lost.\n"
        )
        return 2
    out_fp = open(args.port, "wb", buffering=0)

    interval_ns = args.interval_ms * 1_000_000
    next_tick = time.monotonic_ns()
    try:
        while True:
            row = collect_once(args.top_n)
            out_fp.write((json.dumps(row) + "\n").encode("utf-8"))
            try:
                out_fp.flush()
            except (AttributeError, OSError):
                pass
            next_tick += interval_ns
            sleep_ns = next_tick - time.monotonic_ns()
            if sleep_ns > 0:
                time.sleep(sleep_ns / 1_000_000_000)
            else:
                next_tick = time.monotonic_ns()
    except KeyboardInterrupt:
        return 0
    except (BrokenPipeError, OSError) as e:
        sys.stderr.write(f"[cis490-agent] write failed: {e}\n")
        return 1


if __name__ == "__main__":
    sys.exit(main())
