#!/usr/bin/env python3
"""
Dump the FROZEN v1 REFERENCE NetBox (netbox.baldurkeep.com) to a reviewable JSON
artifact. READ-ONLY. SOURCE SEPARATION (operator-directed 2026-07-25): this script
pulls ONLY the baldurkeep v1 reference (4.5.8, bare 40-char token, no writes during
VR1 per DOCFIX-195). To dump the CURRENT VR1 working apex / RECORD (office1-netbox,
4.6.4, v2 token), use the SEPARATE netbox/office1-record-dump.py -- one script per
source so a credential/URL can never be confused.

This is phase 1 of the two-phase sandbox seeding loop documented in
docs/session-ledger.md:

    upstream (draft source)  --[this script, READ-ONLY]-->  vr1-draft.json
    vr1-draft.json           --[netbox/sandbox-seed.py]-->  Office1 sandbox
    ...simulate VR1 in the sandbox, apply planned changes (D-115/D-117)...
    validated refinements    --[operator-gated]---------->  upstream

WHY TWO PHASES instead of one source->dest script:

  1. The upstream token NEVER travels. This script runs on vcloud (where
     ~/vr1-office1-creds/vr1-netbox.env lives); sandbox-seed.py runs on office1-netbox (where
     the sandbox token lives). Neither host holds the other's credential.
  2. "The sim NEVER writes upstream" becomes STRUCTURAL, not a discipline: the
     script that writes has no upstream credentials and no upstream URL.
  3. The JSON dump is a reviewable, diffable artifact. You can read exactly what
     is about to be seeded before anything is written.

This script performs NO writes of any kind. It has no --commit flag because it
has nothing to commit.

TRAP -- User-Agent (measured 2026-07-13, see references/platform-traps.md):
upstream NetBox sits behind a filter that 403s the default Python User-Agent.
The SAME token works from curl. It looks exactly like an auth failure and it is
not. We send an accepted UA. pynetbox (python-requests/...) is bitten too.

Usage:
    . ~/vr1-office1-creds/vr1-netbox.env                       # NETBOX_URL + NETBOX_TOKEN
    python3 netbox/prod-draft-dump.py --out netbox/draft/vr1-draft.json
"""

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

# Upstream is 4.5.8 and uses v1 (bare, 40-char) tokens. The Office1 sandbox is
# 4.6.4 and uses v2 (nbt_<key>.<plaintext>). Do NOT assume one shape fits both.
UA = "curl/8.5.0"

# What the VR1 carve actually needs to exist. Order is DEPENDENCY order and the
# seeder replays it verbatim -- parents before children.
ENDPOINTS = [
    ("ipam/rirs", ["name", "slug", "is_private", "description"]),
    ("ipam/roles", ["name", "slug", "weight", "description"]),
    ("dcim/regions", ["name", "slug", "parent", "description"]),
    ("dcim/sites", ["name", "slug", "status", "region", "description"]),
    ("ipam/aggregates", ["prefix", "rir", "description"]),
    ("ipam/prefixes", ["prefix", "status", "role", "scope_type", "scope_id",
                       "description", "is_pool", "mark_utilized"]),
    # D-120: the compose-/24 band ranges + the static service IP assignments. These
    # object classes were previously NOT dumped, so the sandbox loop could not verify
    # the D-120 load at all (the "tooling gap" logged in the session ledger). Ranges
    # get a synthetic "range" key (start-end) below, since KEY is one field.
    ("ipam/ip-ranges", ["start_address", "end_address", "status", "role", "description"]),
    ("ipam/ip-addresses", ["address", "status", "role", "dns_name", "description"]),
]


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


def require_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        die(f"{name} is not set. Source the upstream env first: . ~/vr1-office1-creds/vr1-netbox.env")
    return v


def get_all(base: str, token: str, path: str) -> list:
    """Paginate an endpoint fully. Read-only."""
    out = []
    url = f"{base}/api/{path}/?limit=200"
    while url:
        req = urllib.request.Request(url, headers={
            "Authorization": f"Token {token}",
            "Accept": "application/json",
            "User-Agent": UA,      # <-- the WAF trap. Do not remove.
        })
        try:
            with urllib.request.urlopen(req, timeout=45) as r:
                data = json.load(r)
        except urllib.error.HTTPError as exc:
            if exc.code == 403:
                die(f"403 on {path}. If curl works with this same token, it is the "
                    f"User-Agent filter, NOT the token -- see references/platform-traps.md.")
            die(f"HTTP {exc.code} on {path}: {exc.reason}")
        out += data["results"]
        nxt = data.get("next")
        url = nxt if nxt else None
    return out


def slim(obj: dict, fields: list) -> dict:
    """Keep only what the seeder needs, resolving nested refs to SLUGS.

    IDs are deliberately DISCARDED. A sandbox assigns its own ids; carrying
    upstream ids across would either collide or silently mis-link. Everything is
    re-linked by slug/prefix on the way in.
    """
    rec = {}
    for f in fields:
        v = obj.get(f)
        if isinstance(v, dict):
            # nested object (role, rir, region, parent, status) -> slug or value
            rec[f] = v.get("slug") or v.get("value")
        else:
            rec[f] = v
    return rec


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    ap.add_argument("--out", required=True, help="Path to write the JSON draft artifact")
    args = ap.parse_args()

    base = require_env("NETBOX_URL").rstrip("/")
    token = require_env("NETBOX_TOKEN")

    # Site id -> slug, so prefix scope_id (an upstream id) can be rewritten to a
    # slug the seeder can re-resolve against ITS OWN site ids.
    print(f"Reading upstream draft from {base} (READ-ONLY) ...")
    raw_sites = get_all(base, token, "dcim/sites")
    site_id_to_slug = {s["id"]: s["slug"] for s in raw_sites}
    # Prefixes can be scoped to a REGION as well as a site -- and most of the VR1
    # draft is region-scoped (the f00/f01 containers hang off region VR1, not off
    # a site). Mapping only dcim.site silently DROPPED those scopes on the way in.
    raw_regions = get_all(base, token, "dcim/regions")
    region_id_to_slug = {r["id"]: r["slug"] for r in raw_regions}

    draft = {"_source": base, "_note": "READ-ONLY dump. IDs discarded; refs are by slug."}
    for path, fields in ENDPOINTS:
        rows = raw_sites if path == "dcim/sites" else get_all(base, token, path)
        slimmed = []
        for o in rows:
            rec = slim(o, fields)
            if path == "ipam/prefixes":
                # scope_id is an upstream id -- rewrite to a slug, or drop the scope.
                sid = o.get("scope_id")
                st = o.get("scope_type")
                if sid is not None and st == "dcim.site":
                    rec["scope_site"] = site_id_to_slug.get(sid)
                elif sid is not None and st == "dcim.region":
                    rec["scope_region"] = region_id_to_slug.get(sid)
                rec.pop("scope_id", None)
            if path == "ipam/ip-ranges":
                # a range is identified by BOTH endpoints; give the fidelity check a
                # single stable key (start-end) the way prefixes key on "prefix".
                rec["range"] = f'{rec.get("start_address")}-{rec.get("end_address")}'
            slimmed.append(rec)
        draft[path] = slimmed
        print(f"  {path:<18} {len(slimmed)}")

    os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
    with open(args.out, "w") as fh:
        json.dump(draft, fh, indent=2, sort_keys=True)
        fh.write("\n")
    print(f"\nWrote {args.out}. NOTHING was written to NetBox.")
    return 0


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