#!/usr/bin/env python3
"""
Assign each node its IPv6 static, mirroring its EXISTING IPv4 octet, on every plane
where it already carries IPv4. Site-keyed. DRY BY DEFAULT.

WHY THIS EXISTS. D-101's governing rationale (recorded 2026-07-27) sets the standing
principle IPv6 UNLESS IPv4 IS NECESSARY, driven by real IPv4 sizing constraints. The
plane subnets were carved and the VIPs written to the apex earlier that day -- but
MEASURED, all 18 Ready nodes carried ZERO IPv6 links. MAAS `mode=static` means an
EXPLICITLY CONFIGURED address (that is how the Stage-4 carve set the 90 v4 links), so a
node's v6 address does not arrive with the subnet; it has to be assigned. This is that
assignment. Scope: docs/audit/node-v6-carve-scope-20260727.md.

EVERYTHING IS DERIVED FROM LIVE STATE -- there is no plane table here (hard rule 2):
  * site membership   <- the MAAS tag openstack-<site>
  * which interfaces  <- those that ALREADY carry a v4 static. The provider NIC
                         (enp2s0) is deliberately RAW with no links under D-100, and
                         br-ex carries provider-public instead; deriving from "has a v4
                         link" gets both right without naming either.
  * which v6 subnet   <- the one on the SAME MAAS vlan as that v4 link, so the node is
                         dual-stack on one L2 rather than on a parallel fabric.
  * the host part     <- the last octet of the node's own v4 address, mirrored
                         TEXTUALLY (RULED 2026-07-27): .121 -> ::121, never the hex
                         conversion. `printf '%x' 50` -> 32 already caught this project
                         once during the band work.

SAFETY. Dry by default; --commit writes then READS BACK every link. Idempotent: an
interface already carrying the right v6 address is SKIPPED. It links subnets only -- it
never re-commissions and never touches a MAC, because the 2026-07-20 incident regenerated
all 9 node NIC MACs on an in-place apply and stranded MAAS's records.

Exit: 0 ok | 1 error/mismatch | 2 could not evaluate.
"""
import argparse
import json
import subprocess
import sys

PROFILE_DEFAULT = "admin"


def die(msg, rc=2):
    print(f"REFUSE: {msg}", file=sys.stderr)
    print("  (could not evaluate -- this is NOT a pass)", file=sys.stderr)
    sys.exit(rc)


def maas(profile, *args):
    p = subprocess.run(["maas", profile, *args], capture_output=True, text=True)
    if p.returncode != 0:
        return None, (p.stderr or p.stdout).strip()[:300]
    return p.stdout, None


def maas_json(profile, *args):
    out, err = maas(profile, *args)
    if out is None:
        return None, err
    try:
        return json.loads(out), None
    except json.JSONDecodeError as e:
        return None, f"unparseable JSON from 'maas {profile} {' '.join(args)}': {e}"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("action", choices=["check", "apply"])
    ap.add_argument("site", choices=["vr1-dc0", "vr1-dc1"])
    ap.add_argument("--commit", action="store_true")
    ap.add_argument("--profile", default=PROFILE_DEFAULT)
    a = ap.parse_args()

    subs, err = maas_json(a.profile, "subnets", "read")
    if subs is None:
        die(f"'maas {a.profile} subnets read' failed -- {err}. NOTE an ABSENT `maas` binary "
            f"is a MISSING TOOL, not an unreachable MAAS; run from the D-128 Plane-2 host.")
    # v6 subnet keyed by the vlan it shares with its v4 twin
    v6_by_vlan = {}
    for s in subs:
        if ":" in s["cidr"]:
            vid = (s.get("vlan") or {}).get("id")
            if vid is not None:
                v6_by_vlan[vid] = s

    machines, err = maas_json(a.profile, "machines", "read")
    if machines is None:
        die(f"'maas {a.profile} machines read' failed -- {err}")

    tag = f"openstack-{a.site}"
    nodes = [m for m in machines if tag in (m.get("tag_names") or [])]
    if not nodes:
        die(f"no machines carry the tag '{tag}'. Refusing to fall back to a heuristic -- "
            f"picking nodes by subnet or MAC prefix could silently carve the wrong DC.")

    planned, skipped, errors, applied = [], 0, [], 0

    for m in sorted(nodes, key=lambda x: x["hostname"]):
        for iface in m.get("interface_set", []):
            links = iface.get("links", [])
            v4 = [l for l in links
                  if l.get("ip_address") and ":" not in str(l["ip_address"])]
            if not v4:
                continue          # raw provider NIC (D-100) or an unaddressed iface
            for l in v4:
                sub = l.get("subnet") or {}
                vid = (sub.get("vlan") or {}).get("id")
                v6sub = v6_by_vlan.get(vid)
                if not v6sub:
                    errors.append(f"{m['hostname']}/{iface['name']}: v4 {l['ip_address']} on "
                                  f"vlan {vid} has NO v6 subnet on that vlan -- carve it first")
                    continue
                octet = str(l["ip_address"]).rsplit(".", 1)[1]
                base = v6sub["cidr"].split("::/")[0]
                want = f"{base}::{octet}"          # TEXTUAL mirror, ruled 2026-07-27
                have = [x.get("ip_address") for x in links
                        if x.get("ip_address") and ":" in str(x["ip_address"])]
                if want in have:
                    skipped += 1
                    continue
                if have:
                    errors.append(f"{m['hostname']}/{iface['name']}: already carries v6 "
                                  f"{have} but the mirror wants {want} -- REFUSING to add a "
                                  f"second address; resolve by hand")
                    continue
                planned.append({
                    "host": m["hostname"], "sysid": m["system_id"],
                    "iface": iface["name"], "ifid": iface["id"],
                    "subid": v6sub["id"], "addr": want, "v4": l["ip_address"],
                    "cidr": v6sub["cidr"],
                })

    mode = "--commit" if (a.action == "apply" and a.commit) else \
           ("DRY RUN" if a.action == "apply" else "check")
    print(f"== dc-node-v6-carve {a.action} {a.site} ({mode}) ==")
    print(f"   nodes tagged {tag}: {len(nodes)}")
    print(f"   planned v6 links : {len(planned)}")
    print(f"   already correct  : {skipped}")
    print(f"   errors           : {len(errors)}")
    for p in planned[:6]:
        print(f"     [plan] {p['host']:16} {p['iface']:7} {p['v4']:14} -> {p['addr']}")
    if len(planned) > 6:
        print(f"     ... and {len(planned)-6} more")
    for e in errors[:6]:
        print(f"     [ERR]  {e}")

    if a.action == "check":
        ok = not planned and not errors
        print(f"\n{'PASS' if ok else 'FAIL'}: dc-node-v6-carve check {a.site} "
              f"({skipped} link(s) correct, {len(planned)} missing, {len(errors)} error(s))")
        return 0 if ok else 1

    if not a.commit:
        print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.")
        return 1 if errors else 0

    for p in planned:
        out, err = maas(a.profile, "interface", "link-subnet", p["sysid"], str(p["ifid"]),
                        "mode=STATIC", f"subnet={p['subid']}", f"ip_address={p['addr']}")
        if out is None:
            errors.append(f"{p['host']}/{p['iface']} {p['addr']}: link-subnet FAILED -- {err}")
            continue
        applied += 1

    # READ BACK. A create that reports success but did not land is the class this repo has
    # been bitten by (opnsense-plugins.sh apply always silently dry-ran).
    machines2, err = maas_json(a.profile, "machines", "read")
    if machines2 is None:
        die(f"applied {applied} link(s) but could not re-read machines to verify -- {err}")
    live = set()
    for m in machines2:
        for iface in m.get("interface_set", []):
            for l in iface.get("links", []):
                if l.get("ip_address"):
                    live.add((m["hostname"], iface["name"], str(l["ip_address"])))
    missing = [p for p in planned if (p["host"], p["iface"], p["addr"]) not in live]
    for p in missing[:6]:
        errors.append(f"{p['host']}/{p['iface']} {p['addr']}: MISSING AFTER WRITE")

    print(f"\n  READ-BACK: {len(planned)-len(missing)}/{len(planned)} link(s) verified live")
    print(f"\nRESULT: applied={applied} skipped={skipped} errors={len(errors)}")
    for e in errors[:8]:
        print(f"  [ERR] {e}", file=sys.stderr)
    return 1 if errors else 0


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