#!/usr/bin/env python3
"""
Seed a SANDBOX NetBox from the JSON draft produced by netbox/prod-draft-dump.py.

Phase 2 of the two-phase seeding loop (see prod-draft-dump.py for the why).

THIS SCRIPT CANNOT WRITE UPSTREAM. It has no upstream URL and no upstream token
-- it only knows the JSON file and the sandbox it is pointed at. That is
deliberate: "the sim never writes upstream" is enforced by STRUCTURE, not by
remembering to pass the right flag.

DRY BY DEFAULT. Nothing is written without --commit. (The repo learned this the
hard way: dc-dc-prefixes-import.py used to write with no flag at all, and would
have silently bound VR1 DC0's prefixes to the wrong site -- D-117.)

Idempotent: existing objects are detected by natural key (slug, or the prefix
itself) and skipped. Re-running is safe.

IDs are never carried across. The draft refs everything by slug; this script
re-resolves those against the SANDBOX's own ids.

Auth: the sandbox (NetBox 4.6) uses v2 tokens -- the wire form is
`nbt_<key>.<plaintext>`. The API's `token` field alone is NOT usable (it parses
as a legacy v1 token and 403s with "Invalid v1 token", which is thoroughly
misleading). Pass the ASSEMBLED value.

Usage (on office1-netbox, where NetBox is on localhost and the token lives):
    sudo NETBOX_URL=http://localhost:8000 \
         NETBOX_TOKEN="$(cat /root/netbox-secrets/api.token)" \
         python3 sandbox-seed.py --draft vr1-draft.json            # preview
    ... --draft vr1-draft.json --commit                            # apply
"""

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

# Seeding order is DEPENDENCY order: a site needs its region; a prefix needs its
# role and its site. Prefixes do NOT need parent-prefix ordering -- NetBox
# derives the hierarchy from the CIDR itself.
ORDER = ["ipam/rirs", "ipam/roles", "dcim/regions", "dcim/sites",
         "ipam/aggregates", "ipam/prefixes"]

# Natural key per endpoint -- how we decide "does this already exist?"
NATURAL_KEY = {
    "ipam/rirs": "slug",
    "ipam/roles": "slug",
    "dcim/regions": "slug",
    "dcim/sites": "slug",
    "ipam/aggregates": "prefix",
    "ipam/prefixes": "prefix",
}


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


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

    def _req(self, method: str, path: str, body=None):
        url = f"{self.base}/api/{path}"
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(url, data=data, method=method, headers={
            "Authorization": f"Token {self.token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        })
        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'. This NetBox is 4.6: the token must be "
                    "the ASSEMBLED v2 form nbt_<key>.<plaintext>, not the API's bare "
                    "`token` field. See docs/vr1-office1-as-built.md.")
            die(f"HTTP {exc.code} {method} {path}: {detail}")

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

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

    def patch(self, path: str, payload: dict):
        return self._req("PATCH", f"{path}/", payload)


def resolve(nb: NB, cache: dict, path: str, slug: str):
    """slug -> sandbox id, cached. None if the slug is absent."""
    if slug is None:
        return None
    key = (path, slug)
    if key not in cache:
        obj = nb.get_one(path, slug=slug)
        cache[key] = obj["id"] if obj else None
    return cache[key]


def dep(nb: NB, cache: dict, planned: set, path: str, slug: str, commit: bool):
    """Resolve a dependency to (ok, id).

    In a DRY RUN the dependency may not exist yet because we are not creating
    anything -- but it IS in the plan, and will exist by the time we commit.
    Treating that as "missing" made every prefix cascade-skip behind its role,
    which reported a plan of 27/129 and looked like the draft was broken. A dry
    run must model the END STATE, not the current empty one.
    """
    if slug is None:
        return True, None
    rid = resolve(nb, cache, path, slug)
    if rid is not None:
        return True, rid
    if not commit and (path, slug) in planned:
        return True, None          # will exist; no id needed, we never POST
    return False, None


def build_payload(nb: NB, cache: dict, ep: str, rec: dict, planned: set, commit: bool):
    """Draft record -> sandbox POST body, re-linking every ref by slug."""
    p = {k: v for k, v in rec.items()
         if k not in ("role", "rir", "region", "parent", "scope_site",
                      "scope_region", "scope_type", "status") and v is not None}

    if rec.get("status"):
        p["status"] = rec["status"]

    if ep == "dcim/regions" and rec.get("parent"):
        ok, rid = dep(nb, cache, planned, "dcim/regions", rec["parent"], commit)
        if not ok:
            return None, f"parent region '{rec['parent']}' not present"
        p["parent"] = rid

    if ep == "dcim/sites" and rec.get("region"):
        ok, rid = dep(nb, cache, planned, "dcim/regions", rec["region"], commit)
        if not ok:
            return None, f"region '{rec['region']}' not present"
        p["region"] = rid

    if ep == "ipam/aggregates":
        ok, rid = dep(nb, cache, planned, "ipam/rirs", rec.get("rir"), commit)
        if not ok:
            return None, f"rir '{rec.get('rir')}' not present"
        p["rir"] = rid

    if ep == "ipam/prefixes":
        if rec.get("role"):
            ok, rid = dep(nb, cache, planned, "ipam/roles", rec["role"], commit)
            if not ok:
                return None, f"role '{rec['role']}' not present"
            p["role"] = rid
        if rec.get("scope_site"):
            ok, sid = dep(nb, cache, planned, "dcim/sites", rec["scope_site"], commit)
            if not ok:
                return None, f"site '{rec['scope_site']}' not present"
            p["scope_type"] = "dcim.site"
            if sid is not None:
                p["scope_id"] = sid
        elif rec.get("scope_region"):
            ok, rid = dep(nb, cache, planned, "dcim/regions", rec["scope_region"], commit)
            if not ok:
                return None, f"region '{rec['scope_region']}' not present"
            p["scope_type"] = "dcim.region"
            if rid is not None:
                p["scope_id"] = rid
    return p, None


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    ap.add_argument("--draft", required=True, help="JSON produced by prod-draft-dump.py")
    ap.add_argument("--commit", action="store_true",
                    help="WRITE to the sandbox. Default is a DRY RUN that writes nothing.")
    ap.add_argument("--update", action="store_true",
                    help="Also PATCH objects that already exist so they match the draft "
                         "(otherwise existing objects are left alone). Requires --commit.")
    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 (the SANDBOX's, not upstream's).")

    with open(args.draft) as fh:
        draft = json.load(fh)

    src = draft.get("_source", "?")

    # -----------------------------------------------------------------------
    # SAFETY GUARD -- runs BEFORE anything else touches the network.
    #
    # Never write to the instance the draft was READ from. This is
    # self-configuring: the draft records its own _source, so the guard keeps
    # working if upstream ever moves hosts. A hardcoded hostname denylist would
    # silently stop protecting the day the URL changed -- which is exactly the
    # class of stale-literal bug this repo keeps finding.
    #
    # An earlier version of this guard sat AFTER json.load() and after the
    # banner. It was dead code for any malformed draft, and it read as "safe"
    # to a reviewer. Guards go FIRST.
    # -----------------------------------------------------------------------
    def host(u: str) -> str:
        return (urllib.parse.urlparse(u).hostname or u).lower()

    if src != "?" and host(url) == host(src):
        die(f"REFUSING: NETBOX_URL points at the UPSTREAM NetBox ({host(src)}) -- the "
            f"instance this draft was READ FROM. This script seeds a SANDBOX only; "
            f"the sim never writes upstream.")

    nb = NB(url, token)
    print(f"Draft source : {src}")
    print(f"Sandbox      : {url}")
    if not args.commit:
        print("\n*** DRY RUN -- nothing will be written. Re-run with --commit to apply. ***")
    else:
        print("\n*** COMMITTING to the sandbox. ***")

    cache, planned = {}, set()
    created = existing = skipped = updated = 0

    for ep in ORDER:
        rows = draft.get(ep, [])
        if not rows:
            continue
        print(f"\n{ep}  ({len(rows)} in draft)")
        nkey = NATURAL_KEY[ep]
        for rec in rows:
            nval = rec.get(nkey)
            found = nb.get_one(ep, **{nkey: nval})
            if found:
                existing += 1
                cache[(ep, nval)] = found["id"]
                if args.update:
                    payload, why = build_payload(nb, cache, ep, rec, planned, args.commit)
                    if payload is None:
                        print(f"  SKIP    {nval}  -- {why}")
                        continue
                    if not args.commit:
                        print(f"  [dry-run] would UPDATE {nval}")
                        continue
                    nb.patch(f"{ep}/{found['id']}", payload)
                    print(f"  UPDATED {nval} (id={found['id']})")
                    updated += 1
                continue
            payload, why = build_payload(nb, cache, ep, rec, planned, args.commit)
            if payload is None:
                print(f"  SKIP    {nval}  -- {why}")
                skipped += 1
                continue
            if not args.commit:
                print(f"  [dry-run] would CREATE {nval}")
                planned.add((ep, nval))
                created += 1
                continue
            obj = nb.create(ep, payload)
            cache[(ep, nval)] = obj["id"]
            print(f"  CREATED {nval} (id={obj['id']})")
            created += 1

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


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