#!/usr/bin/env python3
"""netbox_to_topology — NetBox DCIM export -> Iverson topology v2 YAML.

Stdlib only (the collector's covenant holds for its tooling too).

Inputs (exactly one device source):
  --devices devices.csv          NetBox CSV export of devices
                                 (columns: name, rack, site — extra columns
                                 are ignored; headers matched case-insensitively)
  --json devices.json            NetBox API dump of /api/dcim/devices/
                                 (a bare list, or {"results": [...]}; each
                                 object carries .name, .rack.name, .site.name)
Optional power chain (CSV, NetBox export shapes):
  --power-feeds power-feeds.csv  columns: name, power_panel (or panel),
                                 rack, type (primary/redundant)
  --power-panels power-panels.csv  columns: name, site

Mapping:
  device            -> nodes: <name>: {rack: <rack>, pdu: <feed-a>}
  rack              -> rack domain (parent = site zone when known)
  site              -> zone domain
  power feed        -> power domain, parent = its panel (ups kind)
  panel             -> ups domain (parent = site zone when panels CSV says so)
  dual-fed rack     -> the rack's pdu is the A feed; its power domain gets
                       feeds: [panel-a, panel-b] ONLY when the export shows
                       two feeds landing on two distinct panels; otherwise
                       a single parent.

Conservative by design: anything unmappable lands in a trailing
"# UNMAPPED — fill by hand:" comment block, never silently dropped.
A summary (N nodes mapped, M domains, K unmapped rows) goes to stderr.

Usage:
  python3 tools/netbox_to_topology.py --devices devices.csv \
      --power-feeds power-feeds.csv --out topology.yaml
  python3 tools/netbox_to_topology.py --json devices.json > topology.yaml
"""

import argparse
import csv
import json
import re
import sys

# characters the collector's flow-style parser cannot carry in keys/values
_UNSAFE = re.compile(r"[\s:,{}\[\]#]+")

KIND_ORDER = {"rack": 0, "power": 1, "ups": 2, "transformer": 3,
              "cooling": 4, "network": 5, "nvlink": 6, "zone": 7}


def slug(s, lower=True):
    """Make a name safe for the topology parser (no spaces/colons/commas)."""
    s = _UNSAFE.sub("-", str(s).strip()).strip("-")
    return s.lower() if lower else s


def _norm_row(row):
    """Lower/strip a DictReader row's keys (tolerates BOM and Name vs name)."""
    out = {}
    for k, v in row.items():
        if k is None:
            continue
        out[k.strip().lstrip("﻿").lower()] = (v or "").strip()
    return out


def _pick(row, *names):
    for n in names:
        if row.get(n):
            return row[n]
    return ""


# ---------------------------------------------------------------- readers

def read_devices_csv(path):
    """-> list of {name, rack, site, src} (raw strings, may be empty)."""
    out = []
    with open(path, newline="", encoding="utf-8-sig") as f:
        for i, row in enumerate(csv.DictReader(f), start=2):  # 1 = header
            r = _norm_row(row)
            out.append({"name": _pick(r, "name", "device"),
                        "rack": _pick(r, "rack"),
                        "site": _pick(r, "site"),
                        "src": f"{path} row {i}"})
    return out


def read_devices_json(path):
    """NetBox /api/dcim/devices/ dump -> same shape as read_devices_csv."""
    with open(path, encoding="utf-8-sig") as f:
        doc = json.load(f)
    items = doc.get("results", []) if isinstance(doc, dict) else doc
    out = []
    for i, d in enumerate(items):
        if not isinstance(d, dict):
            out.append({"name": "", "rack": "", "site": "",
                        "src": f"{path} item {i}: not an object"})
            continue
        rack = d.get("rack")
        site = d.get("site")
        out.append({
            "name": (d.get("name") or "").strip(),
            "rack": ((rack.get("name") if isinstance(rack, dict) else rack)
                     or "").strip(),
            "site": ((site.get("name") if isinstance(site, dict) else site)
                     or "").strip(),
            "src": f"{path} item {i}"})
    return out


def read_feeds_csv(path):
    """-> list of {name, panel, rack, type, src}."""
    out = []
    with open(path, newline="", encoding="utf-8-sig") as f:
        for i, row in enumerate(csv.DictReader(f), start=2):
            r = _norm_row(row)
            out.append({"name": _pick(r, "name", "feed"),
                        "panel": _pick(r, "power_panel", "panel"),
                        "rack": _pick(r, "rack"),
                        "type": _pick(r, "type").lower(),
                        "src": f"{path} row {i}"})
    return out


def read_panels_csv(path):
    """-> list of {name, site, src}."""
    out = []
    with open(path, newline="", encoding="utf-8-sig") as f:
        for i, row in enumerate(csv.DictReader(f), start=2):
            r = _norm_row(row)
            out.append({"name": _pick(r, "name", "panel"),
                        "site": _pick(r, "site"),
                        "src": f"{path} row {i}"})
    return out


# ---------------------------------------------------------------- convert

def convert(devices, feeds=(), panels=()):
    """-> (nodes, domains, unmapped, notes)

    nodes:   {node-name: {"rack": ..., "pdu": ...}}
    domains: {key: {"kind": ..., "parent": ...|None, "feeds": [...]|None}}
    unmapped: list of human lines for the trailing comment block
    notes:    list of non-fatal cautions
    """
    unmapped, notes = [], []
    nodes, domains = {}, {}

    def domain(key, kind, parent=None, dfeeds=None):
        d = domains.setdefault(key, {"kind": kind})
        if parent and not d.get("feeds"):
            d["parent"] = parent
        if dfeeds:
            d["feeds"] = dfeeds
            d.pop("parent", None)
        return d

    # rack name -> sites seen (rack names may repeat across sites in NetBox)
    rack_sites = {}
    for dev in devices:
        if dev["rack"]:
            rack_sites.setdefault(dev["rack"].lower(), set()).add(
                dev["site"].lower())
    ambiguous = {r for r, s in rack_sites.items() if len(s) > 1}

    def rack_key(rack, site):
        r = rack.lower()
        if r in ambiguous and site:
            return slug(f"{site}-{rack}")
        return slug(rack)

    # ---- devices -> nodes + rack/zone domains
    rack_of = {}   # rack key -> set of node names (to attach pdu later)
    for dev in devices:
        if not dev["name"]:
            unmapped.append(f"{dev['src']}: no device name — skipped")
            continue
        if not dev["rack"]:
            unmapped.append(f"{dev['src']}: device '{dev['name']}' has no "
                            "rack — cannot place; add a nodes: entry by hand")
            continue
        name = slug(dev["name"], lower=False)
        if name != dev["name"]:
            notes.append(f"device name '{dev['name']}' sanitized to '{name}' "
                         "— must match the Kubernetes node name to attach")
        rk = rack_key(dev["rack"], dev["site"])
        nodes[name] = {"rack": rk}
        rack_of.setdefault(rk, set()).add(name)
        if dev["site"]:
            z = slug(dev["site"])
            domain(z, "zone")
            domain(rk, "rack", parent=z)
        else:
            domain(rk, "rack")

    # ---- panels CSV: declare panels (ups), parent them to their site zone
    for p in panels:
        if not p["name"]:
            unmapped.append(f"{p['src']}: panel row has no name — skipped")
            continue
        pk = slug(p["name"])
        z = slug(p["site"]) if p["site"] else None
        if z:
            domain(z, "zone")
        domain(pk, "ups", parent=z)

    # ---- feeds CSV: rack -> feed -> panel; A/B when two distinct panels
    by_rack = {}
    for fd in feeds:
        if not fd["name"]:
            unmapped.append(f"{fd['src']}: feed row has no name — skipped")
            continue
        if not fd["rack"]:
            unmapped.append(f"{fd['src']}: feed '{fd['name']}' has no rack — "
                            "cannot attach; wire it into domains: by hand")
            continue
        r = fd["rack"].lower()
        if r in ambiguous:
            unmapped.append(f"{fd['src']}: feed '{fd['name']}' names rack "
                            f"'{fd['rack']}', which exists in multiple sites "
                            "— attach by hand")
            continue
        by_rack.setdefault(slug(fd["rack"]), []).append(fd)

    for rk, rack_feeds in sorted(by_rack.items()):
        # primary feed first, then stable name order — the A feed leads
        rack_feeds.sort(key=lambda f: (0 if f["type"] == "primary" else 1,
                                       f["name"].lower()))
        feed_a = rack_feeds[0]
        pdu_key = slug(feed_a["name"])
        panel_keys = []
        for fd in rack_feeds:
            if not fd["panel"]:
                unmapped.append(f"{fd['src']}: feed '{fd['name']}' (rack "
                                f"'{fd['rack']}') has no power_panel — "
                                "upstream unknown, fill parent by hand")
                continue
            pk = slug(fd["panel"])
            domain(pk, "ups")
            if pk not in panel_keys:
                panel_keys.append(pk)
        if len(panel_keys) >= 2:
            domain(pdu_key, "power", dfeeds=panel_keys)
        elif len(panel_keys) == 1:
            if len(rack_feeds) >= 2:
                notes.append(f"rack '{rk}': {len(rack_feeds)} feeds all land "
                             f"on panel '{panel_keys[0]}' — no true A/B "
                             "redundancy, emitted as single parent")
            domain(pdu_key, "power", parent=panel_keys[0])
        else:
            domain(pdu_key, "power")
        if rk not in rack_of:
            notes.append(f"feed '{feed_a['name']}' names rack '{rk}' with no "
                         "devices in the device export — domain emitted, "
                         "no node references it yet")
        for node_name in rack_of.get(rk, ()):
            nodes[node_name]["pdu"] = pdu_key

    return nodes, domains, unmapped, notes


# ---------------------------------------------------------------- emit

def emit(nodes, domains, unmapped, notes, sources):
    lines = ["# topology.yaml — generated by tools/netbox_to_topology.py",
             f"# from: {', '.join(sources)}",
             "# review before use: this file is the facility ground truth "
             "the blast-radius math stands on.",
             "nodes:"]
    for name in sorted(nodes):
        attrs = nodes[name]
        body = ", ".join(f"{k}: {attrs[k]}"
                         for k in ("rack", "pdu", "cooling") if k in attrs)
        lines.append(f"  {name}: {{{body}}}")
    if not nodes:
        lines.append("  # (no devices mapped)")
    lines.append("domains:")
    for key in sorted(domains,
                      key=lambda k: (KIND_ORDER.get(domains[k]["kind"], 99), k)):
        d = domains[key]
        parts = [f"kind: {d['kind']}"]
        if d.get("feeds"):
            parts.append("feeds: [" + ", ".join(d["feeds"]) + "]")
        elif d.get("parent"):
            parts.append(f"parent: {d['parent']}")
        lines.append(f"  {key}: {{{', '.join(parts)}}}")
    if not domains:
        lines.append("  # (none)")
    if notes:
        lines.append("")
        lines.append("# NOTES:")
        lines.extend(f"#   {n}" for n in notes)
    if unmapped:
        lines.append("")
        lines.append("# UNMAPPED — fill by hand:")
        lines.extend(f"#   {u}" for u in unmapped)
    return "\n".join(lines) + "\n"


# ---------------------------------------------------------------- main

def main(argv=None):
    ap = argparse.ArgumentParser(
        description="Convert NetBox DCIM exports to Iverson topology v2 YAML")
    src = ap.add_mutually_exclusive_group(required=True)
    src.add_argument("--devices", help="NetBox devices.csv export")
    src.add_argument("--json", dest="json_dump",
                     help="NetBox /api/dcim/devices/ JSON dump")
    ap.add_argument("--power-feeds", help="NetBox power-feeds.csv export")
    ap.add_argument("--power-panels", help="NetBox power-panels.csv export")
    ap.add_argument("--out", help="output path (default: stdout)")
    args = ap.parse_args(argv)

    sources = []
    if args.devices:
        devices = read_devices_csv(args.devices)
        sources.append(args.devices)
    else:
        devices = read_devices_json(args.json_dump)
        sources.append(args.json_dump)
    feeds = read_feeds_csv(args.power_feeds) if args.power_feeds else []
    panels = read_panels_csv(args.power_panels) if args.power_panels else []
    sources += [p for p in (args.power_feeds, args.power_panels) if p]

    nodes, domains, unmapped, notes = convert(devices, feeds, panels)
    text = emit(nodes, domains, unmapped, notes, sources)
    if args.out:
        with open(args.out, "w") as f:
            f.write(text)
    else:
        sys.stdout.write(text)
    print(f"{len(nodes)} nodes mapped, {len(domains)} domains, "
          f"{len(unmapped)} unmapped rows", file=sys.stderr)
    return 0


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