#!/usr/bin/env python3
"""
Prove a seeded sandbox is a FAITHFUL replica of the upstream draft, plus an
expected delta. Read-only; compares two JSON dumps, touches no NetBox.

WHY THIS EXISTS. Counts and idempotency CANNOT prove fidelity. On 2026-07-13 the
seeder silently dropped the scope on 17 of 90 prefixes (it mapped only
`dcim.site`, and most of the VR1 draft is `dcim.region`-scoped) -- and every
count still matched, and re-running was still perfectly idempotent. It was caught
by luck on a spot-check that happened to print `site None`. This script is the
check that would have caught it on purpose.

It diffs FIELD BY FIELD on every shared object, so a dropped or mangled field is
a hard failure rather than an invisible one.

Usage:
    # dump upstream (on vcloud), and the sandbox (on office1-netbox) -- SAME
    # read-only dumper, pointed at each in turn:
    python3 netbox/prod-draft-dump.py --out netbox/draft/vr1-draft.json
    python3 netbox/prod-draft-dump.py --out /tmp/sandbox-dump.json   # NETBOX_URL=sandbox

    python3 netbox/sandbox-fidelity-check.py \
        --upstream netbox/draft/vr1-draft.json --sandbox /tmp/sandbox-dump.json

Exit 0 = faithful replica + exactly the expected delta. Exit 1 = the seeder is
dropping or mangling data.
"""

import argparse
import json
import sys

ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
ap.add_argument("--upstream", required=True, help="JSON dump of the upstream draft")
ap.add_argument("--sandbox", required=True, help="JSON dump of the seeded sandbox")
args = ap.parse_args()

up = json.load(open(args.upstream))
sb = json.load(open(args.sandbox))

KEY={"ipam/rirs":"slug","ipam/roles":"slug","dcim/regions":"slug","dcim/sites":"slug",
     "ipam/aggregates":"prefix","ipam/prefixes":"prefix"}
# The FULL planned delta the sandbox is allowed to have over the upstream draft.
# Anything outside this is a tooling bug (a silent drop, or a stray write).
#   D-115  the office carve            (site vr1-off1, edge role, 8 prefixes)
#   D-117  VR1 DC0/DC1 six-plane       (36 prefixes -- 18 per DC)
#   D-118  the org ULA /48             (RIRs + aggregates)
#   G1/G2  six-plane roles + aggregates
def _dc_prefixes():
    out = set()
    for v4 in ["10.12.4.0/22","10.12.8.0/22","10.12.12.0/22","10.12.16.0/22",
               "10.12.32.0/22","10.12.36.0/22",                      # VR1 DC0 (inherited)
               "10.12.64.0/22","10.12.68.0/22","10.12.72.0/22","10.12.76.0/22",
               "10.12.80.0/22","10.12.84.0/22"]:                     # VR1 DC1 (D-115 /19)
        out.add(v4)
    for dc in ("f02", "f03"):
        out |= {f"2602:f3e2:{dc}:10::/60", f"2602:f3e2:{dc}:10::/64", f"2602:f3e2:{dc}:11::/64"}
    for n in ("2", "3"):                                             # DC nibble
        for nn in ("20", "30", "40", "50"):
            out.add(f"fd50:840e:74e2:{n}{nn}::/60")
            out.add(f"fd50:840e:74e2:{n}{nn}::/64")
        out.add(f"fd50:840e:74e2:{n}21::/64")                        # metal-internal shares metal /60
    return out

EXPECTED_NEW = {
    "ipam/roles": {"edge", "provider-public", "metal-admin", "metal-internal",
                   "data-tenant", "replication"},
    "dcim/sites": {"vr1-off1"},
    "ipam/rirs": {"rfc-4193-ula", "rfc-1918"},
    "ipam/aggregates": {"2602:f3e2::/36", "23.157.124.0/24", "10.0.0.0/8",
                        "172.16.0.0/12", "fd50:840e:74e2::/48"},
    "ipam/prefixes": {"10.10.0.0/16","10.10.0.0/22","10.10.0.0/24","10.10.1.0/24",
                      "172.30.0.0/16","172.30.1.0/24",
                      "2602:f3e2:f01:100::/56","2602:f3e2:f01:100::/64"} | _dc_prefixes(),
}
bad=0
for ep,k in KEY.items():
    U={r[k]:r for r in up.get(ep,[])}
    S={r[k]:r for r in sb.get(ep,[])}
    missing=set(U)-set(S)
    extra=set(S)-set(U)
    exp=EXPECTED_NEW.get(ep,set())
    print(f"\n{ep}:  upstream={len(U)}  sandbox={len(S)}")
    if missing:
        bad+=1; print(f"  !! MISSING FROM SANDBOX ({len(missing)}): {sorted(missing)[:6]}")
    unexpected=extra-exp
    if unexpected:
        bad+=1; print(f"  !! UNEXPECTED EXTRA ({len(unexpected)}): {sorted(unexpected)[:6]}")
    elif extra:
        print(f"  + expected D-115 additions ({len(extra)}): {sorted(extra)}")
    # FIELD-LEVEL fidelity on the shared keys -- this is the check that counts cannot make
    drift=[]
    for key in sorted(set(U)&set(S)):
        u,s=U[key],S[key]
        for f in set(u)|set(s):
            if f.startswith("_"): continue
            if u.get(f)!=s.get(f):
                drift.append((key,f,u.get(f),s.get(f)))
    if drift:
        bad+=1
        print(f"  !! FIELD DRIFT on {len(set(d[0] for d in drift))} object(s), {len(drift)} field(s):")
        for key,f,uv,sv in drift[:12]:
            print(f"       {key:<26} {f:<14} upstream={uv!r}  sandbox={sv!r}")
    else:
        print("  field-level: IDENTICAL on every shared object")
print("\n" + "=" * 70)
if bad == 0:
    print("SANDBOX = upstream draft + EXACTLY the planned delta (D-115/D-117/D-118). Nothing lost, nothing stray.")
    sys.exit(0)
print(f"!! {bad} fidelity problem(s) -- the seeder is dropping or mangling data")
sys.exit(1)

