#!/usr/bin/env python3
"""
Apply the D-115 Office carve (and the roles it needs) to a NetBox.

D-115 option (a), ADOPTED 2026-07-13. This BLESSES what is already running --
every address here was MEASURED off the live Office1 build, not invented, so the
carve costs ZERO renumbering. What it fixes is that those addresses were SQUAT:
they existed on the wire with no allocation behind them.

What D-115 found, and what this creates:

  1. There is NO Office role in IPv4. Office1's LAN (10.10.0.0/24) and its LXD
     compose net (10.10.1.0/24) were squatting.
     -> new role `office` at 10.10.0.0/16, carved /22 per office.
        VR1 Off1 = 10.10.0.0/22 -> LAN .0/24 + compose .1/24 (+ .2/.3 spare).

  2. "Edge Networks" has a v6 /48 (2602:f3e2:fe::/48) but NO v4 counterpart, so
     the simulated ISP uplink 172.30.1.0/24 was squatting inside the OOB /12.
     -> new role `edge` at 172.30.0.0/16, its OWN role (NOT oob), carved out of
        the existing 172.16.0.0/12 container. Office1's WAN becomes legitimate.

  3. IPv6 has NO design freedom here -- the region pattern determines it:
     VR1's office /48 (2602:f3e2:f01::/48) already exists upstream, and VR0's
     Off0 shows the shape (e01:100::/56 container -> e01:100::/64 subnet).
     -> VR1 Off1 = 2602:f3e2:f01:100::/56 container + :100::/64 office subnet.

  4. That /56 needs a SITE. -> `vr1-off1` ("VR1 Off1"), region VR1.

NAMING (D-117): VR1's DCs are dc0/dc1 (matching the apex) but the OFFICE KEEPS
ITS NUMBER -- it is Off1, site `vr1-off1`. That site does not exist upstream, so
it is CREATED, not renamed, and nothing deployed changes. VR1 therefore reads
DC0/DC1/Off1 while VR0 reads DC0/DC1/Off0: an accepted cosmetic asymmetry.

NOT DONE HERE: VR1 DC1's v4 supernet (10.12.64.0/19, also D-115) belongs to
netbox/dc-dc-prefixes-import.py --dc dc1, which owns the six-plane carve. Doing
it in two places is how they drift.

DRY BY DEFAULT. Nothing is written without --commit.

WRITING UPSTREAM IS GATED IN CODE. Per the standing architecture the sim never
writes upstream casually: feeding refinements back to the production apex is an
operator decision. Pointing this at anything that is not an obvious sandbox
requires --yes-write-upstream ON TOP of --commit.

Usage (sandbox, on office1-netbox):
    sudo NETBOX_URL=http://localhost:8000 \
         NETBOX_TOKEN="$(cat /root/netbox-secrets/api.token)" \
         python3 d115-office-carve.py                 # preview
    ... --commit                                      # apply
"""

import argparse
import ipaddress
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

UA = "curl/8.5.0"   # upstream 403s the default python UA -- see platform-traps.md

SITE = {"slug": "vr1-off1", "name": "VR1 Off1", "region": "vr1"}

ROLES = [
    {"slug": "office", "name": "Office"},   # exists upstream; created if absent
    {"slug": "edge", "name": "Edge"},       # NEW -- v6 had Edge Networks, v4 had nothing
]

# Every CIDR below was MEASURED on the live Office1 build (docs/vr1-office1-as-built.md).
# status: container = an allocation block; active = a real subnet on the wire.
PREFIXES = [
    # --- IPv4: the Office role (D-115 gap 1) ---
    ("10.10.0.0/16",  "office", "container", None,
     "Office (v4) -- /22 per office (D-115)"),
    ("10.10.0.0/22",  "office", "container", "vr1-off1",
     "VR1 Off1 -- office /22 (D-115)"),
    ("10.10.0.0/24",  "office", "active",    "vr1-off1",
     "VR1 Off1 office1-local LAN -- Kea DHCP on the OPNsense edge (D-115)"),
    ("10.10.1.0/24",  "office", "active",    "vr1-off1",
     "VR1 Off1 LXD compose net (lxdbr0) -- MAAS DHCP (D-114/D-115)"),

    # --- IPv4: the Edge role (D-115 gap 4) ---
    ("172.30.0.0/16", "edge",   "container", None,
     "Edge (v4) -- simulated ISP/WAN segments; mirrors v6 2602:f3e2:fe::/48 (D-115)"),
    ("172.30.1.0/24", "edge",   "active",    "vr1-off1",
     "VR1 Off1 office1-wan -- simulated ISP uplink (D-115)"),

    # --- IPv6: forced by the region pattern (D-115 gap 3) ---
    ("2602:f3e2:f01:100::/56", "office", "container", "vr1-off1",
     "VR1 Off1 (D-115) -- mirrors VR0 Off0 e01:100::/56"),
    ("2602:f3e2:f01:100::/64", "office", "active",    "vr1-off1",
     "VR1 Off1 office subnet -- NOT YET DEPLOYED (v6 does not egress the lab)"),
]

# A sandbox is local, or the known Office1 sandbox address. Anything else is
# treated as production and requires the explicit upstream flag.
SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"}


def die(msg: str):
    print(f"FAIL: {msg}", file=sys.stderr)
    sys.exit(2)


class NB:
    def __init__(self, base, token):
        self.base = base.rstrip("/")
        self.token = token

    def _req(self, method, path, body=None):
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(f"{self.base}/api/{path}", data=data, method=method,
                                     headers={"Authorization": f"Token {self.token}",
                                              "Accept": "application/json",
                                              "Content-Type": "application/json",
                                              "User-Agent": UA})
        try:
            with urllib.request.urlopen(req, timeout=45) as r:
                return json.load(r) if r.status != 204 else None
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode(errors="replace")[:300]
            if exc.code == 403 and "v1 token" in detail:
                die("403 'Invalid v1 token' -- NetBox 4.6 wants the ASSEMBLED v2 token "
                    "nbt_<key>.<plaintext>, not the API's bare `token` field.")
            if exc.code == 403:
                die(f"403 on {path}. If curl works with this token, it is the upstream "
                    f"User-Agent filter, NOT the token (references/platform-traps.md).")
            die(f"HTTP {exc.code} {method} {path}: {detail}")

    def one(self, path, **flt):
        res = self._req("GET", f"{path}/?{urllib.parse.urlencode(flt)}&limit=1")
        return res["results"][0] if res["results"] else None

    def create(self, path, payload):
        return self._req("POST", f"{path}/", payload)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    ap.add_argument("--commit", action="store_true",
                    help="WRITE. Default is a DRY RUN that writes nothing.")
    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.")
    args = ap.parse_args()

    url = os.environ.get("NETBOX_URL")
    token = os.environ.get("NETBOX_TOKEN")
    if not url or not token:
        die("NETBOX_URL and NETBOX_TOKEN must be set.")

    host = (urllib.parse.urlparse(url).hostname or url).lower()
    is_sandbox = host in SANDBOX_HOSTS

    print(f"Target : {url}   ({'SANDBOX' if is_sandbox else 'NOT a known sandbox'})")
    if args.commit and not is_sandbox and not args.yes_write_upstream:
        die(f"REFUSING to --commit to '{host}': it is not a known sandbox, so this is "
            f"treated as the PRODUCTION apex. Feeding validated refinements upstream is "
            f"an operator decision, not a side effect of running an import. Re-run with "
            f"--yes-write-upstream if that is genuinely what you intend.")

    print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***"
          if not args.commit else "\n*** COMMITTING. ***")

    nb = NB(url, token)
    created = existing = 0

    print("\nIPAM roles:")
    for r in ROLES:
        if nb.one("ipam/roles", slug=r["slug"]):
            print(f"  EXISTS  role {r['slug']}")
            existing += 1
            continue
        if not args.commit:
            print(f"  [dry-run] would CREATE role {r['slug']} ({r['name']})")
            created += 1
            continue
        o = nb.create("ipam/roles", {"name": r["name"], "slug": r["slug"]})
        print(f"  CREATED role {r['slug']} (id={o['id']})")
        created += 1

    print("\nSite:")
    site = nb.one("dcim/sites", slug=SITE["slug"])
    site_id = site["id"] if site else None
    if site:
        print(f"  EXISTS  site {SITE['slug']} (id={site_id})")
        existing += 1
    else:
        region = nb.one("dcim/regions", slug=SITE["region"])
        if region is None:
            die(f"region '{SITE['region']}' is absent -- seed the draft first "
                f"(netbox/sandbox-seed.py).")
        if not args.commit:
            print(f"  [dry-run] would CREATE site {SITE['slug']} ({SITE['name']}) in region {SITE['region']}")
            created += 1
        else:
            o = nb.create("dcim/sites", {"name": SITE["name"], "slug": SITE["slug"],
                                         "region": region["id"], "status": "active"})
            site_id = o["id"]
            print(f"  CREATED site {SITE['slug']} (id={site_id})")
            created += 1

    print("\nPrefixes:")
    for cidr, role_slug, status, scope_slug, desc in PREFIXES:
        ipaddress.ip_network(cidr)          # fail loud on a typo, before any write
        if nb.one("ipam/prefixes", prefix=cidr):
            print(f"  EXISTS  {cidr}")
            existing += 1
            continue
        if not args.commit:
            print(f"  [dry-run] would CREATE {cidr:<24} role={role_slug:<7} {status:<9} "
                  f"scope={scope_slug or '-'}")
            created += 1
            continue
        role = nb.one("ipam/roles", slug=role_slug)
        if role is None:
            die(f"role '{role_slug}' absent -- cannot place {cidr}")
        payload = {"prefix": cidr, "role": role["id"], "status": status, "description": desc}
        if scope_slug:
            if site_id is None:
                die(f"site '{scope_slug}' absent -- cannot scope {cidr}")
            payload["scope_type"] = "dcim.site"
            payload["scope_id"] = site_id
        o = nb.create("ipam/prefixes", payload)
        print(f"  CREATED {cidr} (id={o['id']}) role={role_slug}")
        created += 1

    verb = "would create" if not args.commit else "created"
    print(f"\n{'='*66}\n{verb}: {created}   already present: {existing}")
    if not args.commit:
        print("DRY RUN -- nothing was written. Re-run with --commit.")
    return 0


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