#!/usr/bin/env bash
# ca-implement.sh — cursor-agent (composer-2.5) IMPLEMENT leg in one fail-closed command.
# run-plan-cursor's code-WRITING twin of cx-implement.sh (codex), same JSON contract.
#
# WHY this exists: naive `cursor-agent '<prompt>'` is wrong for unattended implementation — it opens an
# interactive session (hangs), prompts for workspace trust and per-command approval (hangs), and picks
# whatever model the user last selected in the interactive app, NOT composer-2.5. This wrapper pins
# --print --force --trust --model composer-2.5, closes stdin, captures the full log, then COMMITS the
# draft itself (cursor-agent may or may not commit) so the caller gets a deterministic head sha.
#
# CONTRACT:
#   ca-implement.sh --worktree DIR --task-slug SLUG --prompt-file FILE [--timeout SECS]
#   - Pins: cursor-agent --print --output-format text --model composer-2.5 --force --trust \
#           --workspace DIR "<prompt>" </dev/null
#   - Prompt is read from FILE (the task contract) and passed as the PROMPT arg; stdin is closed.
#   - After cursor-agent returns, stages everything and commits a draft IF the tree changed and HEAD did
#     not move (cursor-agent didn't self-commit). Idempotent: a clean tree with HEAD advanced is treated
#     as cursor-agent-self-committed.
#   - STDOUT: exactly one JSON line —
#       success:  {"ok":true,"baseSha":"<sha>","headSha":"<sha>","files":["a","b"],"commitCount":N,"detail":"<short>"}
#       failure:  {"ok":false,"baseSha":"<sha>","headSha":"<sha>","files":[],"commitCount":0,"detail":"<short>"}
#     ok=false is a VERDICT (the caller falls back / halts), never a crash. exit 0 on any outcome.
#   - exit 2 ONLY on caller (usage) error — bad/missing args.
#   - BOUNDED RETRY: rc=124 (timeout) or a detected usage/rate limit → retry up to CAI_RETRIES (default 2).
#   - TEST SEAM: CAI_CURSOR_BIN overrides the `cursor-agent` binary. Default: cursor-agent.
#   - No effort/reasoning-tier flag: composer-2.5 is the only seat, cursor-agent has no effort knob.
set -uo pipefail

emit() { printf '%s\n' "$1"; exit 0; }
jstr() { printf '%s' "${1:-}" | tr -d '\n' | sed 's/\\/\\\\/g; s/"/\\"/g' | cut -c1-300; }
usage() { printf '{"ok":false,"baseSha":"","headSha":"","files":[],"commitCount":0,"detail":"usage: %s"}\n' "$(jstr "$*")" >&2; exit 2; }

WORKTREE=""; TASK_SLUG=""; PROMPT_FILE=""; TIMEOUT="${CAI_TIMEOUT:-3480}"
while [[ $# -gt 0 ]]; do
  case "$1" in
    --worktree)    WORKTREE="${2:-}";    shift 2;;
    --task-slug)   TASK_SLUG="${2:-}";   shift 2;;
    --prompt-file) PROMPT_FILE="${2:-}"; shift 2;;
    --timeout)     TIMEOUT="${2:-}";     shift 2;;
    --effort)      shift 2;;    # accepted, ignored — composer-2.5 has no effort tiers
    --model)       shift 2;;    # accepted, ignored — always composer-2.5
    *) usage "unknown arg: $1";;
  esac
done
[[ -n "$WORKTREE" && -d "$WORKTREE" ]] || usage "--worktree missing or not a dir"
[[ -n "$TASK_SLUG" ]] || usage "--task-slug required"
[[ -n "$PROMPT_FILE" && -f "$PROMPT_FILE" ]] || usage "--prompt-file missing or not a file"
case "$TIMEOUT" in ''|*[!0-9]*) usage "--timeout must be integer seconds" ;; esac
CURSOR_BIN="${CAI_CURSOR_BIN:-cursor-agent}"
MODEL="composer-2.5"
PROMPT="$(cat "$PROMPT_FILE")"
[[ -n "$PROMPT" ]] || usage "--prompt-file is empty"

git_q() { git -C "$WORKTREE" "$@" 2>/dev/null; }
BASE_SHA="$(git_q rev-parse HEAD || echo "")"
[[ -n "$BASE_SHA" ]] || emit "{\"ok\":false,\"baseSha\":\"\",\"headSha\":\"\",\"files\":[],\"commitCount\":0,\"detail\":\"worktree has no HEAD commit\"}"

# Logs MUST live outside the worktree, else `git add -A` commits them and a no-op dispatch
# looks like a change.
LOGDIR="${CAI_LOGDIR:-$(dirname "$WORKTREE")/ca-implement-logs/$(basename "$WORKTREE")}"; mkdir -p "$LOGDIR"   # never /tmp — dirname($WORKTREE) IS the resolved tmp root
LOG="$LOGDIR/${TASK_SLUG}.log"; ERR="$LOGDIR/${TASK_SLUG}.err"
: > "$LOG"

RETRIES="${CAI_RETRIES:-2}"; case "$RETRIES" in ''|*[!0-9]*) RETRIES=2;; esac; [[ "$RETRIES" -lt 1 ]] && RETRIES=1
RSLEEP="${CAI_RETRY_SLEEP:-6}"; case "$RSLEEP" in ''|*[!0-9]*) RSLEEP=6;; esac

CURSOR_ARGS=(cursor-agent --print --output-format text --model "$MODEL" --force --trust --workspace "$WORKTREE")

RC=1; DETAIL=""
attempt=0
while [[ $attempt -lt $RETRIES ]]; do
  attempt=$((attempt+1))
  {
    echo "=== TOOL: cursor-agent (implement) attempt=$attempt model=$MODEL timeout=${TIMEOUT}s ==="
    printf 'cmd: '
    printf '%q ' "$CURSOR_BIN" "${CURSOR_ARGS[@]:1}"
    printf '<PROMPT>\n'
  } >>"$LOG"
  timeout "$TIMEOUT" "$CURSOR_BIN" "${CURSOR_ARGS[@]:1}" "$PROMPT" </dev/null >>"$LOG" 2>"$ERR"
  RC=$?
  cat "$ERR" >>"$LOG"
  if [[ $RC -eq 0 ]]; then DETAIL="cursor-agent ok"; break; fi
  if [[ $RC -eq 124 ]]; then
    DETAIL="cursor-agent timeout (rc=124) attempt $attempt"; echo "$DETAIL" >>"$LOG"
    [[ $attempt -lt $RETRIES ]] && { sleep "$RSLEEP"; continue; } || break
  fi
  # usage/rate limit → retry; any other rc → stop (real failure, fall back)
  if grep -qiE 'rate limit|429|usage limit|quota|out of usage|temporarily unavailable|overloaded|at capacity' "$ERR"; then
    DETAIL="cursor-agent transient (rc=$RC) attempt $attempt"; echo "$DETAIL" >>"$LOG"
    [[ $attempt -lt $RETRIES ]] && { sleep "$RSLEEP"; continue; } || break
  fi
  DETAIL="cursor-agent rc=$RC: $(tail -1 "$ERR" 2>/dev/null)"; break
done

# FALLBACK: cursor-agent exhausted retries or hit a real failure — try grok (composer-2.5-fast) once.
# Same composer family, separate quota pool — covers "out of usage" on the cursor side without
# waiting on Cursor's limit reset. TEST SEAM: CAI_GROK_BIN overrides the `grok` binary.
if [[ $RC -ne 0 ]]; then
  GROK_BIN="${CAI_GROK_BIN:-grok}"
  {
    echo "=== TOOL: grok (FALLBACK — cursor-agent failed: $DETAIL) ==="
    printf 'cmd: %q --output-format streaming-json --cwd %q --always-approve -m grok-composer-2.5-fast --prompt-file %q\n' \
      "$GROK_BIN" "$WORKTREE" "$PROMPT_FILE"
  } >>"$LOG"
  : > "$ERR"
  timeout "$TIMEOUT" "$GROK_BIN" --output-format streaming-json --cwd "$WORKTREE" \
    --always-approve -m grok-composer-2.5-fast --prompt-file "$PROMPT_FILE" >>"$LOG" 2>"$ERR"
  GROK_RC=$?
  cat "$ERR" >>"$LOG"
  if [[ $GROK_RC -eq 0 ]]; then
    RC=0
    DETAIL="grok fallback ok (cursor-agent: $DETAIL)"
  else
    DETAIL="grok fallback also failed rc=$GROK_RC (cursor-agent: $DETAIL)"
    echo "$DETAIL" >>"$LOG"
  fi
fi

# Reconcile tree state → deterministic commit. cursor-agent may have: (a) self-committed, (b) left a
# dirty tree, (c) done nothing.
HEAD_NOW="$(git_q rev-parse HEAD || echo "$BASE_SHA")"
DIRTY="$(git_q status --porcelain)"
COMMIT_COUNT=0
if [[ -n "$DIRTY" ]]; then
  git_q add -A
  if git -C "$WORKTREE" commit -q -m "feat(${TASK_SLUG}): cursor-agent draft" >>"$LOG" 2>&1; then
    HEAD_NOW="$(git_q rev-parse HEAD || echo "$HEAD_NOW")"
  fi
fi
# count commits this dispatch produced
if [[ "$HEAD_NOW" != "$BASE_SHA" ]]; then
  COMMIT_COUNT="$(git_q rev-list --count "${BASE_SHA}..${HEAD_NOW}" || echo 1)"
fi

# changed files across the whole dispatch (base..head)
FILES_JSON="[]"
if [[ "$HEAD_NOW" != "$BASE_SHA" ]]; then
  mapfile -t _f < <(git_q diff --name-only "${BASE_SHA}" "${HEAD_NOW}")
  if [[ ${#_f[@]} -gt 0 ]]; then
    FILES_JSON="$(printf '%s\n' "${_f[@]}" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{printf "["} {printf "%s\"%s\"", (NR>1?",":""), $0} END{printf "]"}')"
  fi
fi

OK=false
if [[ $RC -eq 0 && "$HEAD_NOW" != "$BASE_SHA" ]]; then
  OK=true
elif [[ $RC -eq 0 && "$HEAD_NOW" == "$BASE_SHA" ]]; then
  DETAIL="cursor-agent returned ok but produced NO changes (no commit, clean tree)"
fi

emit "{\"ok\":$OK,\"baseSha\":\"$BASE_SHA\",\"headSha\":\"$HEAD_NOW\",\"files\":$FILES_JSON,\"commitCount\":$COMMIT_COUNT,\"detail\":\"$(jstr "$DETAIL")\"}"
