#!/usr/bin/env python3
"""
NetBox prerequisite import for the VR1 DC-DC buildout: six-plane IPAM roles + aggregates.

Closes gaps G1 (5 of 6 six-plane roles missing) and G2 (0 aggregates) identified in
docs/dc-dc-netbox-buildout-scope.md, so that netbox/dc-dc-prefixes-import.py -- which
ASSUMES the roles already exist and does not create aggregates -- can run cleanly.

Creates, idempotently (get-or-create; existing objects detected and skipped):

  IPAM roles (slugs MUST match scripts/lib-net.sh SPACES6 exactly; the prefixes
  import references them by slug):
    provider-public, metal-admin, metal-internal, data-tenant, replication
  (`storage` already exists in NetBox and is left untouched.)

  RIRs (only if missing): "RFC 4193 ULA", "RFC 1918" (both is_private). "ARIN" is
  assumed to exist (it did on 2026-07-10); looked up, not created.

  Aggregates (under the right RIR):
    2602:f3e2::/36     ARIN        (the HELD allocation = region-0 of the planned /32; D-101)
    23.157.124.0/24    ARIN
    10.0.0.0/8         RFC 1918
    172.16.0.0/12      RFC 1918
    <ORG_ULA_48>       RFC 4193    (ONLY if ORG_ULA_48 is set -- an operator literal, D-101)

NOTHING INVENTED. The six-plane role names are the ADOPTED D-052/D-053 model
(scripts/lib-net.sh). The ARIN /36 and /24 are the real, quoted-in-D-101 allocations.
The RFC1918 blocks are RFC facts. The org ULA /48 is the ONE operator-choice literal
here (D-101 names it NetBox-assigned, not hardcoded): it is read from ORG_ULA_48 and
its format validated; if unset, the ULA aggregate is SKIPPED (with a note), never
guessed. The scope doc recommends fd50:840e:74e2::/48 (CSPRNG, collision-checked vs the
Tailscale fd7a:115c:a1e0::/48 already in NetBox) -- pass it in once ratified.

Note on the /36 vs /32 aggregate: only the /36 is currently allocated by ARIN, so the
aggregate is the /36 (honest for an IPAM apex). D-101 frames it as region-0 of the /32;
once the /36 -> /32 expand completes, update this aggregate to /32.

DELIBERATE default: this script PREVIEWS by default and requires --commit to write
(safer for global roles/aggregates; matches the project's dry-run-first discipline).
This differs from netbox/dc-dc-prefixes-import.py, which commits by default with
--verify-only to preview -- the difference is intentional and documented here.

Usage:
    # preview (no writes) -- ULA aggregate skipped unless ORG_ULA_48 is set:
    NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=<token> \\
        python3 roles-aggregates-import.py

    # preview including the ULA aggregate:
    NETBOX_URL=... NETBOX_TOKEN=... ORG_ULA_48=fd50:840e:74e2::/48 \\
        python3 roles-aggregates-import.py

    # write:
    NETBOX_URL=... NETBOX_TOKEN=... ORG_ULA_48=fd50:840e:74e2::/48 \\
        python3 roles-aggregates-import.py --commit

Idempotent: re-running is safe. NetBox 4.x (tested against 4.5.8).
"""
from __future__ import annotations

import argparse
import ipaddress
import os
import sys
import urllib.parse

try:
    import pynetbox
except ImportError:
    sys.stderr.write("ERROR: pynetbox not installed. pip install pynetbox\n")
    sys.exit(1)


def die(msg: str, code: int = 1) -> None:
    sys.stderr.write(f"ERROR: {msg}\n")
    sys.exit(code)


def get_nb():
    url = os.environ.get("NETBOX_URL")
    token = os.environ.get("NETBOX_TOKEN")
    if not url:
        die("NETBOX_URL environment variable not set")
    if not token:
        die("NETBOX_TOKEN environment variable not set")
    nb = pynetbox.api(url, token=token)
    # WAF TRAP (references/platform-traps.md): upstream NetBox 403s the DEFAULT
    # python-requests User-Agent -- it looks EXACTLY like an auth failure but is
    # not (the same token works from curl). pynetbox is bitten too. The stdlib
    # tools (prod-draft-dump/sandbox-seed/d115-office-carve) already send an
    # accepted UA; these two pynetbox importers did NOT, so they could never have
    # written to the upstream apex (only the sandbox, which has no WAF). Set it
    # on the underlying requests session so EVERY call carries it.
    nb.http_session.headers["User-Agent"] = "curl/8.5.0"
    try:
        _ = nb.status()
    except Exception as exc:  # noqa: BLE001
        die(f"Could not reach NetBox at {url}: {exc}")
    return nb


# --- Data (facts / adopted model; the only operator literal is ORG_ULA_48 via env) ---

# A sandbox is local or the known Office1 sandbox address; anything else is treated
# as the production apex and needs --yes-write-upstream (guard in main()).
SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"}

ROLES = [
    ("provider-public", "Provider Public", 100,
     "Six-plane: provider public API VIPs + FIP/ext_net (GUA). D-052/D-101."),
    ("metal-admin", "Metal Admin", 110,
     "Six-plane: metal admin API, dual-stack v4+ULA (2026-07-09 amendment). D-052/D-101."),
    ("metal-internal", "Metal Internal", 120,
     "Six-plane: metal internal API endpoints, dual-stack v4+ULA. D-052/D-101."),
    ("data-tenant", "Data Tenant", 130,
     "Six-plane: geneve overlay, ULA-only; tenant GUA delegated separately. D-052/D-101."),
    # storage (weight 140) already exists -- intentionally omitted.
    # NAME is "Replication Plane", NOT "Replication" -- the legacy pre-six-plane role
    # `repl` ALREADY HOLDS the name "Replication" in the real draft, and NetBox enforces
    # UNIQUE NAMES as well as unique slugs. Creating this as "Replication" returns
    # 400 {'name': ['role with this name already exists.']}. This script had never met
    # a NetBox carrying the draft (only empty ones), so the collision went unseen until
    # 2026-07-13 -- when it 400'd MID-RUN and left the roles half-created.
    # The slug stays `replication` because that is what lib-net.sh SPACES6 and
    # dc-dc-prefixes-import.py look up.
    ("replication", "Replication Plane", 150,
     "Six-plane: Ceph cluster incl. cross-DC leg, ULA-only. D-052/D-101/D-108."),
]

RIRS = [
    # name, slug, is_private
    ("RFC 4193 ULA", "rfc-4193-ula", True),
    ("RFC 1918", "rfc-1918", True),
]

# prefix, rir-name, description. ULA aggregate handled separately (needs ORG_ULA_48).
AGGREGATES = [
    ("2602:f3e2::/36", "ARIN",
     "ARIN IPv6 allocation: the HELD /36 = region-0 of the planned /32 (D-101). "
     "Update to /32 after the expand completes."),
    ("23.157.124.0/24", "ARIN", "ARIN IPv4 allocation."),
    ("10.0.0.0/8", "RFC 1918", "RFC1918 private IPv4."),
    ("172.16.0.0/12", "RFC 1918", "RFC1918 private IPv4 (OOB space)."),
]


def validate_ula_48(cidr: str) -> str:
    try:
        net = ipaddress.ip_network(cidr, strict=True)
    except ValueError as exc:
        die(f"ORG_ULA_48='{cidr}' is not a valid CIDR: {exc}")
    if net.version != 6:
        die(f"ORG_ULA_48='{cidr}' must be IPv6")
    if net.prefixlen != 48:
        die(f"ORG_ULA_48='{cidr}' must be a /48 (got /{net.prefixlen})")
    if not str(net.network_address).startswith(("fd", "fc")):
        die(f"ORG_ULA_48='{cidr}' is not in the ULA range (fc00::/7)")
    return str(net)


def main() -> int:
    ap = argparse.ArgumentParser(description="Create VR1 six-plane roles + aggregates (idempotent).")
    ap.add_argument("--commit", action="store_true",
                    help="write to NetBox (default: preview only, no writes)")
    ap.add_argument("--yes-write-upstream", action="store_true",
                    help="Required (with --commit) to write to a NON-sandbox NetBox. Feeding "
                         "the production apex is an operator decision, not an import side effect.")
    args = ap.parse_args()
    commit = args.commit

    # UPSTREAM-WRITE GUARD (2026-07-14). This tool had NO hostname gate; until today
    # the WAF's 403 on the default User-Agent was the only thing stopping an accidental
    # --commit against the production apex. get_nb() now sends an accepted UA, so that
    # accidental-safety is gone -- an explicit gate is required (matches d115-office-carve.py).
    if commit:
        _host = (urllib.parse.urlparse(os.environ.get("NETBOX_URL", "")).hostname or "").lower()
        if _host not in SANDBOX_HOSTS and not args.yes_write_upstream:
            die(f"REFUSING to --commit to '{_host or os.environ.get('NETBOX_URL','?')}': it is not a "
                "known sandbox, so this is treated as the PRODUCTION apex. Feeding roles/aggregates "
                "upstream is an operator decision (C2), not a side effect of running an import. "
                "Re-run with --yes-write-upstream if that is genuinely what you intend.")
    mode = "COMMIT" if commit else "PREVIEW (no writes -- add --commit to apply)"

    nb = get_nb()
    print(f"NetBox: {os.environ['NETBOX_URL']}  |  mode: {mode}\n")

    created = existed = 0

    def act_create(kind: str, label: str, create_fn):
        nonlocal created, existed
        if commit:
            obj = create_fn()
            print(f"  CREATED  {kind:10s} {label}" + (f" (id={obj.id})" if obj else ""))
            created += 1
        else:
            print(f"  WOULD    {kind:10s} {label}")
            created += 1

    # --- PREFLIGHT -----------------------------------------------------------
    # Validate EVERY name/slug against NetBox BEFORE writing anything.
    #
    # On 2026-07-13 this script created 4 roles, then hit a 400 on the 5th (a NAME
    # collision with the legacy `repl` role) and DIED -- leaving the apex half
    # populated, with no RIRs and no aggregates, and no rollback. An IPAM importer
    # that can fail partway through is a tool that corrupts the thing it is meant
    # to populate. Check first; write only if the whole plan is viable.
    conflicts = []
    for slug, name, _w, _d in ROLES:
        if nb.ipam.roles.get(slug=slug):
            continue                                  # same slug -> we skip it anyway
        clash = nb.ipam.roles.get(name=name)
        if clash:
            conflicts.append(
                f"role name {name!r} is already held by slug {clash.slug!r} (id={clash.id}) "
                f"-- cannot create slug {slug!r}. NetBox enforces UNIQUE NAMES, not just slugs.")
    for name, slug, _p in RIRS:
        if nb.ipam.rirs.get(slug=slug) or nb.ipam.rirs.get(name=name):
            continue
        clash = nb.ipam.rirs.get(name=name)            # unique NAME, same as roles
        if clash:
            conflicts.append(
                f"RIR name {name!r} is already held by slug {clash.slug!r} (id={clash.id}) "
                f"-- cannot create slug {slug!r}.")

    # 2026-07-14 HARDENING. These two checks USED TO RUN AFTER THE ROLE-CREATION
    # LOOP. That is not a preflight -- it is a landmine: an apex without ARIN, or a
    # typo'd ORG_ULA_48, would commit 5 roles (and the RIRs) and THEN die, leaving
    # the production IPAM half-populated with no rollback. That is the EXACT failure
    # this preflight block was added to prevent, still armed one section lower.
    # A preflight that does not cover every die() downstream of the first write is
    # not a preflight.
    if not (nb.ipam.rirs.get(slug="arin") or nb.ipam.rirs.get(name="ARIN")):
        conflicts.append(
            "RIR 'ARIN' not found (expected pre-existing) -- the aggregates below are "
            "scoped to it. Create it first, then re-run.")

    ula_raw = os.environ.get("ORG_ULA_48")
    if ula_raw:
        try:
            validate_ula_48(ula_raw)                   # format-check BEFORE any write
        except SystemExit:
            conflicts.append(
                f"ORG_ULA_48={ula_raw!r} is not a valid RFC 4193 ULA /48. It is validated "
                "HERE, before the first write -- a typo must not cost a half-populated apex.")

    if conflicts:
        print("PREFLIGHT FAILED -- nothing was written:\n")
        for c in conflicts:
            print(f"  * {c}")
        die("resolve the problem(s) above, then re-run. NO changes were made.")
    # -------------------------------------------------------------------------

    # --- Roles ---
    print("== IPAM roles (six-plane; slugs match lib-net.sh SPACES6) ==")
    for slug, name, weight, desc in ROLES:
        if nb.ipam.roles.get(slug=slug):
            print(f"  EXISTS   role       {slug}")
            existed += 1
            continue
        act_create("role", slug,
                   lambda s=slug, n=name, w=weight, d=desc:
                   nb.ipam.roles.create(name=n, slug=s, weight=w, description=d))
    # storage sanity note
    if nb.ipam.roles.get(slug="storage"):
        print("  EXISTS   role       storage (pre-existing; left untouched)")
    else:
        print("  WARN     role       storage MISSING unexpectedly -- verify SPACES6 vs NetBox")

    # --- RIRs ---
    print("\n== RIRs ==")
    rir_ids: dict[str, object] = {}
    arin = nb.ipam.rirs.get(slug="arin") or nb.ipam.rirs.get(name="ARIN")
    if not arin:
        die("RIR 'ARIN' not found (expected pre-existing). Create it first, then re-run.")
    rir_ids["ARIN"] = arin.id
    print(f"  EXISTS   rir        ARIN (id={arin.id})")
    for name, slug, is_private in RIRS:
        existing = nb.ipam.rirs.get(slug=slug) or nb.ipam.rirs.get(name=name)
        if existing:
            rir_ids[name] = existing.id
            print(f"  EXISTS   rir        {name}")
            existed += 1
            continue
        rir_ids[name] = None  # not yet created (preview) -- resolved on commit below
        act_create("rir", name,
                   lambda n=name, s=slug, p=is_private:
                   nb.ipam.rirs.create(name=n, slug=s, is_private=p))
        if commit:
            rir_ids[name] = (nb.ipam.rirs.get(slug=slug)).id

    # --- Aggregates ---
    print("\n== Aggregates ==")
    agg_list = list(AGGREGATES)
    ula = os.environ.get("ORG_ULA_48")
    if ula:
        ula = validate_ula_48(ula)
        agg_list.append((ula, "RFC 4193 ULA", "Org ULA /48 (internal-only; D-101 literal)."))
    else:
        print("  SKIP     aggregate  <ORG_ULA_48 unset> -- ULA /48 aggregate skipped "
              "(set ORG_ULA_48 to include it; nothing guessed).")

    for prefix, rir_name, desc in agg_list:
        if nb.ipam.aggregates.get(prefix=prefix):
            print(f"  EXISTS   aggregate  {prefix} ({rir_name})")
            existed += 1
            continue
        rid = rir_ids.get(rir_name)
        act_create("aggregate", f"{prefix} ({rir_name})",
                   lambda p=prefix, r=rid, d=desc:
                   nb.ipam.aggregates.create(prefix=p, rir=r, description=d))

    print(f"\nSummary: {created} {'created' if commit else 'to create'}, {existed} already present.")
    if not commit:
        print("Preview only -- re-run with --commit to apply.")
    return 0


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