#!/usr/bin/env python3
"""Inventory a Talos host for FermiHDI installation, from somewhere else.

fermihdi-discover runs *on* the machine and reads /sys and /proc. On Talos
there is nowhere to run it: no shell, no SSH, no package manager, no way in at
all except the API. So this asks the API the same questions from the control
node and writes the same answers, in the same two formats, so that everything
downstream -- fermihdi-configure above all -- cannot tell the difference.

Two sources, because Talos exposes the machine in two ways and neither is
enough alone:

  talosctl get     typed resources: disks, links, pcidevices, cpu, systemdisk.
                   Available in maintenance mode, before the machine has any
                   configuration at all. No NUMA, no topology, no hugepages.
  talosctl read    the same sysfs and procfs files fermihdi-discover reads.
                   Everything the sizing model needs -- per-node cpulists, per
                   node hugepage counts, IOMMU groups -- and NOT available in
                   maintenance mode.

That split is the whole reason a Talos install is two passes. A machine that
has just booted the ISO can be inventoried well enough to choose an install
disk and be given a configuration; it cannot be inventoried well enough to
size a Storage Node. Run this again once it has come up configured.

Usage:
  ./fermihdi-talos-discover -n 10.147.0.20                  YAML facts to stdout
  ./fermihdi-talos-discover -n 10.147.0.20 -o facts.yaml    write to a file
  ./fermihdi-talos-discover -n 10.147.0.20 --format=tsv     for fermihdi-configure
  ./fermihdi-talos-discover -n 10.147.0.20 --insecure       maintenance mode
  ./fermihdi-talos-discover --check-deps                    is talosctl here?

Then, exactly as on bare metal:
  ./fermihdi-configure --facts facts.tsv --append cluster.yaml

  --talosconfig PATH   passed through to talosctl
  --dump-dir DIR       save every raw response, for support and for --replay
  --replay DIR         read a dump instead of a machine; probes nothing

Read-only. Every call is a get, a read or a list; nothing here writes to the
machine, and none of them need the machine to be configured except where said.
"""
import argparse
import concurrent.futures
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys

HERE = pathlib.Path(__file__).resolve().parent

# Sizes the kernel names its hugepage directories with, and the labels the
# facts document uses for them.
HP_DIRS = {"2m": "2048kB", "1g": "1048576kB"}

# A PCI address as it appears in a sysfs path component: 0000:61:00.0.
PCI_RE = re.compile(r"^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]$")

notes = []          # what could not be answered, and why


def note(msg):
    if msg not in notes:
        notes.append(msg)


def die(msg):
    sys.exit(f"error: {msg}")


# ═════════════════════════════════════════════════════════════════════════════
# Talking to the machine
# ═════════════════════════════════════════════════════════════════════════════
class Talos:
    """talosctl, with every response cached, dumpable and replayable.

    Cached because a full inventory asks for the same file more than once and
    each round trip is a TLS handshake away. Dumpable because when an install
    goes wrong on a machine nobody can log in to, the raw responses are the
    only evidence there is.
    """

    def __init__(self, node, talosconfig=None, insecure=False,
                 dump_dir=None, replay=None):
        self.node = node
        self.talosconfig = talosconfig
        self.insecure = insecure
        self.dump_dir = pathlib.Path(dump_dir) if dump_dir else None
        self.replay = pathlib.Path(replay) if replay else None
        self._cache = {}
        if self.dump_dir:
            self.dump_dir.mkdir(parents=True, exist_ok=True)

    # --- plumbing ------------------------------------------------------------
    @staticmethod
    def _key(args):
        """A filename for a command. Stable, readable, and safe on any fs."""
        return re.sub(r"[^A-Za-z0-9._-]+", "_", " ".join(args)).strip("_")[:180]

    def _run(self, args, allow_fail=True):
        key = self._key(args)
        if key in self._cache:
            return self._cache[key]

        if self.replay:
            f = self.replay / f"{key}.out"
            out = f.read_text() if f.exists() else None
            if out is None:
                note(f"replay has no capture for: talosctl {' '.join(args)}")
            self._cache[key] = out
            return out

        cmd = ["talosctl", "--nodes", self.node]
        if self.talosconfig:
            cmd += ["--talosconfig", self.talosconfig]
        cmd += args
        if self.insecure and args and args[0] in ("get", "version", "apply-config"):
            # Only these accept it. Passing it to `read` or `list` is not a
            # degraded call, it is an unknown flag.
            cmd.append("--insecure")

        try:
            p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        except FileNotFoundError:
            die("talosctl not found on PATH. See https://docs.siderolabs.com/talos/")
        except subprocess.TimeoutExpired:
            note(f"timed out: talosctl {' '.join(args)}")
            self._cache[key] = None
            return None

        out = p.stdout if p.returncode == 0 else None
        if p.returncode != 0 and not allow_fail:
            die(f"talosctl {' '.join(args)} failed:\n{p.stderr.strip()}")
        if p.returncode != 0:
            first = (p.stderr.strip().splitlines() or [""])[0]
            note(f"talosctl {' '.join(args)}: {first}")

        if self.dump_dir and out is not None:
            (self.dump_dir / f"{key}.out").write_text(out)
        self._cache[key] = out
        return out

    # --- the three verbs -----------------------------------------------------
    def get(self, resource, allow_fail=True):
        """A typed resource, as a list of {metadata, spec} dicts.

        talosctl emits one YAML document per resource rather than a list, so
        this parses the JSON form: it is unambiguous, and it means this tool
        needs no YAML parser to talk to the machine.
        """
        out = self._run(["get", resource, "-o", "json"], allow_fail=allow_fail)
        if not out:
            return []
        items, buf, depth = [], "", 0
        # Concatenated JSON objects, pretty-printed. Split on brace depth
        # rather than on blank lines, which are not guaranteed between them.
        for ch in out:
            buf += ch
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    try:
                        items.append(json.loads(buf))
                    except json.JSONDecodeError:
                        pass
                    buf = ""
        return items

    def read(self, path):
        """A file's contents, or None. Not available in maintenance mode."""
        if self.insecure:
            note("maintenance mode: `talosctl read` is not offered, so nothing "
                 "under /sys or /proc could be read. CPU topology, NUMA layout, "
                 "IOMMU groups and hugepage state are all missing from this "
                 "inventory. Run again once the machine is configured.")
            return None
        out = self._run(["read", path])
        return out.rstrip("\n") if out is not None else None

    def read_many(self, paths):
        """The same, for many files at once. One round trip each, in parallel."""
        if self.insecure or self.replay:
            return {p: self.read(p) for p in paths}
        paths = list(dict.fromkeys(paths))
        with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
            return dict(zip(paths, pool.map(self.read, paths)))

    def listdir(self, path, depth=1):
        """Entry names under a directory, or []. Not available in maintenance mode."""
        if self.insecure:
            return []
        out = self._run(["list", path, "-d", str(depth)])
        if not out:
            return []
        names = []
        for line in out.splitlines()[1:]:          # drop the NODE/NAME header
            parts = line.split()
            if len(parts) >= 2 and parts[-1] not in (".", ".."):
                names.append(parts[-1])
        return names


# ═════════════════════════════════════════════════════════════════════════════
# Small parsers
# ═════════════════════════════════════════════════════════════════════════════
def as_int(text, default=0):
    try:
        return int(str(text).strip())
    except (TypeError, ValueError):
        return default


def pci_from_bus_path(bus_path):
    """The last PCI address in a sysfs bus path.

    A disk's bus_path is the whole chain from the root complex to the device:
    /pci0000:00/0000:00:17.0/ata1/host0/... . The address that matters is the
    deepest one, which is the function that would be handed to vfio-pci --
    0000:00:17.0 here, not the 0000:00 root bridge.
    """
    found = ""
    for part in (bus_path or "").split("/"):
        if PCI_RE.match(part):
            found = part
    return found


def parse_cpuinfo(text):
    """/proc/cpuinfo -> logical count, socket/core map, flags.

    This replaces four separate things fermihdi-discover gets from lscpu and
    from a sysfs file per CPU. One read instead of one-plus-N round trips, and
    the same answer: two logical CPUs are SMT siblings exactly when they share
    a physical id and a core id.
    """
    blocks, cur = [], {}
    for line in (text or "").splitlines():
        if not line.strip():
            if cur:
                blocks.append(cur)
                cur = {}
            continue
        k, _, v = line.partition(":")
        cur[k.strip()] = v.strip()
    if cur:
        blocks.append(cur)

    cpus = []
    for b in blocks:
        if "processor" not in b:
            continue
        cpus.append({
            "id": as_int(b.get("processor"), -1),
            "socket": as_int(b.get("physical id"), 0),
            "core": as_int(b.get("core id"), 0),
            "cores_per_socket": as_int(b.get("cpu cores"), 0),
            "flags": b.get("flags", ""),
        })
    return cpus


def cpulist(ids):
    """[0,1,2,5] -> "0-2,5". The format sysfs uses and fermihdi-configure reads."""
    ids = sorted(set(ids))
    if not ids:
        return ""
    runs, start, prev = [], ids[0], ids[0]
    for i in ids[1:]:
        if i == prev + 1:
            prev = i
            continue
        runs.append((start, prev))
        start = prev = i
    runs.append((start, prev))
    return ",".join(str(a) if a == b else f"{a}-{b}" for a, b in runs)


def parse_range(spec):
    """"0-1,4" -> [0,1,4]. The inverse of cpulist, for /sys/.../node/online."""
    out = []
    for part in (spec or "").split(","):
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            a, _, b = part.partition("-")
            if a.isdigit() and b.isdigit():
                out.extend(range(int(a), int(b) + 1))
        elif part.isdigit():
            out.append(int(part))
    return out


def meminfo_kb(text, key):
    """The kB figure for a key in a meminfo file.

    Both files this reads use the key, and only one of them starts the line
    with it: a per-node meminfo is "Node 0 MemTotal: 131966976 kB", so taking
    the first number on the line returns the node number. The value is always
    the token before the unit.
    """
    for line in (text or "").splitlines():
        if key not in line:
            continue
        words = line.split()
        idx = words.index(key)
        for w in words[idx + 1:]:
            if w.isdigit():
                return int(w)
    return 0


def pretty_size(spec):
    """The human size lsblk would have printed, so the two facts files match."""
    p = spec.get("pretty_size")
    if p:
        return str(p).replace(" ", "")
    n = as_int(spec.get("size"), 0)
    if not n:
        return "0B"
    for unit in ("B", "K", "M", "G", "T", "P"):
        if n < 1024 or unit == "P":
            return f"{n:.1f}{unit}".replace(".0", "")
        n /= 1024
    return "0B"


# ═════════════════════════════════════════════════════════════════════════════
# The inventory
# ═════════════════════════════════════════════════════════════════════════════
def gather(t):
    f = {"notes": notes}

    # ── Identity ─────────────────────────────────────────────────────────────
    sysinfo = (t.get("systeminformation") or [{}])[0].get("spec", {}) or {}
    nodename = (t.get("nodename") or [{}])[0].get("spec", {}) or {}
    version = t._run(["version", "--short"]) or t._run(["version"]) or ""
    tag = ""
    for line in version.splitlines():
        if "Tag:" in line:
            tag = line.split("Tag:")[-1].strip()
            break

    proc_version = t.read("/proc/version") or ""
    kernel = ""
    m = re.search(r"version (\S+)", proc_version)
    if m:
        kernel = m.group(1)

    # SMBIOS knows what board this is; a hypervisor puts its own name there.
    vendor = f"{sysinfo.get('manufacturer', '')} {sysinfo.get('product_name', '')}".strip()
    virtualised = bool(re.search(r"qemu|kvm|vmware|virtualbox|xen|hyper-v|bochs|amazon|"
                                 r"google|microsoft|digitalocean|openstack|alibaba",
                                 vendor, re.I))

    f["host"] = {
        "name": nodename.get("nodename") or sysinfo.get("uuid") or t.node,
        "kernel": kernel or "unknown",
        "distro": f"Talos Linux {tag}".strip(),
        "virtualised": virtualised,
        "vendor": vendor or "unknown",
        "uuid": sysinfo.get("uuid", ""),
    }

    # ── CPU and NUMA ─────────────────────────────────────────────────────────
    cpus = parse_cpuinfo(t.read("/proc/cpuinfo"))
    processors = [p.get("spec", {}) or {} for p in t.get("cpu")]

    if cpus:
        logical = len(cpus)
        sockets = len({c["socket"] for c in cpus}) or 1
        per_socket = max((c["cores_per_socket"] for c in cpus), default=0) or \
            (len({(c["socket"], c["core"]) for c in cpus}) // sockets)
        tpc = max(1, logical // max(1, sockets * max(1, per_socket)))
        # Siblings share a physical id and a core id. Nothing else does.
        by_core = {}
        for c in cpus:
            by_core.setdefault((c["socket"], c["core"]), []).append(c["id"])
        siblings = {c["id"]: cpulist(by_core[(c["socket"], c["core"])]) for c in cpus}
        if not virtualised:
            f["host"]["virtualised"] = any("hypervisor" in c["flags"] for c in cpus)
    else:
        # Maintenance mode. SMBIOS still describes the sockets, which is enough
        # to say how big the machine is and not enough to place anything on it.
        logical = sum(as_int(p.get("thread_count")) for p in processors)
        sockets = len(processors) or 1
        per_socket = max((as_int(p.get("core_count")) for p in processors), default=0)
        tpc = max(1, logical // max(1, sockets * max(1, per_socket))) if per_socket else 1
        siblings = {}
        if processors:
            note("CPU topology came from SMBIOS, not from /proc/cpuinfo: core "
                 "counts are right, but which logical CPU is which is not known, "
                 "so no cpuset can be computed from this inventory")

    # NUMA. The per-node figures are the ones the sizing model needs: hugepages
    # are reserved per node and a reactor allocates from its own, so a host
    # total that looks generous can still leave one instance with nothing.
    node_ids = parse_range(t.read("/sys/devices/system/node/online")) or []
    node_reads = []
    for n in node_ids:
        base = f"/sys/devices/system/node/node{n}"
        node_reads += [f"{base}/cpulist", f"{base}/meminfo"]
        node_reads += [f"{base}/hugepages/hugepages-{kb}/nr_hugepages"
                       for kb in HP_DIRS.values()]
    node_data = t.read_many(node_reads) if node_reads else {}

    numa = {}
    for n in node_ids:
        base = f"/sys/devices/system/node/node{n}"
        numa[n] = {
            "cpulist": node_data.get(f"{base}/cpulist") or "",
            "memory_mb": meminfo_kb(node_data.get(f"{base}/meminfo"), "MemTotal:") // 1024,
            "hugepages_2m": as_int(node_data.get(
                f"{base}/hugepages/hugepages-2048kB/nr_hugepages")),
            "hugepages_1g": as_int(node_data.get(
                f"{base}/hugepages/hugepages-1048576kB/nr_hugepages")),
        }

    f["cpu"] = {
        "logical": logical,
        "sockets": sockets,
        "cores_per_socket": per_socket,
        "threads_per_core": tpc,
        "numa_nodes": len(node_ids) or 1,
        "numa": numa,
        "smt": tpc > 1,
        "isolated_now": t.read("/sys/devices/system/cpu/isolated") or "",
        "siblings": siblings,
    }

    # ── IOMMU ────────────────────────────────────────────────────────────────
    # The group is the unit VFIO hands out: every device in one goes to the same
    # process or none of them do. Without it, an SN can be given two NVMe that
    # the kernel will refuse to hand over separately.
    groups = {}
    for gid in t.listdir("/sys/kernel/iommu_groups"):
        if gid.isdigit():
            devs = t.listdir(f"/sys/kernel/iommu_groups/{gid}/devices")
            groups[gid] = [d for d in devs if PCI_RE.match(d)]
    group_of = {dev: gid for gid, devs in groups.items() for dev in devs}
    f["iommu"] = {"enabled": bool(groups), "groups": groups}
    if not groups and not t.insecure:
        note("no IOMMU groups: VFIO cannot be used on this machine until the "
             "boot image carries intel_iommu=on (or amd_iommu=on) and iommu=pt. "
             "On Talos those are Image Factory schematic arguments, not machine "
             "configuration -- fermihdi-talos-prepare builds the schematic")

    # ── Memory and hugepages ─────────────────────────────────────────────────
    mem = t.read("/proc/meminfo")
    hp_paths = [f"/sys/kernel/mm/hugepages/hugepages-{kb}/{which}_hugepages"
                for kb in HP_DIRS.values() for which in ("nr", "free")]
    hp = t.read_many(hp_paths) if not t.insecure else {}

    f["memory"] = {
        "total_mb": meminfo_kb(mem, "MemTotal:") // 1024,
        "available_mb": meminfo_kb(mem, "MemAvailable:") // 1024,
        "total_kb": meminfo_kb(mem, "MemTotal:"),
        "available_kb": meminfo_kb(mem, "MemAvailable:"),
    }
    if not mem:
        # Talos reports installed DIMMs over SMBIOS even in maintenance mode.
        total = sum(as_int(m.get("spec", {}).get("size")) for m in t.get("memorymodules"))
        if total:
            f["memory"]["total_mb"] = total
            f["memory"]["total_kb"] = total * 1024
            note("memory total came from the SMBIOS DIMM inventory, which counts "
                 "installed capacity rather than what the kernel ended up with")

    f["hugepages"] = {}
    for label, kb in HP_DIRS.items():
        nr = hp.get(f"/sys/kernel/mm/hugepages/hugepages-{kb}/nr_hugepages")
        free = hp.get(f"/sys/kernel/mm/hugepages/hugepages-{kb}/free_hugepages")
        f["hugepages"][label] = {
            "supported": nr is not None,
            "total": as_int(nr),
            "free": as_int(free),
        }
    mounts = t.read("/proc/mounts") or ""
    mounted = [ln.split()[1] for ln in mounts.splitlines()
               if len(ln.split()) > 2 and ln.split()[2] == "hugetlbfs"]
    f["hugepages"]["mounted"] = " ".join(mounted) or "none"

    # ── Storage ──────────────────────────────────────────────────────────────
    system_disk = ""
    for sd in t.get("systemdisk"):
        system_disk = (sd.get("spec", {}) or {}).get("dev_path", "") or system_disk

    # A discovered volume with a filesystem name is a filesystem. The disk it
    # sits on is the one that would be overwritten.
    with_fs = set()
    for dv in t.get("discoveredvolumes"):
        spec = dv.get("spec", {}) or {}
        if spec.get("name"):
            for key in ("parent_dev_path", "dev_path"):
                if spec.get(key):
                    with_fs.add(spec[key])

    disks, numa_paths = [], []
    for d in t.get("disks"):
        spec = d.get("spec", {}) or {}
        if spec.get("cdrom"):
            continue
        dev_path = spec.get("dev_path") or f"/dev/{d.get('metadata', {}).get('id', '')}"
        name = dev_path.rsplit("/", 1)[-1]
        pci = pci_from_bus_path(spec.get("bus_path"))
        if pci:
            numa_paths.append(f"/sys/bus/pci/devices/{pci}/numa_node")
        transport = (spec.get("transport") or "").lower()
        kind = "nvme" if name.startswith("nvme") or transport == "nvme" \
            else (transport or "unknown")
        holds_root = bool(system_disk) and dev_path == system_disk
        has_fs = dev_path in with_fs or any(p.startswith(dev_path) for p in with_fs)
        disks.append({
            "name": name, "path": dev_path, "size": pretty_size(spec),
            "model": (spec.get("model") or "unknown").strip() or "unknown",
            "kind": kind, "pci": pci, "iommu_group": group_of.get(pci, ""),
            "holds_root": holds_root, "has_fs": has_fs,
            "rotational": bool(spec.get("rotational")),
            "serial": (spec.get("serial") or "").strip(),
        })

    # ── Network ──────────────────────────────────────────────────────────────
    # LinkStatus carries the PCI address itself, so unlike the bare-metal
    # inventory nothing has to walk a symlink to find it.
    default_ifaces = set()
    for r in t.get("routes"):
        spec = r.get("spec", {}) or {}
        if not spec.get("dst") and spec.get("outLinkName"):
            default_ifaces.add(spec["outLinkName"])
        elif not spec.get("dst") and spec.get("out_link_name"):
            default_ifaces.add(spec["out_link_name"])

    addr_of = {}
    for a in t.get("addresses"):
        spec = a.get("spec", {}) or {}
        link = spec.get("linkName") or spec.get("link_name")
        addr = spec.get("address", "")
        if link and addr and ":" not in addr and link not in addr_of:
            addr_of[link] = addr

    nics = []
    for link in t.get("links"):
        spec = link.get("spec", {}) or {}
        iface = link.get("metadata", {}).get("id", "")
        pci = spec.get("pciid") or pci_from_bus_path(spec.get("bus_path"))
        # Physical ports only: no lo, no bridges, no CNI veth, no bonds.
        if not pci or not PCI_RE.match(pci) or spec.get("kind"):
            continue
        numa_paths.append(f"/sys/bus/pci/devices/{pci}/numa_node")
        state = (spec.get("operationalState") or spec.get("operational_state") or "")
        nics.append({
            "iface": iface, "pci": pci,
            "driver": spec.get("driver", ""),
            "state": state.replace("OPER_STATE_", "").lower() or "unknown",
            "mac": spec.get("hardwareAddr") or spec.get("hardware_addr") or "",
            "ipv4": addr_of.get(iface, ""),
            "iommu_group": group_of.get(pci, ""),
            "default_route": iface in default_ifaces,
        })

    # One batch for every device's NUMA node rather than one call per device
    # as they were built.
    numa_data = t.read_many(numa_paths) if numa_paths else {}

    def numa_of(pci):
        return as_int(numa_data.get(f"/sys/bus/pci/devices/{pci}/numa_node"), -1) \
            if pci else -1

    for d in disks:
        d["numa_node"] = numa_of(d["pci"])
        d["current_driver"] = ""            # filled below where known
    for n in nics:
        n["numa_node"] = numa_of(n["pci"])

    # PCIDevice knows the bound driver, which is what says whether a rebind is
    # still outstanding.
    driver_of = {}
    for p in t.get("pcidevices"):
        pid = p.get("metadata", {}).get("id", "")
        drv = (p.get("spec", {}) or {}).get("driver", "")
        if PCI_RE.match(pid):
            driver_of[pid] = drv
    for d in disks:
        d["current_driver"] = driver_of.get(d["pci"], "")

    f["storage"] = disks
    f["network"] = nics
    if not disks:
        note("no disks were reported. Without one there is nowhere to install "
             "Talos and nowhere for a Storage Node to write")
    return f


# ═════════════════════════════════════════════════════════════════════════════
# Output — the same two shapes fermihdi-discover emits
# ═════════════════════════════════════════════════════════════════════════════
def emit_tsv(f, out):
    """The form fermihdi-configure reads. One record per line, type first.

    Byte-for-byte the contract fermihdi-discover --format=tsv produces, so the
    interview cannot tell which machine it came from or how.
    """
    w = out.write
    host, cpu = f["host"], f["cpu"]
    w(f"host\t{host['name']}\t{host['kernel']}\t"
      f"{'virtual' if host['virtualised'] else 'bare'}\n")
    w(f"cpu\t{cpu['logical']}\t{cpu['sockets']}\t{cpu['cores_per_socket']}\t"
      f"{cpu['threads_per_core']}\t{cpu['numa_nodes']}\n")
    for cid in sorted(cpu["siblings"]):
        w(f"sibling\t{cid}\t{cpu['siblings'][cid]}\n")
    for n in sorted(cpu["numa"]):
        d = cpu["numa"][n]
        w(f"numa\t{n}\t{d['cpulist']}\t{d['memory_mb']}\t"
          f"{d['hugepages_2m']}\t{d['hugepages_1g']}\n")
    w(f"iommu\t{'true' if f['iommu']['enabled'] else 'false'}\n")
    w(f"memory\t{f['memory']['total_kb']}\t{f['memory']['available_kb']}\n")
    for label in ("2m", "1g"):
        h = f["hugepages"][label]
        w(f"hugepages\t{label}\t{h['total']}\t{h['free']}\n")

    for d in f["storage"]:
        elig, why = True, "-"
        if d["holds_root"]:
            elig, why = False, "holds the root filesystem"
        w("storage\t" + "\t".join(str(x) for x in [
            d["name"], d["path"], d["size"], d["model"] or "unknown", d["kind"],
            d["pci"] or "-", d["iommu_group"] or "-", d["current_driver"] or "-",
            str(elig).lower(), why, str(d["has_fs"]).lower(),
            str(d["holds_root"]).lower(), d["numa_node"],
        ]) + "\n")

    for n in f["network"]:
        w("nic\t" + "\t".join(str(x) for x in [
            n["iface"], n["pci"], n["driver"] or "-", n["state"],
            n["ipv4"] or "-", n["iommu_group"] or "-",
            str(n["default_route"]).lower(),
            str(not n["default_route"]).lower(), n["numa_node"],
        ]) + "\n")


def q(s):
    return '"' + str(s).replace('\\', '\\\\').replace('"', '\\"') + '"'


def emit_yaml(f, out, node, mode):
    w = out.write
    w(f"# fermihdi-talos-discover facts — {node} ({mode})\n")
    w("# Read-only inventory over the Talos API. Nothing on this machine was\n"
      "# modified, and nothing here needed a shell.\n")
    w("version: 1\n")

    host = f["host"]
    w("host:\n")
    w(f"  name: {host['name']}\n")
    w(f"  kernel: {host['kernel']}\n")
    w(f"  distro: {q(host['distro'])}\n")
    w(f"  vendor: {q(host['vendor'])}\n")
    w(f"  uuid: {q(host['uuid'])}\n")
    w(f"  virtualised: {str(host['virtualised']).lower()}\n")

    cpu = f["cpu"]
    w("cpu:\n")
    for k in ("logical", "sockets", "cores_per_socket", "threads_per_core", "numa_nodes"):
        w(f"  {k}: {cpu[k]}\n")
    w("  numa:\n" if cpu["numa"] else "  numa: {}\n")
    for n in sorted(cpu["numa"]):
        d = cpu["numa"][n]
        w(f'    "{n}":\n')
        w(f"      cpulist: {q(d['cpulist'])}\n")
        w(f"      memory_mb: {d['memory_mb']}\n")
        w(f"      hugepages_2m: {d['hugepages_2m']}\n")
        w(f"      hugepages_1g: {d['hugepages_1g']}\n")
    w(f"  smt: {str(cpu['smt']).lower()}\n")
    w(f"  isolated_now: {q(cpu['isolated_now'])}\n")
    w("  siblings:\n" if cpu["siblings"] else "  siblings: {}\n")
    for cid in sorted(cpu["siblings"]):
        w(f'    "{cid}": {q(cpu["siblings"][cid])}\n')

    w("iommu:\n")
    w(f"  enabled: {str(f['iommu']['enabled']).lower()}\n")
    if f["iommu"]["groups"]:
        w("  groups:\n")
        for gid in sorted(f["iommu"]["groups"], key=lambda x: int(x)):
            devs = ", ".join(q(d) for d in f["iommu"]["groups"][gid])
            w(f'    "{gid}": [{devs}]\n')
    else:
        w("  groups: {}\n")

    w("memory:\n")
    w(f"  total_mb: {f['memory']['total_mb']}\n")
    w(f"  available_mb: {f['memory']['available_mb']}\n")
    w("hugepages:\n")
    for label in ("2m", "1g"):
        h = f["hugepages"][label]
        w(f"  {label}: {{ supported: {str(h['supported']).lower()}, "
          f"total: {h['total']}, free: {h['free']} }}\n")
    w(f"  mounted: {f['hugepages']['mounted']}\n")

    w("storage:\n")
    for d in f["storage"]:
        w(f"  - name: {d['name']}\n")
        w(f"    path: {d['path']}\n")
        w(f"    size: {q(d['size'])}\n")
        w(f"    model: {q(d['model'])}\n")
        w(f"    kind: {d['kind']}\n")
        if d["serial"]:
            w(f"    serial: {q(d['serial'])}\n")
        if d["pci"]:
            w(f"    pci: {q(d['pci'])}\n")
        if d["iommu_group"]:
            w(f"    iommu_group: {d['iommu_group']}\n")
        if d["current_driver"]:
            w(f"    current_driver: {d['current_driver']}\n")
        if d["pci"]:
            w(f"    numa_node: {d['numa_node']}\n")
        w(f"    holds_root: {str(d['holds_root']).lower()}\n")
        w(f"    has_filesystem: {str(d['has_fs']).lower()}\n")
        w(f"    rotational: {str(d['rotational']).lower()}\n")
        # An NVMe controller reaches SPDK through vfio-pci; everything else --
        # SATA, SAS, FC, iSCSI, NVMe-oF -- goes through the kernel and bdev_aio.
        needs_vfio = d["kind"] == "nvme"
        w(f"    requires_vfio: {str(needs_vfio).lower()}\n")
        if needs_vfio and d["current_driver"] and d["current_driver"] != "vfio-pci":
            w(f"    driver_change_required: {q(d['current_driver'] + ' -> vfio-pci')}\n")
        if d["holds_root"]:
            w("    eligible: false\n")
            w('    reason: "Talos installed on it — this is the system disk"\n')
        else:
            w("    eligible: true\n")
            w("    destructive: true\n")
            if d["has_fs"]:
                w('    warning: "DATA LOSS: this device has a filesystem. A '
                  'Storage Node consumes the whole device and overwrites it."\n')
            else:
                w('    warning: "A Storage Node consumes the whole device and '
                  'overwrites it."\n')

    w("network:\n")
    for n in f["network"]:
        w(f"  - iface: {n['iface']}\n")
        w(f"    pci: {q(n['pci'])}\n")
        w(f"    driver: {n['driver']}\n")
        w(f"    state: {n['state']}\n")
        w(f"    mac: {n['mac']}\n")
        if n["ipv4"]:
            w(f"    ipv4: {q(n['ipv4'])}\n")
        if n["iommu_group"]:
            w(f"    iommu_group: {n['iommu_group']}\n")
        w(f"    numa_node: {n['numa_node']}\n")
        w(f"    default_route: {str(n['default_route']).lower()}\n")
        if n["default_route"]:
            w("    eligible_for_dpdk: false\n")
            w('    reason: "carries the default route — binding it to vfio-pci '
              'would cut this machine off, and on Talos the API is the only way '
              'back in"\n')
        else:
            w("    eligible_for_dpdk: true\n")

    if notes:
        w("notes:\n")
        for n in notes:
            w(f"  - {q(n)}\n")


# ═════════════════════════════════════════════════════════════════════════════
def main():
    ap = argparse.ArgumentParser(
        description="Inventory a Talos host over the API.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Feeds fermihdi-configure --facts, exactly as fermihdi-discover does.")
    ap.add_argument("-n", "--node", help="address talosctl reaches the machine on")
    ap.add_argument("-o", "--output", metavar="FILE", help="write here instead of stdout")
    ap.add_argument("--format", choices=("yaml", "tsv"), default="yaml")
    ap.add_argument("-i", "--insecure", action="store_true",
                    help="maintenance mode: the machine has no configuration yet")
    ap.add_argument("--talosconfig", metavar="PATH")
    ap.add_argument("--dump-dir", metavar="DIR",
                    help="save every raw talosctl response here")
    ap.add_argument("--replay", metavar="DIR",
                    help="read a dump instead of a machine; probes nothing")
    ap.add_argument("--check-deps", action="store_true")
    args = ap.parse_args()

    if args.check_deps:
        path = shutil.which("talosctl")
        print(f"  talosctl   {path or 'MISSING'}")
        if not path:
            print("\n  curl -sL https://talos.dev/install | sh")
            print("  or see https://docs.siderolabs.com/talos/")
        return 0 if path else 1

    if not args.node and not args.replay:
        ap.error("--node is required (or --replay DIR to read a saved dump)")

    t = Talos(args.node or "replay", args.talosconfig, args.insecure,
              args.dump_dir, args.replay)
    facts = gather(t)

    mode = "maintenance mode" if args.insecure else "configured"
    if args.replay:
        mode = f"replay of {args.replay}"

    out = open(args.output, "w") if args.output else sys.stdout
    try:
        if args.format == "tsv":
            emit_tsv(facts, out)
        else:
            emit_yaml(facts, out, args.node or args.replay, mode)
    finally:
        if args.output:
            out.close()

    for n in notes:
        print(f"  note  {n}", file=sys.stderr)
    if args.format == "tsv" and not facts["cpu"]["numa"]:
        print("\n  This inventory has no NUMA or SMT topology, so fermihdi-configure\n"
              "  cannot compute a cpuset from it. Give the machine a configuration\n"
              "  first, then run this again without --insecure.", file=sys.stderr)
        return 2
    return 0


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