#!/usr/bin/env bash
# tests/d139-gua-carve/run-tests.sh
#
# Harness for netbox/d139-gua-carve.py. OFFLINE -- every apex call goes to
# stub-apex.py on 127.0.0.1, never to 10.10.1.10 and never to baldurkeep.
# The values are the point: every CIDR below is D-139 ruling B verbatim, and a
# "tidy-up" that shifts one silently renumbers a plane.
# ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
S="$(cd "$HERE/../../netbox" && pwd)/d139-gua-carve.py"
TMP="$(mktemp -d)"; PORT=""; STUB_PID=""
pass=0; fail=0
ok()  { pass=$((pass+1)); }
bad() { fail=$((fail+1)); echo "  FAIL: $1"; }
cleanup() { [ -n "$STUB_PID" ] && kill "$STUB_PID" 2>/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT

command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; }

start_stub() {   # $1 = STUB_MODE; sets PORT, truncates the write log
  [ -n "$STUB_PID" ] && { kill "$STUB_PID" 2>/dev/null; wait "$STUB_PID" 2>/dev/null; }
  : > "$TMP/writes.log"
  STUB_MODE="$1" STUB_WRITELOG="$TMP/writes.log" python3 "$HERE/stub-apex.py" >"$TMP/port" 2>"$TMP/stub.err" &
  STUB_PID=$!
  for _ in $(seq 1 50); do PORT="$(cat "$TMP/port" 2>/dev/null)"; [ -n "$PORT" ] && break; sleep 0.1; done
  [ -n "$PORT" ] || { echo "FAIL: stub apex did not start"; exit 1; }
}
run() {          # $@ -> tool args; captures stdout+stderr in $OUT, rc in $RC
  OUT="$(NETBOX_URL="http://127.0.0.1:$PORT" NETBOX_TOKEN=stub python3 "$S" "$@" 2>&1)"; RC=$?
}
has() { printf '%s' "$OUT" | grep -qF "$1"; }

python3 -c "import ast;ast.parse(open('$S').read())" 2>/dev/null && ok || bad "T01 does not parse"
python3 "$S" --help >/dev/null 2>&1 && ok || bad "T02 --help -> 0"

# T03 no credentials -> REFUSE(2). "Could not look" must never read as "nothing there".
NETBOX_URL= NETBOX_TOKEN= python3 "$S" --dc vr1-dc0 >/dev/null 2>&1
[ $? -eq 2 ] && ok || bad "T03 missing NETBOX_URL/TOKEN must REFUSE with rc=2"

# T04 the two-instance hazard: the v1 upstream is READ-ONLY for VR1 (DOCFIX-195).
o="$(NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=x python3 "$S" --dc vr1-dc0 --commit 2>&1)"
printf '%s' "$o" | grep -q "DOCFIX-195" && ok || bad "T04 did not refuse the baldurkeep upstream"

start_stub normal

# T05/T06 dry run is the DEFAULT and it writes NOTHING. T06 is the assertion that
# proves it: the stub logs every non-GET, and that log must still be empty.
run --dc vr1-dc0
[ "$RC" -eq 0 ] && has "DRY RUN -- nothing was written" && ok || bad "T05 dry run rc=0 + announcement"
[ ! -s "$TMP/writes.log" ] && ok || bad "T06 dry run issued a WRITE to the apex: $(cat "$TMP/writes.log")"

# T07 counts, measured against the fixture (13 new + 3 pre-existing + 9 retiring per DC).
# REPLACED 2026-08-01, not deleted, per the standing rule that remediating a finding must
# REPLACE the assertion with the new invariant: was "CREATE 11" until the operator ruled OOB
# into D-139 ("Dual-stack; rule 10.12.60.0/22 back into force for OOB"), which adds
# :f0::/60 + :f0::/64 = 2 rows. EXISTS and RETIRE are untouched -- OOB has no ULA predecessor.
has "CREATE 13 | EXISTS 3 | RETIRE-REPORT 9" && ok || bad "T07 dc0 plan counts changed"

# T08 the D-139 ruling B dc0 carve, verbatim, with the role each prefix binds to.
while read -r cidr role; do
  printf '%s' "$OUT" | grep -q "^    $cidr  *role=$role" && ok || bad "T08 dc0 CREATE missing $cidr role=$role"
done <<'EOF'
2602:f3e2:f02:20::/60 metal-admin
2602:f3e2:f02:20::/64 metal-admin
2602:f3e2:f02:21::/64 metal-internal
2602:f3e2:f02:30::/60 data-tenant
2602:f3e2:f02:30::/64 data-tenant
2602:f3e2:f02:40::/60 storage
2602:f3e2:f02:40::/64 storage
2602:f3e2:f02:50::/60 replication
2602:f3e2:f02:50::/64 replication
2602:f3e2:f02:80::/60 lbaas-mgmt
2602:f3e2:f02:80::/64 lbaas-mgmt
2602:f3e2:f02:f0::/60 oob
2602:f3e2:f02:f0::/64 oob
EOF

# T09 IDEMPOTENCE. provider-public :10/:11 are already built; they must be reported
# EXISTS and must not appear in the CREATE block.
c="$(printf '%s' "$OUT" | sed -n '/^  CREATE:/,/^$/p')"
[ -n "$c" ] && ok || bad "T09 CREATE-section extraction EMPTY -- every absence check below would pass vacuously"
for cidr in "2602:f3e2:f02:10::/60" "2602:f3e2:f02:10::/64" "2602:f3e2:f02:11::/64"; do
  printf '%s' "$c" | grep -qF "$cidr" && bad "T09 would re-create existing $cidr" || ok
  printf '%s' "$OUT" | sed -n '/^  EXISTS/,/^$/p' | grep -qF "$cidr" && ok || bad "T09 $cidr not reported EXISTS"
done

# T10 RETIRE is a REPORT. It names the nine dc0 ULA rows, says it deletes nothing,
# and counts what would be orphaned (13 statics in each of :220 and :221).
has "RETIRE -- REPORTED ONLY, this tool never deletes" && ok || bad "T10 retire section is not marked report-only"
r="$(printf '%s' "$OUT" | sed -n '/^  RETIRE/,$p')"
[ -n "$r" ] && ok || bad "T10/T11 RETIRE-section extraction EMPTY -- the T11 scope checks would pass vacuously"
for cidr in "fd50:840e:74e2:220::/60" "fd50:840e:74e2:220::/64" "fd50:840e:74e2:221::/64" \
            "fd50:840e:74e2:230::/60" "fd50:840e:74e2:230::/64" "fd50:840e:74e2:240::/60" \
            "fd50:840e:74e2:240::/64" "fd50:840e:74e2:250::/60" "fd50:840e:74e2:250::/64"; do
  printf '%s' "$r" | grep -qF "$cidr" && ok || bad "T10 retire list is missing $cidr"
done
printf '%s' "$r" | grep -qF "26 ip-address(es), 0 ip-range(s)" && ok \
  || bad "T10 dependent-object count wrong -- the orphaned statics are the retirement's real cost"

# T11 SCOPE of the retirement. D-139 sequences the v4 removals LAST and separately;
# the org ULA aggregate and the global fd00::/8 are not VR1 planes.
printf '%s' "$r" | grep -q "10\.12\." && bad "T11 a v4 plane was swept into the retirement" || ok
printf '%s' "$r" | grep -qF "fd50:840e:74e2::/48" && bad "T11 swept the org ULA AGGREGATE" || ok
printf '%s' "$r" | grep -qF "fd00::/8" && bad "T11 swept the global fd00::/8" || ok
printf '%s' "$r" | grep -qF "fd50:840e:74e2:320::" && bad "T11 dc0 run listed a dc1 ULA row" || ok

# T12 dc1 -- the other half of ruling B, same shape, different /48.
run --dc vr1-dc1
# REPLACED 2026-08-01 with the OOB ruling (was "CREATE 11"): dc1 gains :f0::/60 + :f0::/64
# from its OWN /48, so the carve stays symmetric across the two DCs by construction.
[ "$RC" -eq 0 ] && has "CREATE 13 | EXISTS 3 | RETIRE-REPORT 9" && ok || bad "T12 dc1 plan counts changed"
for cidr in "2602:f3e2:f03:20::/60" "2602:f3e2:f03:20::/64" "2602:f3e2:f03:21::/64" \
            "2602:f3e2:f03:30::/60" "2602:f3e2:f03:30::/64" "2602:f3e2:f03:40::/60" \
            "2602:f3e2:f03:40::/64" "2602:f3e2:f03:50::/60" "2602:f3e2:f03:50::/64" \
            "2602:f3e2:f03:80::/60" "2602:f3e2:f03:80::/64" \
            "2602:f3e2:f03:f0::/60" "2602:f3e2:f03:f0::/64"; do
  printf '%s' "$OUT" | sed -n '/^  CREATE:/,/^$/p' | grep -qF "$cidr" && ok || bad "T12 dc1 CREATE missing $cidr"
done
printf '%s' "$OUT" | grep -qF "2602:f3e2:f02:" && bad "T12 dc1 run leaked a dc0 prefix" || ok
[ ! -s "$TMP/writes.log" ] && ok || bad "T12 dc1 dry run issued a WRITE"

# T13 a missing IPAM role REFUSES rather than half-carving the DC.
start_stub no-role
run --dc vr1-dc0
[ "$RC" -eq 2 ] && has "lbaas-mgmt" && ok || bad "T13 missing role must REFUSE(2) and name the role"
[ ! -s "$TMP/writes.log" ] && ok || bad "T13 wrote to the apex despite refusing"

# T14 a D-139 prefix already present under a DIFFERENT site/role is an UNRECOGNISED
# state. Skipping it as "exists" would silently accept a mis-bound datacenter.
start_stub conflict
run --dc vr1-dc0
[ "$RC" -eq 2 ] && has "CONFLICT" && ok || bad "T14 mis-bound prefix must REFUSE(2), not read as EXISTS"

# T15 the User-Agent pin is LOAD-BEARING, proven by mutating a COPY: the stub 403s
# any other UA, exactly as the apex does. Without the pin this tool cannot read at all.
start_stub normal
sed 's|^UA = "curl/8.5.0"|UA = "python-urllib/3"|' "$S" > "$TMP/mutant.py"
cmp -s "$S" "$TMP/mutant.py" && bad "T15 UA line not found -- the pin may have been renamed" || ok
OUT="$(NETBOX_URL="http://127.0.0.1:$PORT" NETBOX_TOKEN=stub python3 "$TMP/mutant.py" --dc vr1-dc0 2>&1)"; RC=$?
[ "$RC" -eq 2 ] && has "User-Agent trap" && ok || bad "T15 a wrong-UA build did not REFUSE with the UA hint"

# T16 an empty scoped read is NOT a virgin DC. Confident CREATEs off a failed query
# is the exact shape of this repo's recorded negative-misread class. Asserted on
# BEHAVIOUR (the stub returns rows, none scoped to dc0), not on the tool's source text.
start_stub empty-scope
run --dc vr1-dc0
[ "$RC" -eq 2 ] && has "instrument currency before negatives" && ok \
  || bad "T16 zero scoped rows must REFUSE(2) and say why (rc=$RC)"
has "CREATE" && bad "T16 printed a plan built on a read it had already refused to trust" || ok
[ ! -s "$TMP/writes.log" ] && ok || bad "T16 wrote to the apex despite refusing"

# T17 the READ-BACK is not decoration. The stub answers every POST 201 but never
# adds the row -- the opnsense-plugins.sh class, where apply reported success and
# had silently dry-run. --commit must catch all 13 and exit 1. (127.0.0.1 only.)
# 11 -> 13 on 2026-08-01: the OOB ruling adds two CREATE rows, and the read-back
# tally is one-per-CREATE, so this literal tracks the carve table by design.
start_stub normal
run --dc vr1-dc0 --commit
[ "$RC" -eq 1 ] && ok || bad "T17 --commit rc=$RC after a write that did not land (want 1)"
has "MISSING AFTER WRITE" && ok || bad "T17 read-back did not notice the writes never landed"
has "READ-BACK: 0/13 present" && ok || bad "T17 read-back tally wrong"
[ "$(wc -l < "$TMP/writes.log")" = "13" ] && ok || bad "T17 posted $(wc -l < "$TMP/writes.log") rows, want 13"
[ "$(grep -c '^POST /api/ipam/prefixes/$' "$TMP/writes.log")" = "13" ] && ok || bad "T17 wrote to an endpoint other than ipam/prefixes"

# T18 an UNREACHABLE apex is UNEVALUABLE, not a write error. Port 1 refuses instantly.
# rc MUST be 2 (REFUSE); rc 1 is this tool's "write/read-back error" and a dry run that
# never reached the apex has written nothing. Measured pre-fix: raw traceback, rc=1.
OUT="$(NETBOX_URL=http://127.0.0.1:1 NETBOX_TOKEN=stub python3 "$S" --dc vr1-dc0 2>&1)"; RC=$?
[ "$RC" -eq 2 ] && ok || bad "T18 unreachable apex rc=$RC, want 2 (REFUSE)"
has "apex read failed" && ok || bad "T18 did not name the read that failed"
has "Traceback" && bad "T18 raw traceback where a REFUSE was owed" || ok

echo
total=$((pass+fail))
if [ "$fail" -eq 0 ]; then echo "d139-gua-carve: $pass/$total PASS"; exit 0; fi
echo "d139-gua-carve: $fail/$total FAIL"; exit 1
