#!/usr/bin/env bash
# Stop hook: verify /goal completion deterministically. Bounded and fail-closed.
#
# THREAT MODEL: best-effort guard against a cooperative-but-lazy model that takes
# shortcuts. NOT a security boundary: a worker with unrestricted shell access as the
# same OS user can tamper with the .claude files and the retry counter.
set -uo pipefail

# Inert unless this session opted in. A Stop hook in settings.json fires on EVERY session.
[ "${GOAL_GATE_ACTIVE:-0}" = "1" ] || exit 0

MAX_ATTEMPTS="${GOAL_MAX_ATTEMPTS:-8}"
[[ "$MAX_ATTEMPTS" =~ ^[1-9][0-9]*$ ]] || MAX_ATTEMPTS=8   # never let bad config make US exit 1

emit_block_raw() { local r="${1//\"/\\\"}"; printf '{"decision":"block","reason":"%s"}\n' "$r"; exit 0; }
command -v jq  >/dev/null 2>&1 || emit_block_raw "Verifier precondition failed: jq not found."
command -v git >/dev/null 2>&1 || emit_block_raw "Verifier precondition failed: git not found."

INPUT=$(cat)
SID=$(jq -r '.session_id // "nosession"' <<<"$INPUT" 2>/dev/null || echo nosession)
SID="${SID//[^A-Za-z0-9_-]/_}"
emit_block() { jq -nc --arg r "$1" '{decision:"block", reason:$r}'; exit 0; }
halt()       { jq -nc --arg r "$1" '{continue:false, stopReason:$r}'; exit 0; }

cd "${CLAUDE_PROJECT_DIR:-$PWD}" 2>/dev/null || emit_block "Cannot cd to the project dir."

# Per-session state and logs, so parallel /goal runs don't clobber each other.
STATE_DIR="${TMPDIR:-/tmp}/goal-verify/sessions/$SID"
mkdir -p "$STATE_DIR" || emit_block "Cannot create the verifier state dir."
COUNTER_FILE="$STATE_DIR/counter"; TEST_LOG="$STATE_DIR/test.log"; BUILD_LOG="$STATE_DIR/build.log"; INT_LOG="$STATE_DIR/integrity.log"

TEST_CMD="${GOAL_TEST_CMD:-npm test}"
BUILD_CMD="${GOAL_BUILD_CMD:-npm run build}"
TEST_DIRS="${GOAL_TEST_DIRS:-test tests spec}"
PROTECTED_PATHS="${GOAL_PROTECTED_PATHS:-package.json package-lock.json pnpm-lock.yaml yarn.lock jest.config.js vitest.config.ts pytest.ini}"
REQUIRE_CLEAN_TREE="${GOAL_REQUIRE_CLEAN_TREE:-1}"
FREEZE_TESTS="${GOAL_FREEZE_TESTS:-0}"

[ -f .claude/goal-base ] || emit_block "Verifier precondition failed: .claude/goal-base missing."
BASE=$(cat .claude/goal-base 2>/dev/null || true)
git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || emit_block "Invalid baseline commit '$BASE'."

FAILURES=()
tail_log() { tail -n 15 "$1" 2>/dev/null | tr '\n' ' ' | cut -c1-300; }

bash -c "$TEST_CMD"  >"$TEST_LOG"  2>&1 || FAILURES+=("$TEST_CMD failed: $(tail_log "$TEST_LOG")")
bash -c "$BUILD_CMD" >"$BUILD_LOG" 2>&1 || FAILURES+=("$BUILD_CMD failed: $(tail_log "$BUILD_LOG")")

if [ "$REQUIRE_CLEAN_TREE" = "1" ] && [ -n "$(git status --porcelain 2>/dev/null)" ]; then
  FAILURES+=("Working tree not clean. Commit the completed work or set GOAL_REQUIRE_CLEAN_TREE=0.")
fi

# Protected build/test config: any change vs baseline fails — including a newly created file,
# so the check still holds when the clean-tree gate is opted out (git diff alone skips untracked).
read -r -a PROT <<< "$PROTECTED_PATHS"
if [ ${#PROT[@]} -gt 0 ]; then
  PROT_NEW=$(git ls-files --others --exclude-standard -- "${PROT[@]}" 2>/dev/null)
  { ! git diff --quiet "$BASE" -- "${PROT[@]}" 2>/dev/null || [ -n "$PROT_NEW" ]; } \
    && FAILURES+=("Protected build/test config changed or newly created vs baseline ($PROTECTED_PATHS).")
fi

# anti-gaming. Default catches deletions + skip/only/todo. GOAL_FREEZE_TESTS=1 rejects ANY change
# (or new file) under the test paths, which also stops semantic weakening.
if [ -n "${GOAL_INTEGRITY_CMD:-}" ]; then
  bash -c "$GOAL_INTEGRITY_CMD" >"$INT_LOG" 2>&1 || FAILURES+=("Integrity check failed: $(tail_log "$INT_LOG")")
else
  read -r -a TPATHS <<< "$TEST_DIRS"
  if [ "$FREEZE_TESTS" = "1" ]; then
    T_NEW=$(git ls-files --others --exclude-standard -- "${TPATHS[@]}" 2>/dev/null)
    { ! git diff --quiet "$BASE" -- "${TPATHS[@]}" 2>/dev/null || [ -n "$T_NEW" ]; } \
      && FAILURES+=("Test files changed or newly created vs baseline (GOAL_FREEZE_TESTS=1).")
  else
    DIFF=$(git diff "$BASE" -- "${TPATHS[@]}" 2>/dev/null || true)
    REMOVED=$(grep -E '^-[^-]' <<<"$DIFF" | grep -cE '\b(it|test|describe)(\.[a-z]+)?[[:space:]]*\(' || true)
    [ "${REMOVED:-0}" -gt 0 ] && FAILURES+=("$REMOVED JS test declaration(s) deleted vs baseline (deleting the test dir counts).")
    SKIPPED=$(grep -E '^\+[^+]' <<<"$DIFF" | grep -cE '\b(it|test|describe)\.(skip|only|todo)\b' || true)
    [ "${SKIPPED:-0}" -gt 0 ] && FAILURES+=("$SKIPPED skip/only/todo added vs baseline.")
  fi
fi

# verdict + bounded enforcement
if [ ${#FAILURES[@]} -eq 0 ]; then rm -f "$COUNTER_FILE"; exit 0; fi

# validate the counter — a corrupt counter must NOT make this script exit 1
N=0
if [ -f "$COUNTER_FILE" ]; then N=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0); [[ "$N" =~ ^[0-9]+$ ]] || N=0; fi
N=$((N + 1))
printf '%s\n' "$N" > "$COUNTER_FILE" 2>/dev/null || halt "Cannot persist the retry counter; halting the run as incomplete."
REASON=$(printf -- '- %s\n' "${FAILURES[@]}")

# At the cap (total attempts), halt with continue:false. Keep GOAL_MAX_ATTEMPTS at or below
# Claude Code's built-in consecutive-block cap (default 8); raise both together for more.
if [ "$N" -ge "$MAX_ATTEMPTS" ]; then
  rm -f "$COUNTER_FILE"
  halt "Completion not verified after $N attempts; halting the run as incomplete:
$REASON"
fi

jq -nc --arg r "Completion condition not met (attempt $N of $MAX_ATTEMPTS). Fix and re-run; do not edit tests or build scripts to pass.
$REASON" '{decision:"block", reason:$r}'
exit 0
