#!/usr/bin/env bash
# Tests for the TODO enforcement trio: todo-autocapture (UserPromptSubmit),
# todo-edit-gate (PreToolUse), todo-tasks (dismissal CLI). Both branches of every
# gate: it blocks when it should and does not block when it should not.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUN="${BUN:-/usr/bin/bun}"
CAPTURE="$HERE/todo-autocapture.mjs"
EDITGATE="$HERE/todo-edit-gate.mjs"
CLI="$HERE/todo-tasks.mjs"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
export HOME="$TMP"
SESS="test-session"
DIR="$HOME/.claude/tasks/$SESS"
FAILS=0

ok() { echo "ok: $1"; }
bad() { echo "FAIL: $1"; FAILS=$((FAILS + 1)); }
expect() { # name expected actual
  [[ "$3" == "$2" ]] && ok "$1" || bad "$1 (expected $2, got $3)"
}

capture() { # prompt
  printf '{"session_id":"%s","prompt":%s}' "$SESS" "$(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
    | "$BUN" "$CAPTURE"
}
edit_gate() { # [transcript_path]
  printf '{"session_id":"%s","tool_name":"Edit","tool_input":{},"transcript_path":"%s"}' "$SESS" "${1:-}" | "$BUN" "$EDITGATE"
}
gate_verdict() {
  local out
  out="$(edit_gate "${1:-}")"
  [[ "$out" == *'"permissionDecision":"deny"'* ]] && echo deny || echo allow
}
ntasks() { ls "$DIR"/[0-9]*.json 2>/dev/null | wc -l | tr -d ' '; }

# --- classifier ---------------------------------------------------------
out="$(capture "please fix the broken build")"
expect "request-bearing prompt is captured" 1 "$(ntasks)"
[[ "$out" == *'recorded as task #1'* ]] && ok "capture announces the task id" || bad "capture announced nothing: $out"

out="$(capture "thanks")"
expect "pure acknowledgement is not captured" 1 "$(ntasks)"
[[ -z "$out" ]] && ok "acknowledgement emits no context" || bad "acknowledgement emitted: $out"

capture "ok cool thanks" >/dev/null
expect "multi-token acknowledgement is not captured" 1 "$(ntasks)"
capture "go eat" >/dev/null
expect "unknown token makes it a request (fail-closed)" 2 "$(ntasks)"
capture "ok ok ok ok ok ok ok" >/dev/null
expect "long ack-only prompt is captured (over token cap)" 3 "$(ntasks)"
capture "" >/dev/null
capture "   " >/dev/null
expect "empty prompt is not captured" 3 "$(ntasks)"

# --- idempotency --------------------------------------------------------
capture "please fix the broken build" >/dev/null
expect "identical prompt does not create a second task" 3 "$(ntasks)"
capture "please fix the broken build twice" >/dev/null
expect "different prompt does create a task" 4 "$(ntasks)"

# --- verbatim recording -------------------------------------------------
python3 - "$DIR/1.json" <<'PY' && ok "prompt recorded verbatim with auto-capture metadata" || bad "verbatim/metadata recording"
import json,sys
t=json.load(open(sys.argv[1]))
assert t["description"]=="please fix the broken build", t["description"]
assert t["metadata"]["autoCaptured"]=="true"
assert t["metadata"]["sessionId"]=="test-session"
assert t["status"]=="pending"
PY

# --- edit gate, both branches -------------------------------------------
expect "Edit denied while nothing is in_progress" deny "$(gate_verdict)"
"$BUN" "$CLI" --session "$SESS" start 1 >/dev/null
expect "Edit still denied while another captured ask is unclaimed" deny "$(gate_verdict)"

deny_msg="$(edit_gate)"
for needle in 'todo-tasks.mjs start' 'todo-tasks.mjs new' 'todo-tasks.mjs dismiss' 'todo-gate-off'; do
  [[ "$deny_msg" == *"$needle"* ]] && ok "deny message names $needle" || bad "deny message missing $needle"
done

# a subagent shares the parent's session id but must never be forced to touch the
# parent's captured asks: for it the gate is the plain in_progress rule.
SIDE="$TMP/side.jsonl"
printf '{"isSidechain":true,"type":"user","message":{"role":"user","content":"go"}}\n' >"$SIDE"
MAIN="$TMP/main.jsonl"
printf '{"isSidechain":false,"type":"user","message":{"role":"user","content":"go"}}\n' >"$MAIN"
expect "subagent allowed while a task is in_progress despite unclaimed captured asks" allow "$(gate_verdict "$SIDE")"
expect "main thread still denied on the same state" deny "$(gate_verdict "$MAIN")"

for id in 2 3 4; do "$BUN" "$CLI" --session "$SESS" dismiss $id "not this run" >/dev/null; done
expect "Edit allowed once every captured ask is claimed or dismissed" allow "$(gate_verdict)"
"$BUN" "$CLI" --session "$SESS" done 1 >/dev/null
expect "Edit denied again once the task completes" deny "$(gate_verdict)"

expect "subagent denied too when nothing is in_progress" deny "$(gate_verdict "$SIDE")"
side_msg="$(edit_gate "$SIDE")"
for needle in 'todo-tasks.mjs start' 'todo-tasks.mjs new' 'todo-gate-off'; do
  [[ "$side_msg" == *"$needle"* ]] && ok "subagent deny message names $needle" || bad "subagent deny message missing $needle"
done
[[ "$side_msg" != *dismiss* ]] && ok "subagent deny path never offers dismiss" || bad "subagent offered dismiss: $side_msg"
printf '{"id":"900","subject":"an owner ask a subagent must not claim","description":"d","status":"pending","blocks":[],"blockedBy":[],"metadata":{"autoCaptured":"true"}}' >"$DIR/900.json"
side_msg="$(edit_gate "$SIDE")"
[[ "$side_msg" != *'#900 '* ]] && ok "subagent deny message never lists an auto-captured ask" || bad "subagent was pointed at #900: $side_msg"
rm -f "$DIR/900.json"
NEW="$TMP/agent-fresh.jsonl"; : >"$NEW"
[[ "$(edit_gate "$NEW")" != *dismiss* ]] && ok "fresh agent-*.jsonl with no readable first line is treated as a subagent" || bad "fresh subagent transcript fell back to the main-thread rule"

touch "$HOME/.claude/todo-gate-off"
expect "escape hatch releases the edit gate" allow "$(gate_verdict)"
rm -f "$HOME/.claude/todo-gate-off"
expect "escape hatch removal re-arms the edit gate" deny "$(gate_verdict)"

# --- unknown session must never wedge -----------------------------------
out="$(printf '{"session_id":"no-such-session","tool_name":"Edit"}' | "$BUN" "$EDITGATE")"
[[ -z "$out" ]] && ok "unknown session fails open" || bad "unknown session denied: $out"
out="$(printf 'garbage' | "$BUN" "$EDITGATE")"
[[ -z "$out" ]] && ok "garbage stdin fails open" || bad "garbage stdin denied: $out"

# --- dismissal ----------------------------------------------------------
"$BUN" "$CLI" --session "$SESS" new "manual work" >/dev/null
expect "CLI new creates and starts a task" allow "$(gate_verdict)"
capture "a fresh ask that turns out to need no work" >/dev/null
fresh="$(ls "$DIR"/[0-9]*.json | sort -t/ -k9 -n | tail -1)"; fresh="$(basename "$fresh" .json)"
out="$("$BUN" "$CLI" --session "$SESS" dismiss "$fresh" "conversational, no work needed")"
[[ "$out" == *'dismissals this session: 4'* ]] && ok "dismissal is counted" || bad "dismissal not counted: $out"
python3 - "$DIR/$fresh.json" <<'PY' && ok "dismissal is visible and carries its reason" || bad "dismissal record"
import json,sys
t=json.load(open(sys.argv[1]))
assert t["status"]=="completed", t["status"]
assert t["subject"].startswith("[dismissed] "), t["subject"]
assert t["metadata"]["dismissedReason"]=="conversational, no work needed"
PY
out="$("$BUN" "$CLI" --session "$SESS" list)"
[[ "$out" == *'4 dismissed'* ]] && ok "list reports the dismissal count" || bad "list count: $out"
set +e
out="$("$BUN" "$CLI" --session "$SESS" dismiss 1 2>&1)"
rc=$?
set -e
[[ $rc -ne 0 && "$out" == *'reason is required'* ]] && ok "dismissal without a reason is refused" || bad "reasonless dismissal accepted: $out"

if [[ $FAILS -gt 0 ]]; then echo "$FAILS failure(s)"; exit 1; fi
echo "all tests passed"
