#!/usr/bin/env bash
# Toast Review CI PreToolUse hook for Claude Code / Codex.
#
# Blocks reflex `gh pr checks` (and look-alikes) ONLY when a Toast Review
# CI loop is actively in flight for the same PR — i.e., when
# `toast review-ci do/next/wait` was called recently for that PR and the
# response has not yet been terminal. Outside an active loop, this script
# is a no-op so it does not interfere with legitimate `gh` debugging.
#
# Lifecycle files (written by the CLI, read here):
#   ~/.cache/toast-cli/loops/<owner>/<repo>/<pr>.json
#   {"pr":"1278","repo":"toast-ninja/backend","startedAt":1716537600000}
#
# Per-PR file layout instead of a single shared file: agents commonly run
# multiple `do` processes for different PRs concurrently (subagents in
# parallel, or sequentially in the main session). A shared file would race;
# per-PR files give every loop its own contention-free state.
#
# Staleness: each `do/next/wait` invocation refreshes the file's startedAt,
# so a file is "fresh" if it was touched within STALE_THRESHOLD_S. Older
# than that, the hook removes the file and allows the command — handles
# crashed agents that never cleared their file.
#
# Escape hatch: prefix the command with `TOAST_BYPASS=1` to skip the gate
# for a single invocation.
#
# Fail-open everywhere — if jq is missing, JSON is malformed, the dir is
# unreadable, etc., we exit 0 (allow). Blocking on hook errors would be
# worse UX than the reflex it tries to prevent.

set -u

LOOP_DIR="${TOAST_LOOP_STATE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/toast-cli/loops}"
STALE_THRESHOLD_S=$((6 * 60 * 60)) # 6h fallback

# 1. Cheap exit when no loops are active — the 99% case on every Bash call.
[[ ! -d "$LOOP_DIR" ]] && exit 0

# 2. Require jq; without it we cannot safely parse stdin. Fail open.
command -v jq >/dev/null 2>&1 || exit 0

# 3. Read the candidate command from the hook's stdin payload.
CMD=$(jq -r '.tool_input.command // ""' 2>/dev/null)
[[ -z "$CMD" ]] && exit 0

# 4. Only react to the reflex patterns we actually mean to discourage.
case "$CMD" in
  *"gh pr checks"*|*"--json statusCheckRollup"*|*"gh pr status"*|*"gh run list"*|*"gh run view"*) : ;;
  *) exit 0 ;;
esac

# 5. Explicit one-shot bypass.
[[ "$CMD" == *"TOAST_BYPASS=1"* ]] && exit 0

# 6. Try to extract an explicit PR number from `gh pr (checks|view) NUM`.
#    Bare `gh pr checks` (no number) defaults to the current branch's PR
#    in `gh` itself — which is overwhelmingly the loop PR in the reflex
#    case, so we block it against any active loop. Explicit numbers must
#    match the loop's PR to trigger a block.
CMD_PR=""
if [[ "$CMD" =~ (^|[[:space:]])gh[[:space:]]+pr[[:space:]]+(checks|view)[[:space:]]+([0-9]+)([[:space:]]|$) ]]; then
  CMD_PR="${BASH_REMATCH[3]}"
fi

# 7. Walk every active loop file. Block if any one matches the candidate
#    command (per the scoping rule above) and is still fresh; silently
#    sweep away anything past the staleness threshold (self-healing for
#    crashed agents).
NOW_S=$(date +%s)
MATCHED_PR=""
MATCHED_REPO=""

# Use find rather than a bash glob because the depth-2 nested layout
# (owner/repo/pr.json) doesn't expand cleanly with `*` globs across all
# shells, and find -print0 / read -d '' handles paths with spaces safely.
while IFS= read -r -d '' LOOP_FILE; do
  STATE=$(cat "$LOOP_FILE" 2>/dev/null) || continue
  [[ -z "$STATE" ]] && { rm -f "$LOOP_FILE" 2>/dev/null; continue; }

  LOOP_STARTED_MS=$(printf '%s' "$STATE" | jq -r '.startedAt // 0' 2>/dev/null)
  LOOP_PR=$(printf '%s' "$STATE" | jq -r '.pr // empty' 2>/dev/null)
  LOOP_REPO=$(printf '%s' "$STATE" | jq -r '.repo // empty' 2>/dev/null)

  # 7a. Stale → sweep + skip. The 6h fallback is the only auto-cleanup
  #     path; if every active session refreshes its file regularly, it
  #     never trips.
  AGE_S=$((NOW_S - LOOP_STARTED_MS / 1000))
  if [[ $AGE_S -gt $STALE_THRESHOLD_S ]]; then
    rm -f "$LOOP_FILE" 2>/dev/null
    continue
  fi

  [[ -z "$LOOP_PR" ]] && continue

  # 7b. Match the loop file:
  #     - If the command names an explicit PR number: only match when it
  #       equals THIS loop's PR (precise scoping; lets a different PR's
  #       explicit query through).
  #     - If the command has no explicit PR (bare reflex like
  #       `gh pr checks`, `gh pr status`, `gh run list`): match this
  #       loop file — bare commands default to the current branch's PR,
  #       which is the loop PR in the typical reflex case.
  if [[ -n "$CMD_PR" ]]; then
    if [[ "$CMD_PR" == "$LOOP_PR" ]]; then
      MATCHED_PR="$LOOP_PR"
      MATCHED_REPO="$LOOP_REPO"
      break
    fi
  else
    MATCHED_PR="$LOOP_PR"
    MATCHED_REPO="$LOOP_REPO"
    break
  fi
done < <(find "$LOOP_DIR" -mindepth 3 -maxdepth 3 -name '*.json' -print0 2>/dev/null)

# 8. No match → allow.
[[ -z "$MATCHED_PR" ]] && exit 0

# 9. Block. Exit code 2 (= block + show stderr to the agent) per Claude
#    Code's hook contract.
cat >&2 <<EOF
BLOCKED: Toast Review CI loop is active for PR ${MATCHED_PR} in ${MATCHED_REPO:-?}.
Do not cross-reference CircleCI / GitHub checks directly while Toast is
deciding readiness — Toast already reads check state and surfaces it in
\`github_checks\` and \`waiting_for\`. Run instead:
  toast review-ci do --repo ${MATCHED_REPO:-ORG/REPO} --pr ${MATCHED_PR}

Escape hatches:
  - One-shot:  TOAST_BYPASS=1 <your command>
  - Reset:     rm $LOOP_DIR/${MATCHED_REPO}/${MATCHED_PR}.json
EOF
exit 2
