#!/usr/bin/env bash
# test-wake-reconcile.sh — RED-FIRST invariant harness for W5 (EPIC #892):
# the source-parity reconciler (reconcile.sh, A7).
#
# Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if
# that invariant regresses:
#   R1 complete inventory -> PASS (exit 0)                               (§4/G3-i)
#   R2 an OMITTED source (declared but unwatched) -> FLAG; the §4 vector
#      cannot pass VACUOUSLY, and it is NOT silently green               (§4/G3-i)
#   R3 a required_sources omission -> FLAG (explicit lane-dependency form) (§4/G3-i)
#   R4 a dangling watch reference -> FLAG                                 (§4/G3-i)
#   R5 an UNACCOUNTED source-state -> FLAGGED (found + non-zero exit)     (§4/G3-ii)
#   R6 pre-existing state at startup -> ENUMERATED into the durable store;
#      a follow-up reconcile then reports 0 unaccounted                  (W4-division)
#   R7 a source already reflected in the inbox -> ACCOUNTED (no double-
#      enumeration of detector-delivered state)                          (§4/G3-ii)
#   R8 a source adapter error -> FAIL LOUD (G2a parity; never 'no state') (§4/G2a)
#   R9 co-feed NO-ALIAS: detector + reconciler share the ONE store-side
#      observed_seq allocator, so co-feeding one store yields DISTINCT,
#      contiguous seqs (no aliasing). Formerly the fail-closed dual-
#      allocator refusal; #908 dissolves the hazard at the root.        (§4/G3, #908)
#   R10 a CONSUMED detector state is ACCOUNTED via the store's last-consumed
#      record — NOT re-enumerated (no dup wake, no spurious rc=1 CRITICAL). (#932)
#   R11 pilot repro: consumed state SUPPRESSED while a distinct unconsumed/gap
#      state STILL re-enumerates + flags (G3 teeth intact; no over-suppression). (#932)
#
# Uses FAKE/STUB sources only (no live network). Isolated per test.
# shellcheck disable=SC2030,SC2031
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
  echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
  exit 97
fi
wake_assert_init
RECON="$SCRIPT_DIR/reconcile.sh"
STORE="$SCRIPT_DIR/store.sh"
DET="$SCRIPT_DIR/detector.sh"

command -v jq >/dev/null 2>&1 || {
  echo "SKIP: jq not available" >&2
  exit 0
}

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

FAILFILE="$TMP_ROOT/failures"
: >"$FAILFILE"
pass=0
fail_msg() {
  echo "  FAIL: $*" >&2
  echo "x" >>"$FAILFILE"
}
ok() { pass=$((pass + 1)); }

fresh_state() {
  local d="$TMP_ROOT/$1"
  rm -rf "$d"
  mkdir -p "$d"
  printf '%s' "$d"
}
depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; }

# make_stub DIR — a source adapter reading $DIR/<kind>_<id> with optional .rc.
make_stub() {
  local dir="$1"
  cat >"$dir/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"; base="$dir/\${kind}_\${id}"; rc=0
[ -f "\$base.rc" ] && rc="\$(cat "\$base.rc")"
[ -f "\$base" ] && cat "\$base"
exit "\$rc"
EOF
  chmod +x "$dir/adapter.sh"
}

echo "== R1: complete inventory -> PASS =="
(
  WAKE_STATE_HOME="$(fresh_state r1)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  wl="$TMP_ROOT/r1.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" }, { "id": "r2" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  out="$("$RECON" inventory 2>&1)"
  rc=$?
  [ "$rc" -eq 0 ] || fail_msg "R1: a complete inventory must PASS (exit 0) [$out]"
  echo "$out" | has_match -qi 'COMPLETE' || fail_msg "R1: a complete inventory must report COMPLETE [$out]"
) && ok

echo "== R2: OMITTED source -> FLAG (vacuous-pass prevented, not silently green) =="
(
  WAKE_STATE_HOME="$(fresh_state r2)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  # r2 is DECLARED (operationally depended-on) but no watch covers it.
  wl="$TMP_ROOT/r2.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" }, { "id": "r2" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  out="$("$RECON" inventory 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R2: an omitted (declared-but-unwatched) source must FLAG (non-zero), not silently pass"
  echo "$out" | has_match -qi 'r2' || fail_msg "R2: the flag must name the omitted source r2 [$out]"
  echo "$out" | has_match -qi 'vacuous' || fail_msg "R2: the flag must state the vacuous-pass is prevented [$out]"
) && ok

echo "== R3: required_sources omission -> FLAG =="
(
  WAKE_STATE_HOME="$(fresh_state r3)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  # The lane explicitly declares it depends on r1 AND r2, but only watches r1.
  wl="$TMP_ROOT/r3.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" }, { "id": "r2" } ],
  "watches": [ { "lane": "L", "required_sources": [ "r1", "r2" ], "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  out="$("$RECON" inventory 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R3: a required_sources omission must FLAG (non-zero)"
  echo "$out" | has_match -qi "requires source 'r2'" || fail_msg "R3: the flag must name the required-but-omitted source r2 [$out]"
) && ok

echo "== R4: dangling watch reference -> FLAG =="
(
  WAKE_STATE_HOME="$(fresh_state r4)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  wl="$TMP_ROOT/r4.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r9" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  out="$("$RECON" inventory 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R4: a dangling reference must FLAG (non-zero)"
  echo "$out" | has_match -qi 'dangling' || fail_msg "R4: the flag must state it is dangling [$out]"
) && ok

echo "== R5/R6: pre-existing state -> UNACCOUNTED flag + enumerated into store; re-run 0 =="
(
  WAKE_STATE_HOME="$(fresh_state r56)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/r56stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  # TWO pre-existing sources with content already present at startup.
  printf 'PRE-EXISTING-1\n' >"$stub/repo_r1"
  printf 'PRE-EXISTING-2\n' >"$stub/repo_r2"
  wl="$TMP_ROOT/r56.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" }, { "id": "r2" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  # Sole-feeder mode: no detector co-feeds this store (detector.sh is never run
  # here), so enumeration is collision-safe once the operator asserts it.
  export WAKE_RECONCILE_ALLOW_ENUMERATE=1
  [ "$(depth)" = "0" ] || fail_msg "R5: store must start empty"
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R5: pre-existing unaccounted state must FLAG (non-zero) on first reconcile"
  echo "$out" | has_match -qi 'UNACCOUNTED=2' || fail_msg "R5: both pre-existing sources must be UNACCOUNTED [$out]"
  # R6: enumerated into the durable store — one entry per pre-existing source.
  [ "$(depth)" = "2" ] || fail_msg "R6: pre-existing state must be ENUMERATED into the store (depth 2), got $(depth)"
  # A follow-up reconcile now finds everything accounted -> 0 unaccounted.
  out2="$("$RECON" reconcile 2>&1)"
  rc2=$?
  [ "$rc2" -eq 0 ] || fail_msg "R6: a second reconcile must report 0 unaccounted (exit 0) [$out2]"
  echo "$out2" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R6: second reconcile must be 0 unaccounted [$out2]"
  [ "$(depth)" = "2" ] || fail_msg "R6: a clean reconcile must NOT re-enumerate (depth still 2), got $(depth)"
) && ok

echo "== R7: source already in the inbox -> ACCOUNTED (no double-enumeration) =="
(
  WAKE_STATE_HOME="$(fresh_state r7)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/r7stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'V0\n' >"$stub/repo_r1"
  wl="$TMP_ROOT/r7.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  # Detector baselines (silent) then observes a real delta -> pending inbox entry.
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R7: detector baseline failed"
  printf 'V1\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R7: detector delta failed"
  [ "$(depth)" = "1" ] || fail_msg "R7: detector delta should leave one pending entry, got $(depth)"
  # The source's CURRENT state IS the pending inbox entry -> ACCOUNTED, and the
  # reconciler must NOT enumerate a duplicate.
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  [ "$rc" -eq 0 ] || fail_msg "R7: state reflected in the inbox must be ACCOUNTED (exit 0) [$out]"
  echo "$out" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R7: inbox-reflected state must be 0 unaccounted [$out]"
  [ "$(depth)" = "1" ] || fail_msg "R7: reconciler must NOT double-enumerate inbox-reflected state (depth still 1), got $(depth)"
) && ok

echo "== R8: source adapter error -> FAIL LOUD (G2a; never 'no state') =="
(
  WAKE_STATE_HOME="$(fresh_state r8)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/r8stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'FORBIDDEN\n' >"$stub/repo_r1"
  printf '3\n' >"$stub/repo_r1.rc" # adapter exits non-zero (403/partial class)
  wl="$TMP_ROOT/r8.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R8: a source error must FAIL LOUD (non-zero exit)"
  echo "$out" | has_match -qi 'FAIL LOUD' || fail_msg "R8: the source error must be loud [$out]"
  [ "$(depth)" = "0" ] || fail_msg "R8: a failed observation must NOT enumerate anything, got depth $(depth)"
) && ok

echo "== R9: co-feed NO-ALIAS — detector + reconciler share ONE store allocator; every observed_seq distinct + gapless (#908 resolved) =="
(
  WAKE_STATE_HOME="$(fresh_state r9)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE
  stub="$TMP_ROOT/r9stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  # The reviewer's alpha/beta repro (formerly the dual-allocator ALIASING case).
  printf 'ALPHA\n' >"$stub/repo_alpha"
  printf 'BETA\n' >"$stub/repo_beta"
  wl="$TMP_ROOT/r9.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "alpha" }, { "id": "beta" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "alpha" }, { "kind": "repo", "id": "beta" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"

  # A DETECTOR is co-feeding this store. Under #908 both the detector and the
  # reconciler allocate observed_seq from the ONE store cursor (store.sh enqueue,
  # no --seq), so co-feeding is now SAFE — the reconciler ENUMERATES (no refusal),
  # and no seq can be aliased. (The old guard failed closed here; that refusal is
  # retired exactly to the extent the single allocator makes safe.)
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector baseline failed"
  # A real detector delta on alpha -> the store allocates observed_seq 1.
  printf 'ALPHA2\n' >"$stub/repo_alpha"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector delta (alpha) failed"
  [ "$(depth)" = "1" ] || fail_msg "R9: detector delta should enqueue exactly one entry, got depth $(depth)"

  # The reconciler co-feeds the SAME store: beta is still unaccounted (its
  # baseline was silent), so it enumerates beta -> the store allocates the NEXT
  # seq (2), NOT a reissue of 1. alpha's current state is already in the inbox
  # (accounted), so it is not double-enumerated.
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "R9: pre-existing beta is unaccounted on this pass -> must FLAG (non-zero) [$out]"
  echo "$out" | has_match -qi 'UNACCOUNTED=1' || fail_msg "R9: only beta should be unaccounted (alpha is inbox-accounted) [$out]"
  [ "$(depth)" = "2" ] || fail_msg "R9: reconciler must ENUMERATE beta into the co-fed store (depth 2), got $(depth)"

  # A further detector delta on beta must allocate 3 — the unified allocator
  # advances past the reconciler's seq, it can NEVER reissue/alias 1 or 2.
  printf 'BETA2\n' >"$stub/repo_beta"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector delta (beta) failed"

  # THE NO-ALIAS ASSERTION: every observed_seq across BOTH feeders is DISTINCT and
  # the observed prefix is CONTIGUOUS/GAPLESS (1,2,3). Under the OLD two-allocator
  # design the detector's private counter would have reissued a seq the reconciler
  # already used, so this set would contain a duplicate (alias).
  seqs="$("$STORE" drain | jq -r '.observed_seq' | sort -n | tr '\n' ' ')"
  [ "$seqs" = "1 2 3 " ] || fail_msg "R9: co-fed observed_seqs must be distinct+gapless '1 2 3', got '$seqs' (a duplicate = the aliasing #908 dissolved)"
  ndistinct="$("$STORE" drain | jq -r '.observed_seq' | sort -nu | count_lines .)"
  ntotal="$("$STORE" drain | jq -r '.observed_seq' | count_lines .)"
  [ "$ndistinct" = "$ntotal" ] || fail_msg "R9: aliasing detected — $ntotal entries but only $ndistinct distinct observed_seq"
  # The contiguous-prefix CONSUMED contract holds over the co-fed seqs.
  "$STORE" consume --upto 3 >/dev/null 2>&1 || fail_msg "R9: CONSUMED 3 must succeed over the gapless co-fed prefix"
) && ok

echo "== R10: #932 — a CONSUMED detector state is ACCOUNTED via the store's last-consumed record (no dup wake, no spurious rc=1 CRITICAL) =="
(
  WAKE_STATE_HOME="$(fresh_state r10)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE
  stub="$TMP_ROOT/r10stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'V0\n' >"$stub/repo_r1"
  wl="$TMP_ROOT/r10.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  # Detector baselines (silent) then a real delta -> one pending inbox entry (seq 1).
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R10: detector baseline failed"
  printf 'V1\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R10: detector delta failed"
  [ "$(depth)" = "1" ] || fail_msg "R10: detector delta should leave one pending entry, got $(depth)"
  # CONSUME it: consumed_seq advances, the pending prefix is truncated. The store
  # now records r1's last-consumed observed_hash. The source's CURRENT state is
  # UNCHANGED (still V1) — exactly the pilot's just-consumed byte-match window.
  "$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "R10: CONSUMED 1 must succeed"
  [ "$(depth)" = "0" ] || fail_msg "R10: consume must truncate the pending prefix (depth 0), got $(depth)"
  # RE-RECONCILE. On BASE this RE-ENUMERATES the just-consumed state (inbox
  # truncated + seen-ledger never covered a detector enqueue) -> UNACCOUNTED=1,
  # rc=1 (spurious CRITICAL), depth 1 (duplicate wake). After #932 the store's
  # last-consumed record ACCOUNTS it -> clean.
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  [ "$rc" -eq 0 ] || fail_msg "R10: a consumed state must be ACCOUNTED (exit 0, no spurious rc=1 CRITICAL) [$out]"
  echo "$out" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R10: a consumed state must be 0 unaccounted (no re-enumeration) [$out]"
  [ "$(depth)" = "0" ] || fail_msg "R10: reconciler must NOT re-enumerate a consumed state (depth still 0 = no duplicate wake), got $(depth)"
) && ok

echo "== R11: #932 — pilot repro: consumed state SUPPRESSED while a distinct unconsumed/gap state STILL re-enumerates (G3 teeth intact; no blanket suppression) =="
(
  WAKE_STATE_HOME="$(fresh_state r11)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE
  stub="$TMP_ROOT/r11stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'V0\n' >"$stub/repo_r1"
  # r2 is a genuine unaccounted GAP: present from the start so the detector's
  # FIRST-SEEN baseline is SILENT (never enqueues it), leaving it unaccounted and
  # unconsumed — it has NO last-consumed record, so it MUST still re-enumerate.
  printf 'GAP-STATE\n' >"$stub/repo_r2"
  wl="$TMP_ROOT/r11.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1" }, { "id": "r2" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"
  # Baseline BOTH sources silently (no enqueue on first-seen). r2 stays at this
  # state forever -> never enqueued = genuine gap.
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R11: detector baseline failed"
  # r1: a real delta -> enqueued (seq 1) -> CONSUMED (store records its hash).
  printf 'V1\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "R11: detector delta (r1) failed"
  "$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "R11: CONSUMED 1 must succeed"
  [ "$(depth)" = "0" ] || fail_msg "R11: store should be empty before the reconcile, got depth $(depth)"
  out="$("$RECON" reconcile 2>&1)"
  rc=$?
  # r2 is genuinely unaccounted -> the pass STILL FLAGS (G3 teeth intact).
  [ "$rc" -ne 0 ] || fail_msg "R11: a genuine gap (r2) must still FLAG (non-zero) [$out]"
  # After #932: ONLY r2 is unaccounted (r1 suppressed by the store record). On
  # BASE both r1 and r2 re-enumerate (UNACCOUNTED=2) -> this assertion is red-first.
  echo "$out" | has_match -qi 'UNACCOUNTED=1' || fail_msg "R11: exactly ONE source (the gap r2) must be unaccounted; the consumed r1 must be suppressed [$out]"
  # Prove precisely WHICH state re-enumerated: the gap r2 IS enumerated, the
  # consumed r1 is NOT. (base re-enumerates r1 too -> the r1-absent assertion is
  # red-first; the r2-present assertion holds both before and after = no over-suppression.)
  en(){ "$STORE" drain | jq -s --arg id "$1" '[ .[] | select(.locators.id==$id) ] | length'; }
  [ "$(en r2)" -ge 1 ] || fail_msg "R11: the genuine gap r2 MUST be re-enumerated into the store (no blanket suppression) [$out]"
  [ "$(en r1)" -eq 0 ] || fail_msg "R11: the CONSUMED r1 must NOT be re-enumerated (its byte-matched hash is accounted by the store record) [$out]"
) && ok

echo
if [ -s "$FAILFILE" ]; then
  echo "wake reconcile harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
  exit 1
fi
echo "wake reconcile harness: all invariants passed ($pass groups)"
