#!/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",
     # D-120: the compose-/24 band ranges + the static service IPs. Ranges key on the
     # dumper's synthetic "range" (start-end); IPs on "address".
     "ipam/ip-ranges":"range","ipam/ip-addresses":"address"}
# 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
# DC delta prefixes AND their INTENDED scope (the DC's own site). The DC->site
# binding is the D-119 identity: GUA f02 / ULA nibble 2 / the first six /22s ->
# vr1-dc0; f03 / nibble 3 / the last six -> vr1-dc1. Built in ONE place so the CIDR
# set and its scope map cannot drift apart.
def _dc_prefix_scopes():
    scope = {}
    v4 = {"vr1-dc0": ["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)
          "vr1-dc1": ["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)
    for site, cidrs in v4.items():
        for c in cidrs:
            scope[c] = ("site", site)
    for dc, site in (("f02", "vr1-dc0"), ("f03", "vr1-dc1")):
        for c in (f"2602:f3e2:{dc}:10::/60", f"2602:f3e2:{dc}:10::/64", f"2602:f3e2:{dc}:11::/64"):
            scope[c] = ("site", site)
    for n, site in (("2", "vr1-dc0"), ("3", "vr1-dc1")):            # DC nibble
        for nn in ("20", "30", "40", "50"):
            scope[f"fd50:840e:74e2:{n}{nn}::/60"] = ("site", site)
            scope[f"fd50:840e:74e2:{n}{nn}::/64"] = ("site", site)
        scope[f"fd50:840e:74e2:{n}21::/64"] = ("site", site)        # metal-internal shares metal /60
    return scope

def _dc_prefixes():
    return set(_dc_prefix_scopes())

# INTENDED scope per DELTA prefix: ("site", slug) / ("region", slug) / None for an
# intentionally UNSCOPED org-level role container. The two role /16s (office, edge)
# are unscoped BY DESIGN in d115-office-carve.py -- mirroring the 27 unscoped role
# containers ALREADY in the upstream apex (10.0.0.0/8, 172.16.0.0/12, ...). Checking
# scope == INTENDED (not merely "has a scope") kills the false "NO SCOPE" failure on
# those two AND still catches a DROPPED scope (the 17-prefix region loss) or a
# WRONG-TARGET binding (a DC0 prefix bound to vr1-dc1 -- the D-117 near-miss).
EXPECTED_PREFIX_SCOPE = {
    "10.10.0.0/16": None,                          # office role container -- unscoped by design
    "172.30.0.0/16": None,                         # edge role container -- unscoped by design
    "10.10.0.0/22": ("site", "vr1-off1"),
    "10.10.0.0/24": ("site", "vr1-off1"),
    "10.10.1.0/24": ("site", "vr1-off1"),
    "172.30.1.0/24": ("site", "vr1-off1"),
    "2602:f3e2:f01:100::/56": ("site", "vr1-off1"),
    "2602:f3e2:f01:100::/64": ("site", "vr1-off1"),
    **_dc_prefix_scopes(),
}

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": set(EXPECTED_PREFIX_SCOPE),
    # D-120 (must match netbox/d120-compose-bands.py). Ranges keyed start-end (the
    # dumper's synthetic "range"); IPs by address. Both carry the /24 mask. These are
    # pure delta -- the frozen v1 draft carries no ip-ranges/ip-addresses -- so
    # both-bounds already gives endpoint/address correctness (a wrong endpoint is a
    # different key: it shows as UNEXPECTED-EXTRA + EXPECTED-BUT-ABSENT).
    "ipam/ip-ranges": {"10.10.1.2/24-10.10.1.49/24",
                       "10.10.1.100/24-10.10.1.200/24",
                       "10.10.1.201/24-10.10.1.254/24"},
    "ipam/ip-addresses": {"10.10.1.10/24", "10.10.1.11/24"},
}
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)}")

    # 2026-07-14 HARDENING -- THE FALSE-GREEN THIS SCRIPT USED TO RETURN.
    # `unexpected = extra - exp` only proves the delta is a SUBSET of what was
    # planned. It NEVER asserted the plan was actually CARRIED OUT. If the importer
    # created ZERO of the 36 DC prefixes, extra={} -> unexpected={} -> this script
    # printed "Nothing lost, nothing stray" and exited 0. EXPECTED_NEW was an upper
    # bound masquerading as an assertion. It must be BOTH bounds.
    missing_expected = exp - extra
    if missing_expected:
        bad+=1
        print(f"  !! EXPECTED-BUT-ABSENT ({len(missing_expected)}): {sorted(missing_expected)[:8]}")
        print( "     The planned delta was NOT fully applied. A subset-only check would")
        print( "     have called this run clean -- that is the false green this catches.")

    # DELTA SCOPE-CORRECTNESS. The shared-object drift loop below cannot see these
    # (they exist only in the sandbox). Assert each delta prefix's scope == its
    # INTENDED scope (EXPECTED_PREFIX_SCOPE), target and all -- reading the dumper's
    # slug-rewritten scope_site/scope_region (scope_id is popped on the way in). This
    # catches a DROPPED scope (the 17-prefix region loss), a WRONG-TARGET binding (a
    # DC0 prefix bound to vr1-dc1 -- the D-117 near-miss), and a spurious scope on an
    # intentionally-unscoped container -- WITHOUT false-flagging the role /16s that
    # are unscoped by design (which the old "has a scope?" heuristic wrongly failed).
    if ep == "ipam/prefixes":
        for key in sorted(extra & exp):
            rec = S[key]
            if rec.get("scope_site"):
                actual = ("site", rec["scope_site"])
            elif rec.get("scope_region"):
                actual = ("region", rec["scope_region"])
            else:
                actual = None
            if key not in EXPECTED_PREFIX_SCOPE:
                bad+=1; print(f"  !! DELTA prefix {key}: no intended scope encoded (extend EXPECTED_PREFIX_SCOPE)")
            elif actual != EXPECTED_PREFIX_SCOPE[key]:
                bad+=1; print(f"  !! DELTA prefix {key}: scope {actual} != intended {EXPECTED_PREFIX_SCOPE[key]}")
            if not rec.get("role"):
                bad+=1; print(f"  !! DELTA prefix {key} has NO ROLE")
    # 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/D-120). Nothing lost, nothing stray.")
    sys.exit(0)
print(f"!! {bad} fidelity problem(s) -- the seeder is dropping or mangling data")
sys.exit(1)

