#!/usr/bin/env bash
# ca-review.sh — the cursor-agent (composer-2.5) REVIEW leg for run-plan-cursor, ONE fail-closed command.
# review-side twin of cx.sh's review mode (codex), same JSON verdict contract, single seat only (no
# model/effort fallback tiers — composer-2.5 is the whole engine).
#
# WHY this exists: naive `cursor-agent '<prompt>'` is wrong for unattended review — it hangs on stdin,
# prompts for workspace trust, defaults to whatever model the interactive app last had selected (not
# composer-2.5), and emits free-form prose (no contract JSON, no exhaustion signal). This wrapper pins
# --print --output-format json --model composer-2.5 --trust, closes stdin, forces a strict JSON verdict,
# and NEVER blocks the pipeline: any cursor-agent failure (quota, crash, malformed output) collapses to
# {...,"exhausted":true} — the caller halts rather than trusting a bad "clean".
#
# CONTRACT:
#   ca-review.sh --worktree DIR --base REF --head REF [--trust] [--label L]
#     two-dot diff BASE..HEAD (the task's own change).
#   - Pins: cursor-agent --print --output-format json --model composer-2.5 --trust --workspace DIR "<prompt>" </dev/null
#   - stdout: ALWAYS exactly one line of JSON, ALWAYS exit 0 when args are valid:
#       verdict  -> {"clean":bool,"findings":[{"file","issue","severity"}]}; empty diff -> clean.
#       unusable -> {"clean":false,"findings":[],"exhausted":true,"reason":"<short>"}
#   - exit 2 ONLY on caller (usage) error — bad/missing args. A review failure is a VERDICT, not a crash.
#   - TEST SEAM: CAR_CURSOR_BIN overrides the `cursor-agent` binary; CAR_GROK_BIN overrides `grok`.
#     CAR_RETRIES = total attempts (default 3); CAR_RETRY_SLEEP = seconds between attempts (default 4).
#   - cursor-agent exhausted (quota/crash/sandbox-unavailable) -> ONE grok (composer-2.5-fast) attempt
#     before exhausted:true. --sandbox disabled: this is a read-only review (prompt forbids edits) and
#     the AppArmor-gated sandbox mode is unavailable on some hosts.
set -uo pipefail

usage() { echo "{\"clean\":false,\"findings\":[],\"exhausted\":true,\"reason\":\"usage: $*\"}" >&2; exit 2; }

WORKTREE="" BASE="" HEAD="" TRUST="" LABEL=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    --worktree) WORKTREE="${2:-}"; shift 2;;
    --base)     BASE="${2:-}";     shift 2;;
    --head)     HEAD="${2:-}";     shift 2;;
    --trust)    TRUST="1";         shift 1;;
    --label)    LABEL="${2:-}";    shift 2;;
    --model|--effort) shift 2;;    # accepted, ignored — always composer-2.5, no effort tiers
    *) shift;;
  esac
done
[[ -n "$WORKTREE" && -d "$WORKTREE" ]] || usage "--worktree missing or not a dir"
[[ -n "$BASE" ]] || usage "--base REF required"
[[ -n "$HEAD" ]] || usage "--head REF required"
CURSOR_BIN="${CAR_CURSOR_BIN:-cursor-agent}"
MODEL="composer-2.5"

exhausted() {
  local reason; reason="$(printf '%s' "${1:-cursor-agent-unavailable}" | tr -d '\n' | cut -c1-200)"
  reason="${reason//\\/\\\\}"; reason="${reason//\"/\\\"}"
  printf '{"clean":false,"findings":[],"exhausted":true,"reason":"%s"}\n' "$reason"
  exit 0
}

DIFF_RANGE="$BASE..$HEAD"
DIFF="$(git -C "$WORKTREE" diff "$DIFF_RANGE" 2>/dev/null)" || { printf '{"clean":false,"findings":[{"file":"worktree","issue":"git diff failed for %s — refs not reachable in worktree","severity":"blocker"}]}\n' "$DIFF_RANGE"; exit 0; }
[[ -n "$DIFF" ]] || { printf '{"clean":true,"findings":[]}\n'; exit 0; }

DIFF_CAP=60000
if [[ ${#DIFF} -gt $DIFF_CAP ]]; then
  DIFF="${DIFF:0:$DIFF_CAP}
[... diff truncated at ${DIFF_CAP} chars — open files under the worktree for the rest ...]"
fi
TRUST_LINE=""; [[ -n "$TRUST" ]] && TRUST_LINE=" TRUST BOUNDARY touched — scrutinize the boundary hard."
read -r -d '' PROMPT <<EOF || true
You are a senior security+correctness reviewer. Perform an adversarial HIGH-risk code review of this diff
(the task's change: ${DIFF_RANGE}).
Hunt, in priority order: correctness bugs, money/auth defects, IDOR/missing-authorization, injection,
fail-open paths, atomicity/idempotency, cross-task contract breaks.${TRUST_LINE}
WARNING-SUPPRESSION CHECK: if the diff adds lines to a .warnignore, treat each new entry adversarially.
A suppression is legitimate ONLY for a genuinely-unfixable warning (third-party/upstream, un-bumpable dep).
If a newly-suppressed warning is from THIS diff's own code or is fixable, that is a blocker finding:
demand the warning be FIXED and the entry removed. A vague justification ("benign","noise") is itself a blocker.

Do NOT edit any files — this is a read-only review.

OUTPUT CONTRACT — output ONLY a single minified JSON object, no markdown fences, no prose before or after:
{"clean":<true only if ZERO must-fix findings>,"findings":[{"file":"<path>","issue":"<one line>","severity":"<critical|high|medium|low>"}]}
If there are no must-fix findings, output exactly {"clean":true,"findings":[]}.

=== DIFF (${DIFF_RANGE}) ===
${DIFF}
EOF

# extract + validate the verdict from one cursor-agent run. --output-format json wraps the final answer
# in {"type":"result","result":"<text>",...}; the text itself may carry stray prose, so pull the LAST
# balanced object satisfying the review shape (clean:bool + findings:list) out of that text.
extract_verdict() {
  CAR_OUT="$1" python3 - <<'PY' 2>/dev/null
import os, sys, json, re

def balanced_candidates(s):
    cands = []
    for m in re.finditer(r'\{', s):
        depth = 0
        for i in range(m.start(), len(s)):
            c = s[i]
            if c == '{': depth += 1
            elif c == '}':
                depth -= 1
                if depth == 0:
                    cands.append(s[m.start():i+1]); break
    return cands

raw = os.environ.get('CAR_OUT', '')
# Step 1 — unwrap --output-format json's envelope ({"type":"result","result":"<text>",...}).
# Try a WHOLE-FILE parse first: the outer envelope is always one clean top-level JSON object,
# and json.loads's own string parser handles its escaping correctly regardless of what braces
# appear inside the nested "result"/"text" string value. Only fall back to the brace-scan (which
# is NOT escaping-aware and silently drops the outer object whenever that nested string's own
# brace count is odd — e.g. natural-language prose with an unpaired "{" or "}") if the whole
# response isn't clean JSON (leading/trailing noise around it).
text = raw
o = None
try:
    o = json.loads(raw)
except Exception:
    o = None
if not isinstance(o, dict):
    for c in balanced_candidates(raw):
        try:
            o = json.loads(c)
        except Exception:
            continue
        if isinstance(o, dict):
            break
    else:
        o = None
if isinstance(o, dict) and isinstance(o.get('result'), str):
    text = o['result']
elif isinstance(o, dict) and isinstance(o.get('text'), str):
    text = o['text']

# Step 2 — pull the LAST balanced object matching the review shape out of the unwrapped text.
text = re.sub(r'```(?:json)?', '', text)
obj = None
for c in reversed(balanced_candidates(text)):
    try: o = json.loads(c)
    except Exception: continue
    if isinstance(o, dict) and isinstance(o.get('clean'), bool) and isinstance(o.get('findings'), list):
        obj = o; break
if obj is None:
    sys.exit(1)
norm = []
for f in obj['findings']:
    if not isinstance(f, dict): sys.exit(1)
    norm.append({
        'file': str(f.get('file', '')),
        'issue': str(f.get('issue', '')),
        'severity': str(f.get('severity', '')),
    })
print(json.dumps({'clean': obj['clean'], 'findings': norm}, separators=(',', ':')))
PY
}

ERR="$(mktemp)"; trap 'rm -f "$ERR"' EXIT
RETRIES="${CAR_RETRIES:-3}"; case "$RETRIES" in ''|*[!0-9]*) RETRIES=3;; esac; [[ "$RETRIES" -lt 1 ]] && RETRIES=1
RSLEEP="${CAR_RETRY_SLEEP:-4}"; case "$RSLEEP" in ''|*[!0-9]*) RSLEEP=4;; esac

LASTREASON=""
for ((attempt=1; attempt<=RETRIES; attempt++)); do
  OUT="$("$CURSOR_BIN" --print --output-format json --model "$MODEL" --sandbox disabled --trust --workspace "$WORKTREE" "$PROMPT" </dev/null 2>"$ERR")"
  RC=$?
  if [[ $RC -ne 0 ]]; then
    ETAIL="$(tail -3 "$ERR" 2>/dev/null | tr '\n' ' ')"
    LASTREASON="cursor-agent rc=$RC: $ETAIL"
  else
    VERDICT="$(extract_verdict "$OUT")"
    if [[ -n "$VERDICT" ]]; then printf '%s\n' "$VERDICT"; exit 0; fi
    LASTREASON="cursor-agent output not valid review json"
  fi
  [[ $attempt -lt $RETRIES && "$RSLEEP" -gt 0 ]] && sleep "$RSLEEP" 2>/dev/null
done

# FALLBACK: cursor-agent exhausted — try grok (composer-2.5-fast), separate quota pool, read-only
# review so no sandbox needed. TEST SEAM: CAR_GROK_BIN overrides the `grok` binary.
GROK_BIN="${CAR_GROK_BIN:-grok}"
GPF="$(mktemp /tmp/car-grok-XXXXXX.txt)"; printf '%s' "$PROMPT" > "$GPF"
GOUT="$("$GROK_BIN" --output-format json --cwd "$WORKTREE" --always-approve -m grok-composer-2.5-fast --prompt-file "$GPF" 2>"$ERR")"
GRC=$?
rm -f "$GPF"
if [[ $GRC -eq 0 ]]; then
  VERDICT="$(extract_verdict "$GOUT")"
  if [[ -n "$VERDICT" ]]; then printf '%s\n' "$VERDICT"; exit 0; fi
  LASTREASON="grok fallback output not valid review json (cursor-agent: $LASTREASON)"
else
  GTAIL="$(tail -3 "$ERR" 2>/dev/null | tr '\n' ' ')"
  LASTREASON="grok fallback rc=$GRC: $GTAIL (cursor-agent: $LASTREASON)"
fi
exhausted "${LASTREASON:-cursor-agent-unavailable} (after $RETRIES attempts + grok fallback)"
