#!/bin/bash
set -euo pipefail

# Cursor `stop` hook — checks the patchcord inbox at the end of every turn.
# Installed automatically by `npx patchcord` when cursor-agent is detected.
#
# Why this exists: cursor-agent has no push-from-background-shell mechanism.
# Its Shell tool exposes only the pull-shaped Await contract
# (task_id / block_until_ms / regex), so a background `patchcord subscribe`
# listener can print "PATCHCORD:" lines that nothing ever reads. Hooks are the
# one inbound path Cursor actually offers, and `stop` is the one that fires
# when the agent goes idle — exactly when an unread message would otherwise sit
# until a human pokes the session.
#
# Contract — captured from a live cursor-agent 2026.07.23 stop event, not from
# docs. The real stdin payload is:
#   {conversation_id, generation_id, model, status, loop_count, input_tokens,
#    output_tokens, cache_read_tokens, cache_write_tokens, session_id,
#    hook_event_name, cursor_version, workspace_roots[], user_email,
#    transcript_path}
# Note `workspace_roots` — an ARRAY, and there is no `workspace_root_path` and
# no `cwd`. Reading a scalar here silently falls back to $PWD (whatever cwd the
# hook process inherited) and resolves the wrong project, or none.
#
#   stdout — JSON; a `followup_message` field starts a new turn carrying that
#            text (StopRequestResponse.followupMessage). Verified live: it does
#            start turns, and each new turn ends and fires this hook again — a
#            bare canary looped 5 times in 14s before Cursor's own loop_count
#            cap stopped it. That is why the streak brake below is not optional.
# Never exit nonzero: a failing hook must not break the session.
#
# Hooks are loaded at STARTUP only. Dropping this file into ~/.cursor while a
# session is running does nothing until that session restarts (verified: the
# same canary was silent mid-session and fired immediately after a restart).

command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0

HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
if [ -f "$HOOK_DIR/lib/runtime-dir.sh" ]; then
  # shellcheck source=lib/runtime-dir.sh
  . "$HOOK_DIR/lib/runtime-dir.sh"
elif [ -f "$HOOK_DIR/runtime-dir.sh" ]; then
  . "$HOOK_DIR/runtime-dir.sh"
fi
RUNTIME_DIR=$(pc_runtime_dir 2>/dev/null || true)
[ -n "$RUNTIME_DIR" ] || exit 0

INPUT=$(cat 2>/dev/null || echo '{}')

# workspace_roots[0] is what cursor-agent actually sends. The scalar spellings
# are accepted as fallbacks in case the payload shape changes again; $PWD is the
# last resort and is deliberately last, since it points at the hook process's
# inherited cwd rather than the agent's project.
PROJECT_CWD=$(echo "$INPUT" | jq -r '(.workspace_roots[0]? // .workspace_root_path? // .cwd?) // empty' 2>/dev/null || true)
if [ -z "$PROJECT_CWD" ] || [ "$PROJECT_CWD" = "null" ]; then
  PROJECT_CWD="$PWD"
fi

CONVERSATION=$(echo "$INPUT" | jq -r '.conversation_id // empty' 2>/dev/null || true)
[ -n "$CONVERSATION" ] || CONVERSATION="nocid"

# ── Resolve project-scoped .cursor/mcp.json ──────────────────────────────────
# PROJECT-scoped ONLY — walk up from the workspace root. A cursor-agent started
# outside a patchcord project is not that agent and must not be nudged.
CURSOR_MCP=""
dir="$PROJECT_CWD"
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
  if [ -f "$dir/.cursor/mcp.json" ]; then
    CURSOR_MCP="$dir/.cursor/mcp.json"
    break
  fi
  parent=$(dirname "$dir")
  [ "$parent" = "$dir" ] && break
  dir="$parent"
done
[ -n "$CURSOR_MCP" ] || exit 0

TOKEN=$(jq -r '.mcpServers.patchcord.headers.Authorization // empty' "$CURSOR_MCP" 2>/dev/null | sed 's/^Bearer //i' || true)
URL=$(jq -r '.mcpServers.patchcord.url // empty' "$CURSOR_MCP" 2>/dev/null || true)
if [ -z "$TOKEN" ] || [ -z "$URL" ]; then
  exit 0
fi

# Cursor uses the bearer endpoint; both spellings normalize to the same base.
BASE_URL=$(echo "$URL" | sed 's|/mcp/bearer$||; s|/mcp$||')

# ── Suppression state, keyed per project+conversation ────────────────────────
# One file holding "<epoch> <consecutive_nudges>". Two jobs:
#   * rate limit  — never nudge more than once per COOLDOWN seconds
#   * loop brake  — if the agent is handed the same nudge MAX_CONSECUTIVE times
#                   without the inbox draining, stop; something is wrong and an
#                   endless followup chain would burn the session.
# Both reset the moment the inbox reaches zero.
#
# Keyed on the RESOLVED project root (the directory owning .cursor/mcp.json),
# never on the incoming workspace path: Cursor reports whatever cwd the turn ran
# in, so keying on that would mint a fresh key per subdirectory and silently
# defeat both the cooldown and the loop brake.
COOLDOWN=30
MAX_CONSECUTIVE=3
PROJECT_ROOT=$(dirname "$(dirname "$CURSOR_MCP")")
STATE_KEY=$(printf '%s|%s' "$PROJECT_ROOT" "$CONVERSATION" | cksum | cut -d' ' -f1)
STATE_FILE="$RUNTIME_DIR/patchcord_cursor_stop_${STATE_KEY}"

# ── Pending count ────────────────────────────────────────────────────────────
RESPONSE=$(curl -s -w $'\n%{http_code}' --max-time 5 \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-patchcord-install-path: ${PROJECT_CWD}" \
  "${BASE_URL}/api/inbox?status=pending&limit=5&count_only=1" 2>/dev/null || printf '\n000')
HTTP_CODE=${RESPONSE##*$'\n'}
RESPONSE=${RESPONSE%$'\n'*}

[ -n "$RESPONSE" ] || RESPONSE='{}'

if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
  # Surface a dead token once per cooldown rather than silently going quiet.
  NOW=$(date +%s)
  LAST=0
  [ -f "$STATE_FILE" ] && LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
  if [ $(( NOW - LAST )) -ge "$COOLDOWN" ]; then
    echo "$NOW 0" > "$STATE_FILE"
    jq -n --arg code "$HTTP_CODE" \
      '{followup_message: ("Patchcord token rejected (HTTP " + $code + "). Tell the human to re-run `npx patchcord` in this project — do not edit .cursor/mcp.json by hand.")}'
  fi
  exit 0
fi

# Network blip / origin down — stay silent, try again next turn.
[ "$HTTP_CODE" = "200" ] || exit 0

COUNT=$(echo "$RESPONSE" | jq -r '.pending_count // .count // 0' 2>/dev/null || echo "0")
case "$COUNT" in
  ''|*[!0-9]*) COUNT=0 ;;
esac

if [ "$COUNT" -eq 0 ]; then
  rm -f "$STATE_FILE"
  exit 0
fi

NOW=$(date +%s)
LAST=0
STREAK=0
if [ -f "$STATE_FILE" ]; then
  LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
  STREAK=$(cut -d' ' -f2 "$STATE_FILE" 2>/dev/null || echo 0)
  case "$LAST" in ''|*[!0-9]*) LAST=0 ;; esac
  case "$STREAK" in ''|*[!0-9]*) STREAK=0 ;; esac
fi

[ $(( NOW - LAST )) -lt "$COOLDOWN" ] && exit 0
[ "$STREAK" -ge "$MAX_CONSECUTIVE" ] && exit 0

echo "$NOW $(( STREAK + 1 ))" > "$STATE_FILE"

NAMESPACE=$(echo "$RESPONSE" | jq -r '.namespace_id // empty' 2>/dev/null || true)
AGENT_ID=$(echo "$RESPONSE" | jq -r '.agent_id // empty' 2>/dev/null || true)

# The identity belongs in the FIRST sentence, not appended after it. Tacked on
# the end it reads as an afterthought and the reader has already moved on.
WHO=""
[ -n "$NAMESPACE" ] && [ -n "$AGENT_ID" ] && WHO=" for ${AGENT_ID}@${NAMESPACE}"

if [ "$COUNT" -eq 1 ]; then
  MSG="Patchcord: 1 pending message${WHO} — call the patchcord inbox tool, do what it asks, then reply with what you did."
else
  MSG="Patchcord: ${COUNT} pending messages${WHO} — call the patchcord inbox tool, do what each asks, then reply to each with what you did."
fi

# Name the identity the count belongs to. This hook reads the token from
# .cursor/mcp.json on disk; the agent's MCP connection holds whatever token it
# was given at session start. Once anything rewrites that config the two drift
# apart, the hook counts for the new identity and the inbox tool answers 0 for
# the old one — a contradiction neither side can see alone. See
# lib/inbox-guard.sh.
if [ -n "$NAMESPACE" ] && [ -n "$AGENT_ID" ]; then
  MSG="${MSG}
If the inbox tool shows a different agent or 0 pending: the token on disk changed after this session started, so this session is signed in as someone else. Do not retry — tell the user and ask them to restart the session."
fi

jq -n --arg msg "$MSG" '{followup_message: $msg}'
exit 0
