#!/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 (dc1 or dc2, selected via --dc, mirroring the scripts/lib-net.sh
$DC selector convention, DOCFIX-151), 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

DC1 v4: INHERITS the DC0 six-plane CIDRs unchanged (D-101 -- "DC1 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 a DISTINCT NetBox site
(vr1-dc1), not the existing vr0-dc0 site.

DC2 v4: D-101 explicitly states DC2's supernet is "NetBox-assigned... not
hardcoded." No default is provided here -- DC2_V4_SUPERNET is a REQUIRED
environment variable with no fallback. Running with --dc dc2 and no
DC2_V4_SUPERNET set FAILS LOUD rather than guessing or reusing DC1's range.

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 dc1): /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 (dc1 -> ...:02xx, dc2 -> ...: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:
    # DC1 (v4 inherited, only v6 top-level blocks required):
    NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=<token> \\
        ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:1000::/40 \\
        python3 dc-dc-prefixes-import.py --dc dc1

    # DC2 (v4 supernet ALSO required, nothing defaulted):
    NETBOX_URL=... NETBOX_TOKEN=... \\
        ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:2000::/40 \\
        DC2_V4_SUPERNET=10.13.0.0/19 \\
        python3 dc-dc-prefixes-import.py --dc dc2

    # Preview only, no writes:
    ... python3 dc-dc-prefixes-import.py --dc dc1 --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

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

# -----------------------------------------------------------------------------
# DC1/DC2 site identity. Slugs are new -- no prior convention existed in this
# repo (grepped opentofu/*.tf and the buildout design; none found) -- chosen
# to match the D-106 Designate naming already ratified (dc1/dc2, vr1 label).
# -----------------------------------------------------------------------------

SITES = {
    "dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"},
    "dc2": {"slug": "vr1-dc2", "name": "VR1 DC2"},
}

# 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.
DC1_V4_PREFIXES = {
    "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",
}

# DC2 v4: NO default. Must come from DC2_V4_SUPERNET (env var), 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).
DC2_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)
    try:
        _ = nb.status()
    except Exception as exc:  # noqa: BLE001
        die(f"Could not reach NetBox at {url}: {exc}")
    return nb


def find_or_create_site(nb, slug: str, name: str):
    site = nb.dcim.sites.get(slug=slug)
    if site is not None:
        return site, 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_dc2_supernet(cidr: str) -> ipaddress.IPv4Network:
    try:
        net = ipaddress.ip_network(cidr, strict=True)
    except ValueError as exc:
        die(f"DC2_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}")
    if net.version != 4:
        die(f"DC2_V4_SUPERNET='{cidr}' is not an IPv4 prefix")
    if net.prefixlen > DC2_MIN_SUPERNET_PREFIXLEN:
        die(
            f"DC2_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold "
            f"six /22 planes -- need at least a /{DC2_MIN_SUPERNET_PREFIXLEN}"
        )
    dc1_nets = [ipaddress.ip_network(c) for c in DC1_V4_PREFIXES.values()]
    for d1 in dc1_nets:
        if net.overlaps(d1):
            die(
                f"DC2_V4_SUPERNET='{cidr}' overlaps DC1's inherited range {d1} -- "
                f"D-101 requires DC2 to be non-overlapping. Choose a different supernet."
            )
    return net


def carve_dc2_v4(supernet: ipaddress.IPv4Network) -> dict:
    subnets = list(supernet.subnets(new_prefix=22))
    if len(subnets) < len(PLANE_ORDER):
        die(
            f"DC2_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 (dc1 -> ...:02xx, dc2 ->
# ...:03xx) so the 4th hextet reads DC.NN in BOTH families.
DC_V6_INDEX = {"dc1": 0x02, "dc2": 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) -> None:
    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:
        created = nb.ipam.prefixes.create(**payload)
        print(f"  CREATED prefix {cidr} (id={created.id}) role={role_slug}")
    elif update:
        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=["dc1", "dc2"], help="Which DC to import (no default -- explicit, measured selection per the $DC convention)")
    parser.add_argument("--update", action="store_true", help="Update existing prefixes in place")
    parser.add_argument("--verify-only", action="store_true", help="Skip writes; only print verification block")
    args = parser.parse_args()

    dc = args.dc
    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"))

    if dc == "dc1":
        v4 = dict(DC1_V4_PREFIXES)
    else:
        dc2_supernet = validate_dc2_supernet(require_env("DC2_V4_SUPERNET", "D-101 explicitly requires DC2's v4 supernet be NetBox-assigned, not hardcoded"))
        v4 = carve_dc2_v4(dc2_supernet)

    v6_recs = carve_v6(dc, org_ula_48, dc_gua_prefix)

    nb = get_nb()
    site, site_created = find_or_create_site(nb, site_cfg["slug"], site_cfg["name"])
    print(f"Connected. Site '{site_cfg['name']}' (id={site.id}){' -- CREATED' if site_created else ''}.")

    if dc == "dc1":
        print(
            "\nNOTE: this may create prefixes identical to the existing 'vr0-dc0' "
            "site's entries (D-101: DC1 inherits 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 DC1 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("\nIPv4 prefixes:")
        for plane in PLANE_ORDER:
            create_or_report_prefix(
                nb, v4[plane], plane, site,
                f"VR1 {dc.upper()} {plane} (v4; D-101)", args.update,
            )
        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"VR1 {dc.upper()} {role_slug} ({kind}; D-101/D-111)",
                args.update,
            )

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


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