#!/usr/bin/env bash
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
# git wrappers (T51 WP5b).
#
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
# plus the per-tool behaviors (base precedence, transition validation, remote
# fail-closed, absence policy, route context, staged worktree rule).
#
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
# exits nonzero — the declaration-driven arms fail against tools that predate
# the change (evidence captured in the WP5b report).
#
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
# network, no live forge, no writes outside the sandbox.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
TROOT="${TMPDIR:-/tmp}"

PASS=0 FAIL=0 FAILED_CASES=""
FIXTURES=()
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
trap cleanup_all EXIT INT TERM

ok()  { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
assert_rc()    { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
assert_eq()    { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }

new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }

# A fixture repo with the (possibly pre-change) tools + validator installed.
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
mkrepo() {
    local d="$1" decl_origin="${2:-}"
    mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
    cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
    cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
    cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
    git -C "$d" init -q -b feature/x
    git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
    mkdir -p "$d/.mosaic" "$d/sites"
    printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
    [ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
    cat > "$d/.mosaic/repo.json"
    # whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
    # not an invalid-file fixture: strip it to no file.
    grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
}

# The canonical v2 declaration used by default (custom branch names prove the
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
DECL_TR='{
  "schema_version": 2,
  "integration_trunk": "dev-trunk",
  "release_branch": "prod-rel",
  "flow": "trunk-release",
  "canonical_remote": "https://git.example.test/acme/widgets",
  "canonical_clone": "host:/src/widgets",
  "worktree_root": "host:/src/widgets-worktrees",
  "worktree_policy": "orchestrator-precreated"
}'

decl_direct() { printf '{\n  "schema_version": 2,\n  "integration_trunk": "mainline",\n  "release_branch": "mainline",\n  "flow": "direct",\n  "canonical_remote": "https://git.example.test/acme/widgets",\n  "canonical_clone": "host:/src/widgets"\n}\n'; }

load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
    ( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
        && printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
           "$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
}

echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
A="$(load_decl_in "$SB/r1")"
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
assert_contains "A1 schema" "$A" "SCHEMA=2"

new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
A="$(load_decl_in "$SB/r2")"
assert_contains "A2 missing file = absent" "$A" "STATE=absent"

new_sb; mkrepo "$SB/r3" <<< '{ not json'
A="$(load_decl_in "$SB/r3")"
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"

new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r4")"
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"

new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
A="$(load_decl_in "$SB/r5")"
assert_contains "A5 v1 validates" "$A" "STATE=valid"
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"

new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
A="$(load_decl_in "$SB/r6")"
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"

new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r7")"
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"

new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r8")"
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"

new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
A="$(load_decl_in "$SB/r9")"
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"

echo "== A2. transitions + remote + path anchoring =="
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
T=(); rc=0
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
  repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
  repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
  repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
  repo_decl_check_transition other other || echo "same:REFUSED"
  repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"

new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
  repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
  repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"

new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"

new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"

echo "== B. pr-create consumption =="
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
    mkdir -p "$1/stub"
    cat > "$1/stub/curl" <<'STUB'
#!/usr/bin/env bash
url="${*: -1}"
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
case "$url" in
    */api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
    */pulls)
        while [[ $# -gt 0 ]]; do
            case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
        done
        printf '%s\n' '{"number":42}'; exit 0 ;;
    *) printf '%s\n' '{}'; exit 0 ;;
esac
STUB
    chmod +x "$1/stub/curl"
}
run_pr() { # $1 dir, rest args -> pr-create
    local prdir="$1"; shift
    PR_RC=0
    PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
        GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
        bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
    PR_ERR="$(cat "$prdir/err.txt")"
    PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
    PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
    PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
    : > "$prdir/calls.log" 2>/dev/null || true
    rm -f "$prdir/payload.json"
}

new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
run_pr "$SB/b1" -t "T"
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"

run_pr "$SB/b1" -t "T" -B prod-rel
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
assert_count "B2 no POST issued" 0 "$PR_POSTS"

run_pr "$SB/b1" -t "T" -B dev-trunk
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"

run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"

new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
run_pr "$SB/b5" -t "T"
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
assert_count "B5 no POST" 0 "$PR_POSTS"

new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
run_pr "$SB/b6" -t "T"
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B6 payload base = forge default" "forge-default" "$base"
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"

new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
run_pr "$SB/b7" -t "T"
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B7 legacy forge default used" "forge-default" "$base"

new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
run_pr "$SB/b8" -t "T"
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B8 legacy forge default used" "forge-default" "$base"

echo "== C. pr-merge transition validation (no hardcoded targets) =="
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
    cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
    cat > "$1/tools/git/pr-metadata.sh" <<EOF
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
EOF
    chmod +x "$1/tools/git/pr-metadata.sh"
    cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
    chmod +x "$1/tools/git/ci-queue-wait.sh"
    mkdir -p "$1/stub"
    printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
    M_RC=0
    M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
        bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
    M_ERR="$(cat "$1/merr.txt")"
}

new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
mkmerge "$SB/c1" dev-trunk feature/x
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"

mkmerge "$SB/c1" prod-rel feature/x
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"

mkmerge "$SB/c1" prod-rel dev-trunk
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"

new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
mkmerge "$SB/c4" main feature/x
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
mkmerge "$SB/c4" trunk-x feature/x
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"

new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
mkmerge "$SB/c5" dev-trunk feature/x
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"

echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
    mv "$1/home" "$SB/wthome" 2>/dev/null || true
}
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
WT_RC=0
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
    bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"

new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
WT_RC=0
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
    bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"

new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
WT_RC=0
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
    bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"

echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
E_RC=0
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
    bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
E_ERR="$(cat "$SB/e1/eerr.txt")"
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"

new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
E_RC=0
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
    bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"

cleanup_all
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"

echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then
    echo "FAILED CASES:$FAILED_CASES"
    exit 1
fi
echo "ALL GREEN"
