#!/usr/bin/env python3
"""
NetBox multi-DC / dual-stack prefix import for VR1 DC-DC (Omega Cloud).

Closes tooling gap register item #3 in docs/dc-dc-deployment-workflow.md:
extends the v1 (netbox/ipv4-prefixes-import.py) single-site IPv4-only import
to the VR1 two-DC, dual-stack model ratified in D-101 (docs/design-decisions.md).

Per DC (vr1-dc0 or vr1-dc1, selected via --dc, mirroring the scripts/lib-net.sh
$DC selector convention, DOCFIX-151/D-119), creates the six-plane structure with
the D-101 family matrix:

  Plane           v4                  v6 family
  provider-public  (see below)         GUA (dual-stack)
  metal-admin      (see below)         ULA (dual-stack, 2026-07-09 amendment)
  metal-internal   (see below)         ULA (dual-stack)
  data-tenant      (see below)         ULA-only
  storage          (see below)         ULA-only
  replication      (see below)         ULA-only

NAMING (D-117 + D-119): VR1's DCs are **VR1 DC0** (slug vr1-dc0) and **VR1 DC1**
(slug vr1-dc1), matching the NetBox apex, which already holds both site records.
This script once called them dc1/dc2 -- an off-by-one against the apex that would
have bound VR1 DC0's prefixes to the site vr1-dc1 (i.e. the OTHER DC), silently,
because it also used to write by default. Both defects are fixed.

D-119 goes further: **the --dc selector IS the apex slug** (identity map). There is
no offset table left to get wrong. Bare dc0/dc1/dc2 are RETIRED -- 'dc0' used to
mean VR0's LIVE testcloud in scripts/lib-net.sh but VR1's FIRST DC here: one
string, two clouds. EVERY "DCn" below is REGION-QUALIFIED, because an unqualified
"DC0" is ambiguous across VR0 and VR1 and that ambiguity is what produced the bug.

VR1 DC0 v4: INHERITS the **VR0 DC0** six-plane CIDRs unchanged (D-101 -- "the
first DC equals the validated template"), i.e. the SAME literals as
netbox/ipv4-prefixes-import.py, hardcoded here for that reason (this is not an
inferred value; it is the explicit text of an ADOPTED decision). Scoped to the
DISTINCT NetBox site **vr1-dc0** -- NOT the existing **vr0-dc0** site, which is
the rehearsal environment those literals came from.

VR1 DC1 v4: D-101 explicitly states the second DC's supernet is "NetBox-assigned
... not hardcoded." No default is provided here -- VR1_DC1_V4_SUPERNET is a REQUIRED
environment variable with no fallback. Running with --dc vr1-dc1 and no
VR1_DC1_V4_SUPERNET set FAILS LOUD rather than guessing or reusing VR1 DC0's range.
(It was DC2_V4_SUPERNET, then briefly DC1_V4_SUPERNET. BOTH old names are now
REJECTED BY NAME with a pointer: an unqualified "DC1" could mean VR0's dc0 OR VR1's
second DC, which is precisely the ambiguity D-119 deletes.)

IPv6 (both DCs): D-101 names two literals as NetBox-assigned and "NOT
hardcoded in this decision": the org ULA /48, and the per-DC GUA carve out
of the already-real ARIN 2602:f3e2::/32 region-0 /36 (the /32 itself IS a
real literal quoted in D-101's own text -- only its per-DC subdivision is
still open). Both are REQUIRED environment variables with no defaults:
ORG_ULA_48 and DC_GUA_PREFIX.

IMPORTANT -- what this script does NOT do: it does not invent the org ULA
/48 or the GUA carve. The operator must generate/obtain those (an RFC 4193
random ULA /48, and the actual per-DC GUA assignment from whoever manages
2602:f3e2::/32) and pass them in. This script only validates their FORMAT
and then applies a DETERMINISTIC, DOCUMENTED SUBDIVISION into per-plane
prefixes (see SUBCARVE SCHEME below). That subdivision scheme is this
script's own proposal -- it is NOT itself a ratified D-NNN sub-decision.
Review it before trusting the values this script would write to a real
NetBox. (Precedent: D-102's MTU sub-policy was folded into D-101 only after
explicit operator ratification; this carve scheme has not had that pass.)

SUBCARVE SCHEME (D-111 -- aligned to the DEPLOYED VR0-DC0/Willamette NN
mnemonic; see the D-111 decision record + docs/dc-dc-netbox-buildout-scope.md
section 5.1. Supersedes the original contiguous-plane-index proposal):
  - Net-byte (NN) offsets match the live cloud: provider :10 (+ API VIP :11),
    metal :20/:21, data :30, storage :40, replication :50. Each plane gets a
    /60 container + a /64 active, mirroring the deployed template.
  - GUA: provider-public only (D-101 family matrix). Out of DC_GUA_PREFIX (the
    per-DC site /48, e.g. 2602:f3e2:f02::/48 for VR1 DC0): /60 + /64 active at :10,
    API-VIP /64 at :11.
  - ULA: the five internal planes, out of a per-DC /56 of the org ULA /48. The
    /56 index is the GUA site nibble (VR1 DC0 -> ...:02xx, VR1 DC1 -> ...:03xx) so the
    4th hextet reads DC.NN in BOTH families. metal-admin (:20) and metal-internal
    (:21) SHARE the metal /60 (at :20), exactly as provider (:10) / API-VIP (:11)
    share the provider /60. data-tenant :30, storage :40, replication :50 each
    get their own /60 + /64.
  - Addresses are computed by direct index arithmetic (_sub_at), NEVER
    list(...subnets()) enumeration -- the DOCFIX-181 no-hang property is preserved.

Idempotent: re-running is safe (existing prefixes are detected and skipped
unless --update). NetBox version: 4.x (scope_type/scope_id, not legacy site=).

Usage:
    # DRY-RUN IS THE DEFAULT (D-117). Nothing is written without --commit.

    # VR1 DC0 (v4 inherited from VR0 DC0; only v6 top-level blocks required):
    NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=<token> \\
        ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f02::/48 \\
        python3 dc-dc-prefixes-import.py --dc vr1-dc0            # preview
    ... python3 dc-dc-prefixes-import.py --dc vr1-dc0 --commit   # write

    # VR1 DC1 (v4 supernet ALSO required, nothing defaulted):
    NETBOX_URL=... NETBOX_TOKEN=... \\
        ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f03::/48 \\
        VR1_DC1_V4_SUPERNET=10.12.64.0/19 \\
        python3 dc-dc-prefixes-import.py --dc vr1-dc1 --commit

    # DC_GUA_PREFIX is CROSS-CHECKED against --dc (D-119): a mismatched pair is
    # rejected, not silently written.

    # Verification block only (no plan, no writes):
    ... python3 dc-dc-prefixes-import.py --dc vr1-dc0 --verify-only

KNOWN PRE-EXISTING STALENESS (flagged, not fixed here): netbox/ipv4-prefixes-
import.py's role slugs (provider/metal/lbaas-management/openstack-tenant)
predate the D-052/D-053 six-plane cutover that scripts/lib-net.sh reflects.
This script uses NEW role slugs matching the CURRENT six-plane names
(provider-public/metal-admin/metal-internal/data-tenant/storage/replication)
since VR1 is being built against the current model, not the v1-era one.
These roles must exist in NetBox before running (create out of band, same
assumption netbox/ipv4-prefixes-import.py already makes for its own roles --
this script does not create IPAM roles, only prefixes/sites use them).

UNVALIDATED: no NETBOX_URL/live NetBox and no `pynetbox`-reachable instance
were available this session (workstation, not the vopenstack-jesse jumphost)
to run this end-to-end. Reviewed against the pynetbox API shape used
successfully by the existing v1 scripts in this same directory, not executed.
"""

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)

# -----------------------------------------------------------------------------
# VR1 DC site identity.
#
# HISTORY (kept deliberately -- this is the bug's own confession): the original
# comment here read "Slugs are new -- no prior convention existed in this repo
# (grepped opentofu/*.tf and the buildout design; none found)". It grepped the
# REPO. The convention lived in the APEX (NetBox), which is precisely where
# D-010/D-103 say naming authority lives. That is how the off-by-one got in.
# -----------------------------------------------------------------------------

# D-119: THE SELECTOR *IS* THE APEX SLUG. This is an IDENTITY map, and that is the
# whole point -- there is no offset table left to get wrong. The original defect was
# a WRONG LOOKUP TABLE (--dc dc1 bound to slug vr1-dc1 while treating f02::/48 as
# dc1's, though the apex binds f02 to VR1 DC0), which would have written the FIRST
# DC's prefixes onto the SECOND DC's site, silently. Deleting the mapping deletes
# the bug class.
#
# Slugs verified live against netbox.baldurkeep.com (2026-07-14):
#   2602:f3e2:f02::/48 -> site vr1-dc0  "Virtual Region 1 (VR1) Datacenter 0 (DC0)"
#   2602:f3e2:f03::/48 -> site vr1-dc1  "Virtual Region 1 (VR1) Datacenter 1 (DC1)"
# They are NOT a repo-local invention. Do not "fix" them to dc1/dc2 -- that is the
# exact off-by-one D-117 closed and D-119 made unrepresentable.
# A sandbox is local or the known Office1 sandbox address; anything else is treated
# as the production apex and needs --yes-write-upstream (see the guard in main()).
SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"}

SITES = {
    "vr1-dc0": {"slug": "vr1-dc0", "name": "VR1 DC0"},
    "vr1-dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"},
}
# The structural invariant D-119 buys us. If a future edit reintroduces an offset,
# this fails at import time rather than silently mis-binding a datacenter.
assert all(k == v["slug"] for k, v in SITES.items()), \
    "SITES must be an IDENTITY map (D-119): the --dc selector IS the apex site slug"

# The apex's MEASURED DC->GUA binding. The selector cannot be allowed to disagree
# with the addressing: without this, `--dc vr1-dc0 DC_GUA_PREFIX=<f03>` is accepted
# and writes the SECOND DC's GUA, with the FIRST DC's ULA nibble, onto the FIRST
# DC's site -- a silently mis-bound datacenter assembled from two disagreeing
# sources. Identity-mapping the slug alone does NOT close that; this does.
EXPECTED_GUA = {
    "vr1-dc0": "2602:f3e2:f02::/48",
    "vr1-dc1": "2602:f3e2:f03::/48",
}

# Fixed plane order -- must match scripts/lib-net.sh's PLANE_NAME exactly
# (CIDR-keyed there; name-keyed here, same six planes, same order).
PLANE_ORDER = [
    "provider-public",
    "metal-admin",
    "metal-internal",
    "data-tenant",
    "storage",
    "replication",
]

# D-101 family matrix: which v6 family (if any) each plane carries.
# "gua" = dual-stack v4+GUA; "ula" = dual-stack v4+ULA; "ula-only" = no v4
# plane object created for the v6 leg (data-tenant/storage/replication ARE
# dual-stack-capable per D-102's Phase-A rollback posture text, but the
# ADOPTED default is IPv6-only for those three -- see D-101/D-102 body).
PLANE_V6_FAMILY = {
    "provider-public": "gua",
    "metal-admin": "ula",
    "metal-internal": "ula",
    "data-tenant": "ula-only",
    "storage": "ula-only",
    "replication": "ula-only",
}

# DC1 v4: the DC0 six-plane CIDRs, UNCHANGED, per D-101 ("DC1 equals the
# validated template"). Identical to netbox/ipv4-prefixes-import.py's
# historical single-plane set is NOT the comparison here -- these are the
# CURRENT six-plane values from scripts/lib-net.sh's PLANE_CIDRS, which is
# this repo's single source of truth for that layout.
VR0_DC0_TEMPLATE_V4 = {
    "provider-public": "10.12.4.0/22",
    "metal-admin": "10.12.8.0/22",
    "metal-internal": "10.12.12.0/22",
    "data-tenant": "10.12.16.0/22",
    "storage": "10.12.32.0/22",
    "replication": "10.12.36.0/22",
}

# VR1 DC1 (the SECOND DC) v4: NO default. From VR1_DC1_V4_SUPERNET (env), carved into
# six sequential /22s in PLANE_ORDER, exactly mirroring DC1's /22-per-plane
# structure. The supernet must be large enough to hold six non-overlapping
# /22s (a /19 holds exactly eight /22s -- six used, two spare; anything
# smaller than /19 FAILS LOUD).
VR1_DC1_MIN_SUPERNET_PREFIXLEN = 19  # a /19 = exactly 8x /22, room for the six planes


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


def get_nb() -> "pynetbox.api":
    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


class _PlannedSite:
    """A site that does not exist yet, in a run that is not allowed to create it.

    D-117: a dry run against a FRESH NetBox must still be able to show the whole
    plan -- that is the entire point of previewing against an empty apex. Bailing
    out with "no site id" would make --dry-run useless exactly when it matters
    most. Nothing is ever written from one of these: id is None, and every write
    path returns before touching the API."""
    def __init__(self, slug: str, name: str):
        self.id = None
        self.slug = slug
        self.name = name


def find_or_create_site(nb, slug: str, name: str, commit: bool):
    """D-117: a site is a WRITE. Without --commit we return a _PlannedSite stub
    instead of creating one, so the rest of the plan can still be printed."""
    site = nb.dcim.sites.get(slug=slug)
    if site is not None:
        return site, False
    if not commit:
        return _PlannedSite(slug, name), False
    # Minimal site record -- status defaults to NetBox's own default (active).
    created = nb.dcim.sites.create(name=name, slug=slug)
    return created, True


def find_role(nb, slug: str):
    role = nb.ipam.roles.get(slug=slug)
    if role is None:
        die(
            f"IPAM role with slug '{slug}' not found in NetBox. Create it first "
            f"(out of band) -- this script does not create roles, matching "
            f"netbox/ipv4-prefixes-import.py's existing assumption."
        )
    return role


# -----------------------------------------------------------------------------
# Required-literal validation. Each of these dies with a specific, actionable
# message rather than silently defaulting -- the whole point of this pipeline
# closing gap #3 is that these values are NOT invented here.
# -----------------------------------------------------------------------------


def require_env(name: str, why: str) -> str:
    val = os.environ.get(name)
    if not val:
        die(f"{name} environment variable is required and not set -- {why}")
    return val


def validate_ula_48(cidr: str) -> ipaddress.IPv6Network:
    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}' is not an IPv6 prefix")
    if net.prefixlen != 48:
        die(f"ORG_ULA_48='{cidr}' must be a /48 (got /{net.prefixlen})")
    # RFC 4193 ULA range is fc00::/7; the locally-assigned form sets the L bit
    # (fd00::/8). Reject anything outside fc00::/7 -- a GUA or other range
    # here would be a real misconfiguration, not a style nit.
    if not net.subnet_of(ipaddress.ip_network("fc00::/7")):
        die(f"ORG_ULA_48='{cidr}' is not within the ULA range fc00::/7 (RFC 4193)")
    return net


def validate_gua_prefix(cidr: str) -> ipaddress.IPv6Network:
    try:
        net = ipaddress.ip_network(cidr, strict=True)
    except ValueError as exc:
        die(f"DC_GUA_PREFIX='{cidr}' is not a valid CIDR: {exc}")
    if net.version != 6:
        die(f"DC_GUA_PREFIX='{cidr}' is not an IPv6 prefix")
    parent = ipaddress.ip_network("2602:f3e2::/32")
    if not net.subnet_of(parent):
        die(
            f"DC_GUA_PREFIX='{cidr}' is not within the D-101-cited ARIN block "
            f"{parent} -- if this is a genuinely different GUA allocation, "
            f"that is itself a design-decision-worthy change (grep D-101 "
            f"first), not something this script should silently accept."
        )
    if net.prefixlen >= 64:
        die(f"DC_GUA_PREFIX='{cidr}' must be shorter than /64 (need room to carve a /64 for provider-public)")
    return net


def validate_vr1_dc1_supernet(cidr: str) -> ipaddress.IPv4Network:
    try:
        net = ipaddress.ip_network(cidr, strict=True)
    except ValueError as exc:
        die(f"VR1_DC1_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}")
    if net.version != 4:
        die(f"VR1_DC1_V4_SUPERNET='{cidr}' is not an IPv4 prefix")
    if net.prefixlen > VR1_DC1_MIN_SUPERNET_PREFIXLEN:
        die(
            f"VR1_DC1_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold "
            f"six /22 planes -- need at least a /{VR1_DC1_MIN_SUPERNET_PREFIXLEN}"
        )
    tmpl_nets = [ipaddress.ip_network(c) for c in VR0_DC0_TEMPLATE_V4.values()]
    for d1 in tmpl_nets:
        if net.overlaps(d1):
            die(
                f"VR1_DC1_V4_SUPERNET='{cidr}' overlaps VR1 DC0's inherited range {d1} -- "
                f"D-101 requires VR1 DC1 to be non-overlapping. Choose a different supernet."
            )
    return net


def carve_vr1_dc1_v4(supernet: ipaddress.IPv4Network) -> dict:
    subnets = list(supernet.subnets(new_prefix=22))
    if len(subnets) < len(PLANE_ORDER):
        die(
            f"VR1_DC1_V4_SUPERNET yields only {len(subnets)} /22 subnet(s), "
            f"need {len(PLANE_ORDER)}"
        )
    return {plane: str(subnets[i]) for i, plane in enumerate(PLANE_ORDER)}


# D-111 alignment: lay the v6 legs out on the DEPLOYED VR0-DC0/Willamette NN
# net-byte mnemonic (provider :10 [+VIP :11], metal :20/:21, data :30, storage
# :40, replication :50), /60-per-plane container + /64 active, mirroring the
# live template. Replaces the earlier contiguous-plane-index subcarve. See the
# D-111 decision record + docs/dc-dc-netbox-buildout-scope.md section 5.1.
#
# Per-DC ULA /56 index aligned to the GUA site nibble (VR1 DC0 -> ...:02xx, VR1 DC1 ->
# ...:03xx) so the 4th hextet reads DC.NN in BOTH families.
DC_V6_INDEX = {"vr1-dc0": 0x02, "vr1-dc1": 0x03}


def _sub_at(base: ipaddress.IPv6Network, new_prefixlen: int, index: int) -> ipaddress.IPv6Network:
    """The index-th subnet of `base` at new_prefixlen, computed by direct
    arithmetic -- NOT list(base.subnets(...)) enumeration, which would
    materialize up to millions of objects and hang (DOCFIX-181)."""
    if new_prefixlen < base.prefixlen:
        die(f"internal: new_prefixlen /{new_prefixlen} shorter than base {base}")
    net_int = int(base.network_address) + index * (1 << (128 - new_prefixlen))
    return ipaddress.ip_network((net_int, new_prefixlen))


def carve_v6(dc: str, org_ula_48: ipaddress.IPv6Network, dc_gua_prefix: ipaddress.IPv6Network) -> list:
    """Return [(cidr, role_slug, kind)] for this DC's v6 legs on the deployed NN
    layout. provider-public = GUA out of DC_GUA_PREFIX; the five internal planes
    = ULA out of a per-DC /56 of the org ULA /48 (D-101 family matrix)."""
    ula = _sub_at(org_ula_48, 56, DC_V6_INDEX[dc])
    recs = [
        (str(_sub_at(dc_gua_prefix, 60, 0x10 >> 4)), "provider-public", "GUA /60"),
        (str(_sub_at(dc_gua_prefix, 64, 0x10)),      "provider-public", "GUA /64 active"),
        (str(_sub_at(dc_gua_prefix, 64, 0x11)),      "provider-public", "GUA VIP /64"),
        (str(_sub_at(ula, 60, 0x20 >> 4)),           "metal-admin",     "ULA metal /60 (admin+internal)"),
        (str(_sub_at(ula, 64, 0x20)),                "metal-admin",     "ULA /64 active"),
        (str(_sub_at(ula, 64, 0x21)),                "metal-internal",  "ULA /64 active (in metal /60)"),
    ]
    for plane, nn in (("data-tenant", 0x30), ("storage", 0x40), ("replication", 0x50)):
        recs.append((str(_sub_at(ula, 60, nn >> 4)), plane, "ULA /60"))
        recs.append((str(_sub_at(ula, 64, nn)),      plane, "ULA /64 active"))
    return recs


# -----------------------------------------------------------------------------
# NetBox writes
# -----------------------------------------------------------------------------


def create_or_report_prefix(nb, cidr: str, role_slug: str, site, description: str, update: bool, commit: bool) -> None:
    """D-117: DRY BY DEFAULT. Without commit=True this writes NOTHING -- it only
    prints the plan. This tool used to write by default, which is how an
    off-by-one site binding could have landed in the IPAM apex unannounced."""
    role = find_role(nb, role_slug)
    existing = nb.ipam.prefixes.get(prefix=cidr)
    payload = {
        "prefix": cidr,
        "role": role.id,
        "description": description,
        "scope_type": "dcim.site",
        "scope_id": site.id,
    }
    if existing is None:
        if not commit:
            print(f"  [dry-run] would CREATE prefix {cidr} role={role_slug} -> site {site.name}")
            return
        created = nb.ipam.prefixes.create(**payload)
        print(f"  CREATED prefix {cidr} (id={created.id}) role={role_slug}")
    elif update:
        if not commit:
            print(f"  [dry-run] would UPDATE prefix {cidr} (id={existing.id}) role={role_slug} -> site {site.name}")
            return
        existing.update(payload)
        print(f"  UPDATED prefix {cidr} (id={existing.id}) role={role_slug}")
    else:
        print(f"  EXISTS  prefix {cidr} (id={existing.id}) -- skipped (use --update to overwrite)")


def verify(nb, site, v4: dict, v6_recs: list) -> None:
    print()
    print("=" * 72)
    print(f"Verification -- final state for site {site.name} (id={site.id})")
    print("=" * 72)
    print("\nIPv4 (per plane):")
    for plane in PLANE_ORDER:
        cidr = v4[plane]
        p = nb.ipam.prefixes.get(prefix=cidr)
        status = "MISSING" if p is None else f"OK (id={p.id})"
        print(f"  {plane.ljust(16)} {cidr.ljust(18)} {status}")
    print("\nIPv6 (D-101 family matrix / D-111 NN layout):")
    for cidr, role_slug, kind in v6_recs:
        p = nb.ipam.prefixes.get(prefix=cidr)
        status = "MISSING" if p is None else f"OK (id={p.id})"
        print(f"  {role_slug.ljust(16)} {cidr.ljust(26)} {kind.ljust(24)} {status}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    parser.add_argument("--dc", required=True, choices=["vr1-dc0", "vr1-dc1"], help="Which VR1 DC to import. D-119: the selector IS the apex site slug (identity map, no offset table). Bare dc0/dc1/dc2 are RETIRED -- they were ambiguous across regions.")
    parser.add_argument("--update", action="store_true", help="Update existing prefixes in place (requires --commit)")
    parser.add_argument("--commit", action="store_true", help="WRITE to NetBox. Default is a DRY RUN that writes nothing (D-117 -- this tool used to write by default)")
    parser.add_argument("--verify-only", action="store_true", help="Skip the plan entirely; only print the verification block")
    parser.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 = parser.parse_args()

    # UPSTREAM-WRITE GUARD (2026-07-14). This tool had NO hostname gate, and until
    # today the WAF's 403 on the default User-Agent was the only thing stopping an
    # accidental --commit against the production apex. Now that get_nb() sends an
    # accepted UA, that accidental-safety is GONE -- so an explicit gate is required,
    # matching d115-office-carve.py. A sandbox is local or the known Office1 address;
    # anything else is treated as production.
    if args.commit and not args.verify_only:
        _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 validated prefixes "
                "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.")

    # D-119: reject BOTH historical names by NAME rather than ignoring them. An
    # operator with muscle memory could plausibly export either -- and both are now
    # ambiguous ("DC1" could mean VR0's dc0 OR VR1's second DC). Silence would give
    # them "required env missing" with no hint the variable moved under them.
    for retired in ("DC1_V4_SUPERNET", "DC2_V4_SUPERNET"):
        if os.environ.get(retired) and not os.environ.get("VR1_DC1_V4_SUPERNET"):
            die(f"{retired} is set, but that name is RETIRED (D-119). An unqualified "
                "'DC1'/'DC2' is exactly the cross-region ambiguity D-119 deletes. The "
                "variable is now VR1_DC1_V4_SUPERNET (VR1's SECOND DC, slug vr1-dc1). "
                "Re-export it under the new name -- refusing to guess.")

    dc = args.dc
    commit = args.commit
    site_cfg = SITES[dc]

    org_ula_48 = validate_ula_48(require_env("ORG_ULA_48", "D-101's org ULA /48 -- not defaulted, must be a real RFC 4193 /48 you generated"))
    dc_gua_prefix = validate_gua_prefix(require_env("DC_GUA_PREFIX", f"the {dc} carve out of ARIN 2602:f3e2::/32 region-0 /36 cited in D-101 -- not defaulted"))

    # D-119 BREAK-2 GUARD. The selector must not be allowed to disagree with the
    # addressing. Without this, `--dc vr1-dc0 DC_GUA_PREFIX=<f03::/48>` is accepted:
    # it writes the SECOND DC's GUA prefixes, with the FIRST DC's ULA nibble (the
    # ULA index is keyed off --dc, independently), all scoped to the FIRST DC's
    # site -- a silently mis-bound datacenter assembled from two disagreeing
    # sources. Identity-mapping the site slug does NOT close this hole; this does.
    if str(dc_gua_prefix) != EXPECTED_GUA[dc]:
        die(f"DC_GUA_PREFIX={dc_gua_prefix} does not match {dc}'s MEASURED apex block "
            f"({EXPECTED_GUA[dc]}). Refusing to bind one DC's addressing to another "
            f"DC's site -- that is the exact off-by-one D-117/D-119 exist to kill.")

    if dc == "vr1-dc0":
        v4 = dict(VR0_DC0_TEMPLATE_V4)
    else:
        second_supernet = validate_vr1_dc1_supernet(require_env("VR1_DC1_V4_SUPERNET", "D-101 explicitly requires the SECOND DC's v4 supernet be NetBox-assigned, not hardcoded (D-115 assigns 10.12.64.0/19)"))
        v4 = carve_vr1_dc1_v4(second_supernet)

    v6_recs = carve_v6(dc, org_ula_48, dc_gua_prefix)

    nb = get_nb()

    # --- WHOLE-PLAN PREFLIGHT (2026-07-14 hardening) --------------------------
    # Resolve EVERY IPAM role this run needs BEFORE the first write. find_role()
    # used to be called lazily INSIDE the write loop, so against an apex missing a
    # six-plane role (true of upstream TODAY) a --commit would create the site and
    # the first 4 prefixes, then die on the 5th -- a half-written datacenter in the
    # production IPAM, with no rollback. Same failure class as the roles importer's
    # 2026-07-13 partial write. An importer that can die partway through corrupts
    # the thing it exists to populate.
    needed_roles = list(PLANE_ORDER) + [r for _c, r, _k in v6_recs]
    missing = sorted({r for r in needed_roles if nb.ipam.roles.get(slug=r) is None})
    if missing:
        print("PREFLIGHT FAILED -- nothing was written:\n")
        for r in missing:
            print(f"  * IPAM role {r!r} does not exist in NetBox")
        die("create the role(s) above (netbox/roles-aggregates-import.py), then re-run. "
            "NO changes were made -- refusing to half-write a datacenter.")
    # -------------------------------------------------------------------------

    site, site_created = find_or_create_site(nb, site_cfg["slug"], site_cfg["name"], commit)
    if isinstance(site, _PlannedSite):
        print(f"[dry-run] would CREATE site '{site.name}' ({site.slug}) -- it does not exist yet.")
    else:
        print(f"Connected. Site '{site_cfg['name']}' (id={site.id}){' -- CREATED' if site_created else ''}.")

    if dc == "vr1-dc0":
        print(
            "\nNOTE: this may create prefixes identical to the existing 'vr0-dc0' "
            "site's entries (D-101: VR1 DC0 inherits VR0 DC0's v4 layout unchanged). "
            "NetBox permits duplicate global prefixes (soft-warn, not a hard "
            "block) but this is a real IPAM-hygiene decision for the operator: "
            "either retire/delete the vr0-dc0 entries once VR1 DC0 supersedes that "
            "rehearsal environment, or knowingly keep both for historical "
            "record. This script does not decide that for you."
        )

    if not args.verify_only:
        print("\n*** DRY RUN -- nothing will be written. Re-run with --commit to apply. ***"
              if not commit else "\n*** COMMITTING to NetBox. ***")
        print("\nIPv4 prefixes:")
        for plane in PLANE_ORDER:
            create_or_report_prefix(
                nb, v4[plane], plane, site,
                f"{site_cfg['name']} {plane} (v4; D-101)", args.update, commit,
            )
        print("\nIPv6 prefixes (D-101 family matrix on the D-111 NN layout):")
        for cidr, role_slug, kind in v6_recs:
            create_or_report_prefix(
                nb, cidr, role_slug, site,
                f"{site_cfg['name']} {role_slug} ({kind}; D-101/D-111)",
                args.update, commit,
            )

    verify(nb, site, v4, v6_recs)
    print("\nDone.")
    return 0


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