#!/usr/bin/env bash
# tests/d139-step6-vip-rehome/run-tests.sh
#
# Offline harness for netbox/d139-step6-vip-rehome.py (D-139 execution step 6).
# Exercises plan() against fixtures shaped like the live NetBox API. No network.
#
# The tool's write path is CREATE -> verify -> DEPRECATE and never deletes; plan() is
# what decides all three sets, so it is where the risk lives.
#
# PROOF-OF-TEETH is a first-class case here (T13): this repo has twice shipped
# assertions that could not fail. Each REFUSE case is also proven to be reached for the
# stated reason, not incidentally.
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TOOL="$REPO/netbox/d139-step6-vip-rehome.py"
PASS=0; FAIL=0
ok()  { echo "  PASS  $1"; PASS=$((PASS+1)); }
bad() { echo "  FAIL  $1"; FAIL=$((FAIL+1)); }

echo "== d139-step6-vip-rehome: plan() against API-shaped fixtures =="
[ -r "$TOOL" ] || { echo "  FAIL  tool missing at $TOOL"; echo "RESULT: PASS=0 FAIL=1"; exit 1; }

run_case() {  # run_case <label> <python-snippet-file>
  python3 "$1" 2>&1
}

TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

cat > "$TMP/harness.py" <<'PY'
import importlib.util, ipaddress, os, sys, io, contextlib
TOOL = os.environ["TOOL"]
spec = importlib.util.spec_from_file_location("step6", TOOL)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)

def pfx(cidr, role, dc="vr1-dc0", status="active", pid=None):
    return {"id": pid if pid is not None else abs(hash(cidr)) % 100000,
            "prefix": cidr, "status": {"value": status},
            "role": ({"slug": role} if role else None),
            "scope": {"slug": dc}}

def adr(addr, desc, status="reserved", aid=None):
    return {"id": aid if aid is not None else abs(hash(addr)) % 100000,
            "address": addr, "status": {"value": status}, "description": desc}

# dc0's five retiring ULA /64s + four /60 parents = the 9 the live apex reports.
def ula_prefixes():
    return [pfx("fd50:840e:74e2:220::/60", "metal-admin"),
            pfx("fd50:840e:74e2:220::/64", "metal-admin"),
            pfx("fd50:840e:74e2:221::/64", "metal-internal"),
            pfx("fd50:840e:74e2:230::/60", "data-tenant"),
            pfx("fd50:840e:74e2:230::/64", "data-tenant"),
            pfx("fd50:840e:74e2:240::/60", "storage"),
            pfx("fd50:840e:74e2:240::/64", "storage"),
            pfx("fd50:840e:74e2:250::/60", "replication"),
            pfx("fd50:840e:74e2:250::/64", "replication")]

# MEASURED against the live apex 2026-08-02, and the harness caught this the hard way:
# the VIP host parts MIRROR THE v4 DECIMAL BAND (D-134 .50-.99 VIP band), so they read
# ::50..::59 then ::60..::62 -- THIRTEEN per plane. They are NOT a contiguous hex run:
# 0x5a-0x5f are skipped. A fixture generating range(0x50, 0x63) yields 19 per plane and
# silently inflates every count, which is how the first run of this harness reported
# CREATE=38 against a real CREATE=26. A mock must be truthful to the real data or the
# assertions grade the mock.
VIP_OCTETS = list(range(0x50, 0x5a)) + list(range(0x60, 0x63))   # 13


# The GUA /64s that ACTUALLY EXIST in the live apex for dc0 (step 1 created 16 rows).
# The DEF-1 guard refuses any target not in this set, so omitting them from the fixture
# made every plan() case refuse -- correct behaviour, wrong fixture. Only the /64s the
# ULA roles map to are needed here.
def gua_prefixes():
    return [pfx("2602:f3e2:f02:20::/64", "metal-admin"),
            pfx("2602:f3e2:f02:21::/64", "metal-internal"),
            pfx("2602:f3e2:f02:30::/64", "data-tenant"),
            pfx("2602:f3e2:f02:40::/64", "storage"),
            pfx("2602:f3e2:f02:50::/64", "replication")]


def all_prefixes():
    return ula_prefixes() + gua_prefixes()


def vip_addrs():
    out = []
    for hx, plane in ((0x220, "metal-admin"), (0x221, "metal-internal")):
        for i in VIP_OCTETS:
            out.append(adr("fd50:840e:74e2:%x::%x/64" % (hx, i),
                           "VIP svc%x %s v6 (vr1-dc0) -- octet mirror ::%x" % (i, plane, i)))
    return out

def refuses(fn):
    """Returns (did_refuse, stderr_text). die() calls sys.exit(2)."""
    err = io.StringIO()
    try:
        with contextlib.redirect_stderr(err):
            fn()
    except SystemExit as e:
        return (e.code == 2), err.getvalue()
    return False, err.getvalue()
PY

emit() { cat > "$TMP/t.py"; TOOL="$TOOL" PYTHONPATH="$TMP" python3 "$TMP/t.py" 2>&1; }

# ---- T1-T4 happy path ------------------------------------------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
c, da, dp, al = m.plan("vr1-dc0", all_prefixes(), vip_addrs())
print("CREATE=%d DEPA=%d DEPP=%d ALREADY=%d" % (len(c), len(da), len(dp), len(al)))
tgt = sorted(x["address"] for x in c)
print("FIRST=%s LAST=%s" % (tgt[0], tgt[-1]))
print("STATUS_SET=%s" % sorted({x["status"] for x in c}))
print("DESC0=%s" % c[0]["description"])
PY
)"
grep -q "CREATE=26 DEPA=26 DEPP=9 ALREADY=0" <<<"$out" \
  && ok "T1 26 creates, 26 addr-deprecations, 9 prefix-deprecations" \
  || bad "T1 wrong plan counts: $out"
grep -q "FIRST=2602:f3e2:f02:20::50/64" <<<"$out" \
  && ok "T2 metal-admin ULA :220 maps to GUA :20, host octet preserved" \
  || bad "T2 wrong low target: $out"
grep -q "LAST=2602:f3e2:f02:21::62/64" <<<"$out" \
  && ok "T3 metal-internal ULA :221 maps to GUA :21, host octet preserved" \
  || bad "T3 wrong high target: $out"
grep -q "STATUS_SET=\['reserved'\]" <<<"$out" \
  && ok "T4 creates are status=reserved (MEASURED from the ULA originals, not 'active')" \
  || bad "T4 wrong create status: $out"

# ---- T5 description carried verbatim ---------------------------------------------
grep -q "DESC0=VIP svc50 metal-admin v6" <<<"$out" \
  && ok "T5 description carried VERBATIM onto the GUA record" \
  || bad "T5 description not preserved: $out"

# ---- T6 idempotence --------------------------------------------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
a = vip_addrs() + [adr("2602:f3e2:f02:20::50/64", "already there")]
c, da, dp, al = m.plan("vr1-dc0", all_prefixes(), a)
print("CREATE=%d ALREADY=%d" % (len(c), len(al)))
PY
)"
grep -q "CREATE=25 ALREADY=1" <<<"$out" \
  && ok "T6 an existing GUA address is ALREADY, not a duplicate create" \
  || bad "T6 not idempotent: $out"

# ---- T7 already-deprecated rows drop out of the deprecate sets --------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
p = all_prefixes(); p[0]["status"] = {"value": "deprecated"}
a = vip_addrs(); a[0]["status"] = {"value": "deprecated"}
c, da, dp, al = m.plan("vr1-dc0", p, a)
print("DEPA=%d DEPP=%d" % (len(da), len(dp)))
PY
)"
grep -q "DEPA=25 DEPP=8" <<<"$out" \
  && ok "T7 rows already deprecated are not re-patched (idempotent deprecate)" \
  || bad "T7 re-patches deprecated rows: $out"

# ---- T8 REFUSE: no ULA prefixes in scope is NOT silent success --------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
did, err = refuses(lambda: m.plan("vr1-dc0", gua_prefixes(), vip_addrs()))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "no ULA prefixes in scope" <<<"$out" \
  && ok "T8 REFUSE when no ULA prefixes in scope (empty != done)" \
  || bad "T8 did not refuse for the stated reason: $out"

# ---- T9 REFUSE: a role with no GUA plane -----------------------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
p = [pfx("fd50:840e:74e2:220::/64", "no-such-role")]
did, err = refuses(lambda: m.plan("vr1-dc0", p + gua_prefixes(), vip_addrs()))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "no D-139 GUA plane carries apex role" <<<"$out" \
  && ok "T9 REFUSE on a role with no GUA counterpart" \
  || bad "T9 did not refuse for the stated reason: $out"

# ---- T10 REFUSE: ambiguous role (provider-public owns :10 AND :11) ----------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
did, err = refuses(lambda: m.gua_net_for_role("vr1-dc0", "provider-public"))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "ambiguous, refusing to choose" <<<"$out" \
  && ok "T10 REFUSE on an ambiguous role rather than picking a /64" \
  || bad "T10 did not refuse on ambiguity: $out"

# ---- T11 REFUSE: ULA prefix carrying no role -------------------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
p = [pfx("fd50:840e:74e2:220::/64", None)]
did, err = refuses(lambda: m.plan("vr1-dc0", p + gua_prefixes(), vip_addrs()))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "has no apex role" <<<"$out" \
  && ok "T11 REFUSE on a roleless ULA prefix (cannot map it)" \
  || bad "T11 did not refuse: $out"

# ---- T12 other DCs' rows are ignored ----------------------------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
p = all_prefixes() + [pfx("fd50:840e:74e2:320::/64", "metal-admin", dc="vr1-dc1")]
c, da, dp, al = m.plan("vr1-dc0", p, vip_addrs())
print("DEPP=%d" % len(dp))
PY
)"
grep -q "DEPP=9" <<<"$out" \
  && ok "T12 another DC's ULA prefix is out of scope and untouched" \
  || bad "T12 leaked across DC scope: $out"

# ---- T13 PROOF OF TEETH ------------------------------------------------------------
# Confirm the ULA->GUA mapping assertions (T2/T3) CAN fail. Uses live_gua=None to bypass
# the DEF-1 existence guard DELIBERATELY: with the guard active a wrong /48 REFUSES
# before producing a target, so the test would assert on a refusal rather than on the
# mapping it is meant to constrain.
# HARDENED after the 2026-08-02 adversarial review: the original asserted only that the
# right string was ABSENT, so a TRACEBACK satisfied it -- it passed against a tool file
# that did not even parse. It now requires a POSITIVE, well-formed, DIFFERENT target.
out="$(emit <<'PYX'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
good = m.gua_net_for_role("vr1-dc0", "metal-admin")
orig = m.C.DC_GUA["vr1-dc0"]
m.C.DC_GUA["vr1-dc0"] = "2602:f3e2:f0f::/48"
mutant = m.gua_net_for_role("vr1-dc0", "metal-admin")
m.C.DC_GUA["vr1-dc0"] = orig
print("GOOD=%s" % good)
print("MUTANT=%s" % mutant)
print("DIFFER=%s" % (str(good) != str(mutant)))
PYX
)"
if grep -q "GOOD=2602:f3e2:f02:20::/64" <<<"$out" \
   && grep -q "MUTANT=2602:f3e2:f0f:20::/64" <<<"$out" \
   && grep -q "DIFFER=True" <<<"$out"; then
  ok "T13 PROOF-OF-TEETH: a wrong /48 yields a well-formed but DIFFERENT /64 (T2/T3 can fail)"
else
  bad "T13 PROOF-OF-TEETH did not produce the expected mutant mapping: $out"
fi

# ---- T14 NO DELETE PATH, across the tool AND the module it imports -------------------
# The ruling is "Deprecate both, delete nothing". Assert on the ARTIFACT.
# HARDENED after review: the original grepped ONE file, so adding a delete to the
# IMPORTED d139-gua-carve.py left it green. The import inherits that module's whole
# surface, so the assertion must cover it too.
CARVE_TOOL="$REPO/netbox/d139-gua-carve.py"
if grep -nE '"DELETE"|\.delete\(|method="DELETE"' "$TOOL" "$CARVE_TOOL" >/dev/null 2>&1; then
  bad "T14 a DELETE path exists in the tool or its imported module -- the ruling forbids it"
else
  ok "T14 NO delete path in the tool OR in the module it imports"
fi

# ---- T15 the tool actually PARSES (T13's escape hatch, closed) ----------------------
if python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$TOOL" 2>/dev/null; then
  ok "T15 tool parses -- so a passing suite cannot mean 'the file is broken'"
else
  bad "T15 tool does not parse"
fi

# ---- T16-T18 main(): PHASE ORDER, which had ZERO coverage before the review ---------
# The reviewer moved the deprecate loops ABOVE the create phase and the suite stayed
# green. Both rulings call this order load-bearing, so it is now asserted directly by
# recording the ORDER of calls a fake client receives.
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
import ipaddress

CALLS = []
class FakeNB:
    def __init__(self, *a, **k): pass
    def get_all(self, path):
        if "prefixes" in path:
            p = list(all_prefixes())
            if CALLS.count("PATCH_P"):          # model the deprecation landing
                for x in p:
                    if x["prefix"].startswith("fd50:"):
                        x["status"] = {"value": "deprecated"}
            return p
        a = list(vip_addrs())
        if CALLS.count("PATCH_A"):              # model the deprecation landing
            for x in a:
                x["status"] = {"value": "deprecated"}
        if CALLS.count("POST"):            # after creates, read-back must find them
            for hx, g in ((0x220, 0x20), (0x221, 0x21)):
                for i in VIP_OCTETS:
                    a.append(adr("2602:f3e2:f02:%x::%x/64" % (g, i), "created"))
        return a
    def post(self, path, body): CALLS.append("POST"); return {}
    def patch(self, path, body):
        CALLS.append("PATCH_A" if "ip-addresses" in path else "PATCH_P"); return {}

m.NBW = FakeNB
sys.argv = ["x", "--dc", "vr1-dc0", "--commit"]
import os as _os
_os.environ["NETBOX_URL"] = "http://10.10.1.10:8000"
_os.environ["NETBOX_TOKEN"] = "fake"
rc = m.main()
print("RC=%s" % rc)
print("NPOST=%d NPATCH=%d" % (CALLS.count("POST"), CALLS.count("PATCH_A")+CALLS.count("PATCH_P")))
print("FIRST_PATCH_AFTER_LAST_POST=%s" %
      (CALLS.index("PATCH_A") > max(i for i,c in enumerate(CALLS) if c == "POST")))
PY
)"
grep -q "RC=0" <<<"$out" \
  && ok "T16 main() --commit completes rc=0 against a fake client" \
  || bad "T16 main() did not complete: $out"
grep -q "NPOST=26 NPATCH=35" <<<"$out" \
  && ok "T17 main() issues 26 POSTs and 35 PATCHes (26 addr + 9 prefix)" \
  || bad "T17 wrong call counts: $out"
grep -q "FIRST_PATCH_AFTER_LAST_POST=True" <<<"$out" \
  && ok "T18 ORDER: every CREATE precedes the first DEPRECATE (both rulings' load-bearing property)" \
  || bad "T18 ORDER VIOLATED -- a deprecate ran before the creates finished: $out"

# ---- T19 DEF-1: refuse when the target GUA prefix does not exist --------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
did, err = refuses(lambda: m.gua_net_for_role("vr1-dc0", "metal-admin", live_gua=set()))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "DOES NOT EXIST in the apex" <<<"$out" \
  && ok "T19 DEF-1 REFUSE when the target GUA /64 is absent (the dc1 orphan-create)" \
  || bad "T19 did not refuse on a missing target prefix: $out"

# ---- T20 DEF-3: refuse on an address no in-scope /64 covers ------------------------
out="$(emit <<'PY'
import sys; sys.path.insert(0, __import__("os").environ.get("PYTHONPATH","."))
from harness import *
p = [x for x in all_prefixes() if x["prefix"] != "fd50:840e:74e2:221::/64"]
did, err = refuses(lambda: m.plan("vr1-dc0", p, vip_addrs()))
print("REFUSED=%s" % did); print("ERR=%s" % err.strip())
PY
)"
grep -q "REFUSED=True" <<<"$out" && grep -qi "claimed by NO prefix row" <<<"$out" \
  && ok "T20 DEF-3 REFUSE on the silent under-count (13 VIPs skipped at rc=0)" \
  || bad "T20 under-counted silently instead of refusing: $out"

echo
echo "RESULT: PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ] && echo "ALL PASS" || echo "FAILURES PRESENT"
[ "$FAIL" -eq 0 ]
