#!/usr/bin/env python3
"""
Register the D-120 compose-/24 band ranges + the static service IP assignments.

D-120 (ADOPTED 2026-07-15) ratified a functional band layout for every MAAS-managed
compose /24, and D-120 Step 6 owes NetBox the record of it. On Office1's compose net
10.10.1.0/24 (lxdbr0) this creates:

  ip-ranges (the child bands D-120 names for NetBox: static / dynamic / node):
    10.10.1.2/24   - 10.10.1.49/24    static site-services   (.2-.49)
    10.10.1.100/24 - 10.10.1.200/24   MAAS dynamic           (.100-.200)
    10.10.1.201/24 - 10.10.1.254/24   deployed nodes         (.201-.254)
  ip-addresses (the two re-IP'd static services):
    10.10.1.10/24  office1-netbox      (re-IP from .201, D-120)
    10.10.1.11/24  office1-tailscale   (re-IP from .202, D-120)

(D-120's layout also names .1 gateway and .50-.99 reserved, but its NetBox instruction
is explicitly "child ranges (static/dynamic/node)" -- so those three, not the others.)

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

WRITING UPSTREAM IS GATED IN CODE. The sim never writes the production apex casually:
SANDBOX_HOSTS below is the allowlist; a --commit at any other host REFUSES without
--yes-write-upstream. WHOLE-PLAN PREFLIGHT: the parent compose net must exist and every
range/IP must fall inside it, checked BEFORE any create, so a bad plan cannot half-write.

Usage (on office1-netbox / through a tunnel, with the sandbox token):
    NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=<tok> python3 netbox/d120-compose-bands.py
    ... same, add --commit, to write.
"""
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

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

PARENT = "10.10.1.0/24"   # the compose net these bands/IPs live within (must pre-exist)

# start/end carry the /24 mask, per NetBox convention (netbox/ipv4-prefixes-import.py).
IP_RANGES = [
    ("10.10.1.2/24",   "10.10.1.49/24",  "active",
     "D-120 static site-services band (.2-.49) -- MAAS static-assign, outside the dynamic range"),
    ("10.10.1.100/24", "10.10.1.200/24", "active",
     "D-120 MAAS dynamic band (.100-.200) -- enlistment/commissioning/PXE/DHCP"),
    ("10.10.1.201/24", "10.10.1.254/24", "active",
     "D-120 deployed-node band (.201-.254) -- MAAS auto-assign for compute/OpenStack nodes"),
]
IP_ADDRESSES = [
    ("10.10.1.10/24", "active", "office1-netbox",
     "office1-netbox service (D-120 static band; re-IP from .201)"),
    ("10.10.1.11/24", "active", "office1-tailscale",
     "office1-tailscale subnet router (D-120 static band; re-IP from .202)"),
]


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


class NB:
    """Stdlib NetBox client -- same shape as the other sandbox-loop tools; UA-aware so
    it is not 403'd by the upstream User-Agent filter."""
    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.")
    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}': not a known sandbox, so treated as the "
            f"PRODUCTION apex. Re-run with --yes-write-upstream if that is intended.")

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

    nb = NB(url, token)

    # WHOLE-PLAN PREFLIGHT -- fail loud BEFORE any create so a bad plan cannot half-write.
    net = ipaddress.ip_network(PARENT)
    for s, e, _st, _d in IP_RANGES:
        for a in (s, e):
            if ipaddress.ip_interface(a).ip not in net:
                die(f"range endpoint {a} is outside the parent {PARENT}")
        if ipaddress.ip_interface(s).ip > ipaddress.ip_interface(e).ip:
            die(f"range {s} - {e} has start > end")
    for a, _st, _dns, _d in IP_ADDRESSES:
        if ipaddress.ip_interface(a).ip not in net:
            die(f"address {a} is outside the parent {PARENT}")
    if nb.one("ipam/prefixes", prefix=PARENT) is None:
        die(f"parent prefix {PARENT} absent in NetBox -- seed the D-115 carve first "
            f"(netbox/d115-office-carve.py). Refusing to place bands in an unallocated /24.")

    created = existing = 0

    print("\nIP ranges:")
    for start, end, status, desc in IP_RANGES:
        if nb.one("ipam/ip-ranges", start_address=start, end_address=end):
            print(f"  EXISTS  {start} - {end}")
            existing += 1
            continue
        if not args.commit:
            print(f"  [dry-run] would CREATE range {start} - {end}  ({status})")
            created += 1
            continue
        o = nb.create("ipam/ip-ranges", {"start_address": start, "end_address": end,
                                         "status": status, "description": desc})
        print(f"  CREATED range {start} - {end} (id={o['id']})")
        created += 1

    print("\nIP addresses:")
    for addr, status, dns, desc in IP_ADDRESSES:
        if nb.one("ipam/ip-addresses", address=addr):
            print(f"  EXISTS  {addr}")
            existing += 1
            continue
        if not args.commit:
            print(f"  [dry-run] would CREATE address {addr}  dns={dns}")
            created += 1
            continue
        o = nb.create("ipam/ip-addresses", {"address": addr, "status": status,
                                            "dns_name": dns, "description": desc})
        print(f"  CREATED address {addr} (id={o['id']})")
        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())
