#!/usr/bin/env bash
# tests/render-dc-overlays/run-tests.sh
#
# Offline regression harness for scripts/render-dc-overlays.py (D-136 option (D)).
# No live infra, no network. Mutates NOTHING outside $TMP.
#
# THE CENTRAL ASSERTION is byte-for-byte reproduction of REVIEWED artifacts, on BOTH
# shapes: the FROZEN v4 fixture (immutable, so it stays a stable target) and the LIVE
# dual-family overlay (which is what actually deploys, and whose own header mandates
# that it remain renderer-reproducible). A renderer validated only against its own output
# is validated against nothing -- that is the objection D-136 raises against its
# own option (A). So the reproduction case is paired with THREE seeded faults
# proving the comparison can fail: a wrong octet, a dropped header, and wrong-DC
# prefixes. A byte-compare only ever observed passing is as untrustworthy as a
# gate only ever observed failing.
#
# Exit: 0 all pass | 1 any case failed.  ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
R="$REPO/scripts/render-dc-overlays.py"
CHK="$REPO/scripts/provider-bundle-check.py"
LIVE="$REPO/overlays/vr1-dc1-vips.yaml"
FIX="$REPO/tests/render-baseline/fixtures/vr1-dc1-vips.v4-11app.yaml"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
ok()  { echo "  PASS  $1"; PASS=$((PASS+1)); }
bad() { echo "  FAIL  $1"; FAIL=$((FAIL+1)); }
t_eq(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2' want '$3')"; fi; }

echo "== render-dc-overlays: deterministic per-DC overlay renderer =="

# ---- derive ------------------------------------------------------------------
V4="$TMP/dc1-v4.yaml"      # v4 target = the FROZEN fixture (immutable)
# The dual target is the COMMITTED values file, not a fresh derive. `derive --from-overlay`
# lifts the header verbatim FROM the artifact being compared against, so deriving here and
# comparing against $LIVE made ~a third of the bytes self-referential and the header
# assertion unfalsifiable -- measured in the 2026-07-29 chain audit. Reading the committed
# values file makes T3b a real comparison and keeps it consistent with tests/render-drift.
V="$TMP/dc1-dual.yaml"
if python3 "$R" derive --dc vr1-dc1 --from-overlay "$FIX" \
     --output overlays/vr1-dc1-vips.yaml -o "$V4" 2>"$TMP/e1" \
   && cp "$REPO/render/values/vr1-dc1-vips.yaml" "$V"; then
  ok "T1 derive vr1-dc1 succeeds for both the v4 and dual-family shapes"
else
  bad "T1 derive failed: $(head -1 "$TMP/e1")"
fi

n_apps="$(python3 -c "import yaml,sys; print(len(yaml.safe_load(open(sys.argv[1]))['apps']))" "$V4" 2>/dev/null)"
t_eq "T2 derive defaults to the 11 BUILT apps (vault/designate excluded)" "$n_apps" "11"

# ---- the central assertion, on BOTH shapes -----------------------------------
if python3 "$R" check "$V4" --against "$FIX" >/dev/null 2>&1; then
  ok "T3 render reproduces the FROZEN v4 fixture BYTE-FOR-BYTE"
else
  bad "T3 render does NOT reproduce the frozen v4 fixture"
fi
if python3 "$R" check "$V" --against "$LIVE" >/dev/null 2>&1; then
  ok "T3b render reproduces the LIVE dual-family overlay BYTE-FOR-BYTE"
else
  bad "T3b render does NOT reproduce the live dual-family overlay"
fi

# T4 the same bytes as the FROZEN fixture, which is the durable target: the live
#    overlay is about to be rewritten by the R2/R11 reconciliation, the fixture is not.
got="$(python3 "$R" render "$V4" 2>/dev/null | sha256sum | cut -d' ' -f1)"
want="$(sha256sum "$FIX" | cut -d' ' -f1)"
t_eq "T4 rendered output matches the frozen render-baseline fixture hash" "$got" "$want"

# ---- proven able to FAIL (three seeded faults) --------------------------------
sed 's/octet: 50/octet: 51/' "$V4" > "$TMP/f1.yaml"
python3 "$R" check "$TMP/f1.yaml" --against "$FIX" >/dev/null 2>&1
t_eq "T5 a wrong octet FAILS the byte-compare" "$?" "1"

python3 - "$V4" "$TMP/f2.yaml" <<'PY'
import sys, yaml
v = yaml.safe_load(open(sys.argv[1])); v.pop("header", None)
yaml.safe_dump(v, open(sys.argv[2], "w"), sort_keys=False, width=4096)
PY
python3 "$R" check "$TMP/f2.yaml" --against "$FIX" >/dev/null 2>&1
t_eq "T6 a dropped comment header FAILS the byte-compare" "$?" "1"

python3 "$R" derive --dc vr1-dc0 --from-overlay "$FIX" -o "$TMP/f3.yaml" 2>/dev/null
python3 "$R" check "$TMP/f3.yaml" --against "$FIX" >/dev/null 2>&1
t_eq "T7 dc0 prefixes against the dc1 artifact FAIL (cross-DC)" "$?" "1"

# ---- determinism + purity ----------------------------------------------------
a="$(python3 "$R" render "$V4" 2>/dev/null | sha256sum)"
b="$(python3 "$R" render "$V4" 2>/dev/null | sha256sum)"
t_eq "T8 render is deterministic across runs" "$a" "$b"

# T9 render is PURE -- no apex, no network. A v4 values file must render with the
#    apex record deliberately unreachable, or the renderer is not offline-testable
#    and forward item F2's frozen interface is violated.
out="$(APEX_RECORD=/nonexistent/apex.json python3 "$R" render "$V4" 2>/dev/null | sha256sum | cut -d' ' -f1)"
t_eq "T9 render needs no apex (pure, offline)" "$out" "$want"

# T10 ...but DERIVING dual-family does, and must REFUSE rather than invent one.
APEX_RECORD=/nonexistent/apex.json python3 "$R" derive --dc vr1-dc1 --dual-family \
  -o "$TMP/nope.yaml" >/dev/null 2>&1
t_eq "T10 derive --dual-family REFUSES an unreadable apex (rc=2)" "$?" "2"

# ---- ordering ----------------------------------------------------------------
# T11 emission order is ASCENDING OCTET, not alphabetical. Sorting by name
#     reproduces nothing: keystone(.50) must precede barbican(.51).
first="$(python3 "$R" render "$V4" 2>/dev/null | grep -E '^  [a-z]' | head -1 | tr -d ' :')"
t_eq "T11 apps emit in ascending-octet order, not alphabetical" "$first" "keystone"

# ---- dual-family (R2) --------------------------------------------------------
DV="$V"
n2="$(python3 -c "import yaml,sys; print(len(yaml.safe_load(open(sys.argv[1]))['apps']))" "$DV" 2>/dev/null)"
t_eq "T12 --include-unbuilt adds R11's vault .61 + designate .62" "$n2" "13"

# T13 the v6 separator regression. The values file stores the /64 base with its
#     trailing colons stripped, so a single-colon join yields 2602:f3e2:f03:11:50
#     -- malformed, and it still LOOKS like an address. This was the renderer's
#     first dual-family output; provider-bundle-check caught it. Locked here.
if python3 "$R" render "$DV" 2>/dev/null | grep -q '2602:f3e2:f03:11::50'; then
  ok "T13 v6 legs use the '::' separator (the malformed-but-plausible regression)"
else
  bad "T13 v6 leg separator wrong -- check for a single-colon join"
fi
if python3 "$R" render "$DV" 2>/dev/null | grep -qE '2602:f3e2:f03:11:[0-9]+ '; then
  bad "T13b a single-colon v6 leg is present"
else
  ok "T13b no single-colon v6 leg emitted"
fi

# T14 the end-to-end acceptance criterion: the renderer's dual-family output is
#     VALID DEPLOY INPUT per the gate. This is what the 2026-07-28 arity work
#     exists to provide -- without it the dry-run below means nothing.
mkdir -p "$TMP/policies"
cp "$REPO/bundle.yaml" "$TMP/b.yaml"
cp "$REPO/policies/domain-manager-policy.yaml" "$REPO/policies/overrides.zip" "$TMP/policies/"
python3 "$R" render "$DV" -o "$TMP/vips.yaml" 2>/dev/null
# The MACHINES overlay is part of the dc1 deploy input, not optional scenery. Added
# 2026-07-29 with provider-bundle-check invariant 11: base + dc1 VIPs alone leaves the
# machines block tagged openstack-vr1-dc0, i.e. a dc1 model allocating dc0 hardware,
# and the gate now (correctly) rejects it. Asserting the renderer's output against a
# HALF deploy input was the reason this case went red.
if python3 "$CHK" "$TMP/b.yaml" --overlay "$REPO/overlays/vr1-dc1-machines.yaml" \
     --overlay "$TMP/vips.yaml" --dc vr1-dc1 >"$TMP/gate" 2>&1; then
  ok "T14 rendered dual-family overlay PASSES provider-bundle-check"
else
  bad "T14 rendered overlay rejected by the gate: $(grep -m1 FAIL "$TMP/gate")"
fi
# COUNT 13->12 (2026-08-05, D-020 amendment): vault renders METAL-ONLY, so it is NOT
# in the provider/admin/internal dual-family count -- 12 clustered dual-family VIPs +
# vault's own metal-only [ok] line. The 13 VIP-carrying apps are unchanged (T12).
if grep -q "12 clustered VIP(s).*12 dual-family" "$TMP/gate" \
   && grep -q "vault vip is metal-only" "$TMP/gate"; then
  ok "T15 the gate sees 12 dual-family VIPs + vault metal-only"
else
  bad "T15 gate did not report 12 dual-family VIPs + vault metal-only"
fi

# T15b PROOF-OF-TEETH for the D-020 vault metal-only render exception (2026-08-05). It is
# keyed on the app NAME: rename vault in a COPY of the values and the octet-61 app renders
# the FULL dual-family sextet again -- proving the 2-leg metal pair is produced BY the
# exception, not by accident -- while the real vault stays 2 legs with NO provider (10.12.64.) leg.
python3 - "$DV" "$TMP/vault-renamed.yaml" <<'PY'
import sys, yaml
v = yaml.safe_load(open(sys.argv[1]))
for a in v["apps"]:
    if a["name"] == "vault": a["name"] = "zzz-notvault"
yaml.safe_dump(v, open(sys.argv[2], "w"), sort_keys=False, width=4096)
PY
vault_legs="$(python3 "$R" render "$DV" 2>/dev/null | grep -A2 '^  vault:' | grep 'vip:' | sed 's/.*"\([^"]*\)".*/\1/' | wc -w)"
vault_prov="$(python3 "$R" render "$DV" 2>/dev/null | grep -A2 '^  vault:' | grep 'vip:' | grep -c '10.12.64.')"
renamed_legs="$(python3 "$R" render "$TMP/vault-renamed.yaml" 2>/dev/null | grep -A2 'zzz-notvault:' | grep 'vip:' | sed 's/.*"\([^"]*\)".*/\1/' | wc -w)"
if [ "$vault_legs" = "2" ] && [ "$vault_prov" = "0" ] && [ "$renamed_legs" = "6" ]; then
  ok "T15b vault renders the 2-leg metal pair (no provider); renaming it restores the 6-leg sextet"
else
  bad "T15b vault metal-only render (vault_legs=$vault_legs vault_prov=$vault_prov renamed_legs=$renamed_legs; want 2/0/6)"
fi

# ---- refusals ----------------------------------------------------------------
printf 'dc: vr1-dc1\nlegs: [a]\n' > "$TMP/bad.yaml"
python3 "$R" render "$TMP/bad.yaml" >/dev/null 2>&1
t_eq "T16 a values file missing required keys REFUSES (rc=2)" "$?" "2"

# ---- v6 FAMILY selection (added 2026-08-01) -----------------------------------
# D-139 creates the GUA carve ALONGSIDE the ULA rows at step 1 and retires the ULA
# rows only at step 6, so for the WHOLE transition every v6-only plane has TWO /64s
# under one (role, kind) key. Measured LIVE on 2026-08-01 minutes after the step-1
# push: `derive --dual-family` refused outright, making the D-136 pipeline unrunnable
# for exactly the migration it exists to serve. These cases pin the fix AND the
# refusals it deliberately keeps.
mkapex() {   # $1 = out path; emits a minimal apex record with the families asked for
  python3 - "$1" "$2" <<'PYEOF'
import json, sys
out, mode = sys.argv[1], sys.argv[2]
rows = [{"prefix": "2602:f3e2:f03:11::/64", "scope_site": "vr1-dc1",
         "role": "provider-public", "description": "VR1 DC1 provider-public (GUA VIP /64)"}]
if mode in ("ula", "both"):
    rows += [{"prefix": "fd50:840e:74e2:320::/64", "scope_site": "vr1-dc1",
              "role": "metal-admin", "description": "VR1 DC1 metal-admin (ULA /64 active)"},
             {"prefix": "fd50:840e:74e2:321::/64", "scope_site": "vr1-dc1",
              "role": "metal-internal", "description": "VR1 DC1 metal-internal (ULA /64 active)"}]
if mode in ("gua", "both"):
    rows += [{"prefix": "2602:f3e2:f03:20::/64", "scope_site": "vr1-dc1",
              "role": "metal-admin", "description": "VR1 DC1 metal-admin (GUA /64; D-139)"},
             {"prefix": "2602:f3e2:f03:21::/64", "scope_site": "vr1-dc1",
              "role": "metal-internal", "description": "VR1 DC1 metal-internal (GUA /64; D-139)"}]
if mode == "dupsame":
    rows += [{"prefix": "2602:f3e2:f03:20::/64", "scope_site": "vr1-dc1",
              "role": "metal-admin", "description": "VR1 DC1 metal-admin (GUA /64; D-139)"},
             {"prefix": "2602:f3e2:f03:2f::/64", "scope_site": "vr1-dc1",
              "role": "metal-admin", "description": "VR1 DC1 metal-admin (GUA /64 spare)"}]
json.dump({"ipam/prefixes": rows}, open(out, "w"))
PYEOF
}

# T17 BOTH families present + no --v6-family -> REFUSE, and the message must NAME the
# flag that resolves it. A refusal a reader cannot act on is only half a gate.
mkapex "$TMP/apex-both.json" both
out="$(APEX_RECORD="$TMP/apex-both.json" python3 "$R" derive --dc vr1-dc1 --dual-family 2>&1 >/dev/null)"; rc=$?
t_eq "T17 both families + no flag REFUSES (rc=2)" "$rc" "2"
if printf '%s' "$out" | grep -q -- "--v6-family"; then
  ok "T17b the refusal names --v6-family as the resolution"
else
  bad "T17b refusal did not name the flag: $out"
fi

# T18 --v6-family gua picks the GUA /64s out of an apex holding both, and RECORDS
# which family it took -- provenance, because a values file derived mid-transition is
# otherwise indistinguishable from one derived before or after it.
out="$(APEX_RECORD="$TMP/apex-both.json" python3 "$R" derive --dc vr1-dc1 --dual-family --v6-family gua 2>/dev/null)"
if printf '%s' "$out" | grep -q "metal-admin: 2602:f3e2:f03:20" \
   && printf '%s' "$out" | grep -q "v6_family: gua"; then
  ok "T18 --v6-family gua selects GUA and records the family"
else
  bad "T18 gua selection or provenance missing"
fi

# T19 the ORIGINAL 2026-07-29 guard is UNTOUCHED: two prefixes in the SAME family under
# one key still refuse. The family work must not become a licence to pick by array order.
mkapex "$TMP/apex-dup.json" dupsame
APEX_RECORD="$TMP/apex-dup.json" python3 "$R" derive --dc vr1-dc1 --dual-family --v6-family gua >/dev/null 2>&1
t_eq "T19 two SAME-family /64s under one key still REFUSE (rc=2)" "$?" "2"

# T20 asking for a family the apex cannot satisfy REFUSES rather than inventing one:
# provider-public's VIP /64 is GUA-only, so a pure-ULA ask has nothing to give.
mkapex "$TMP/apex-ula.json" ula
APEX_RECORD="$TMP/apex-ula.json" python3 "$R" derive --dc vr1-dc1 --dual-family --v6-family ula >/dev/null 2>&1
t_eq "T20 a family the apex cannot satisfy REFUSES, never invents" "$?" "2"


echo
echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1
