#!/usr/bin/env python3
"""
Seed the D-124 transit role + the 172.31.0.0/24 transit supernet container.

These are the PRECONDITIONS that netbox/dc-rack-mgmt-import.py is deliberately
die-if-absent on (it creates NEITHER the role NOR the container -- see its docstring).
D-124 (operator-ratified 2026-07-16) carves a DEDICATED transit supernet, NOT under
Cloud, mirroring the D-115 `edge` role's dedicated 172.30.0.0/16:

  1. role `transit` -- the transit's OWN role (mirrors `edge`), NOT `infra`/`oob`.
  2. 172.31.0.0/24 -- the dedicated per-DC point-to-point transit supernet, as a
     CONTAINER. UNSCOPED, exactly like edge's /16 container: each DC's per-DC
     transit link is carved from it and scoped to that DC's site (dc-rack-mgmt-import
     places vr1-dc0's link -> site vr1-dc0; a future dc1's link -> vr1-dc1). Keeping
     the container unscoped is what lets it serve every DC.

After this runs (--commit), dc-rack-mgmt-import finds role+container+site and can
place the per-DC transit link. This tool creates ONLY the role + the container --
never a transit link prefix (that is dc-rack-mgmt-import's job) and never the site
(a precondition).

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

WRITING UPSTREAM IS GATED IN CODE (same standing rule as the sibling importers):
pointing this at anything that is not an obvious sandbox needs --yes-write-upstream
ON TOP of --commit. Feeding the production apex is an operator decision.

Usage (sandbox, on office1-netbox):
    sudo NETBOX_URL=http://localhost:8000 \
         NETBOX_TOKEN="$(cat /root/netbox-secrets/api.token)" \
         python3 d124-transit-seed.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

# The transit role + container. Values MUST match netbox/dc-rack-mgmt-import.py's
# ROLE_SLUG + CONTAINER (D-124), or that importer's preconditions still fail.
ROLE = {"slug": "transit", "name": "Transit"}
CONTAINER = "172.31.0.0/24"
CONTAINER_DESC = ("Transit (v4) -- dedicated per-DC point-to-point transit supernet "
                  "(D-124, operator-pinned 2026-07-16); UNSCOPED container, each DC's link "
                  "scoped to its site. Precondition for dc-rack-mgmt-import.")

# 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.")

    ipaddress.ip_network(CONTAINER)   # fail loud on a typo, before any write

    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 a seed. 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 role:")
    role = nb.one("ipam/roles", slug=ROLE["slug"])
    if role:
        print(f"  EXISTS  role {ROLE['slug']} (id={role['id']})")
        existing += 1
    elif not args.commit:
        print(f"  [dry-run] would CREATE role {ROLE['slug']} ({ROLE['name']})")
        created += 1
    else:
        role = nb.create("ipam/roles", {"name": ROLE["name"], "slug": ROLE["slug"]})
        print(f"  CREATED role {ROLE['slug']} (id={role['id']})")
        created += 1

    print("\nTransit supernet container:")
    if nb.one("ipam/prefixes", prefix=CONTAINER):
        print(f"  EXISTS  {CONTAINER}")
        existing += 1
    elif not args.commit:
        print(f"  [dry-run] would CREATE {CONTAINER:<16} role={ROLE['slug']:<7} container  scope=- (unscoped)")
        created += 1
    else:
        if role is None:
            die(f"role '{ROLE['slug']}' absent -- cannot place {CONTAINER} (seed the role first)")
        o = nb.create("ipam/prefixes", {"prefix": CONTAINER, "role": role["id"],
                                        "status": "container", "description": CONTAINER_DESC})
        print(f"  CREATED {CONTAINER} (id={o['id']}) role={ROLE['slug']} container")
        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.")
    else:
        print("Next: run netbox/dc-rack-mgmt-import.py to place the per-DC transit link + rack IP.")
    return 0


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