#!/usr/bin/env bash
# trace-mcp-reindex v0.6.0
# trace-mcp PostToolUse auto-reindex hook
# Daemon-first: posts to the running daemon's /api/projects/reindex-file
# endpoint via curl (no Node startup). Falls back to a cold subprocess
# only when the daemon is unreachable.
#
# v0.5 changes (TRA-694 — the dispatch no longer blocks the agent):
#   - Notifying the daemon that a file changed is fire-and-forget: reindexing
#     is asynchronous on the daemon side and nothing here consumes the result.
#     Yet the curl ran in the foreground, so every edit paid its round trip —
#     p50 372 ms on the happy path, and the full `--max-time 2` ceiling
#     whenever the daemon was up but not answering. Measured over 16,440
#     recorded runs: 12,215 s (3 h 24 min) of agent wall clock, 70% of it on
#     timeouts. The whole dispatch is now detached; the hook returns in ~5 ms.
#   - Fixed the failure classification. `curl -w '%{http_code}'` prints "000"
#     itself on connection failure AND exits non-zero, so the old
#     `|| echo "000"` appended a second one: HTTP_CODE became "000000", which
#     matched no case arm and was recorded as `other`. Every connection
#   failure in the history is mislabelled, and `no-daemon` — which the tests
#     believed they covered — never occurred once in 16,440 lines.
#
# v0.6 changes (TRA-2032 — daemon answered 404 project-not-registered on
# nearly half of all dispatches):
#   - Canonicalize PROJECT_ROOT and FILE_PATH through the same symlink
#     resolution before posting. `path.resolve` on the daemon side never
#     resolves symlinks, so on macOS the same checkout was `/tmp/x` to the
#     hook and `/private/tmp/x` to the daemon (or the reverse) and every such
#     Edit paid a cold CLI spawn. Both paths go through one spelling, or the
#     daemon's lexical inside-root check would 400 a canonical root paired
#     with an aliased file.
#   - Record the project root on every stats line so the next "hook always
#     falls back" QA run can correlate 404s to the missed root from the
#     stats file alone (previously the line carried no root at all).

set -euo pipefail

INPUT=$(cat)
TOOL_NAME="${CLAUDE_TOOL_NAME:-$(echo "$INPUT" | jq -r '.tool_name // empty')}"

# Only handle edit-like tools
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "MultiEdit" ]]; then
  exit 0
fi

# Code file extensions to reindex
CODE_EXT_RE='\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|go|rs|java|kt|kts|rb|php|cs|cpp|c|h|hpp|swift|scala|vue|svelte|astro|blade\.php)$'

# Non-code extensions — skip silently
NONCODE_EXT_RE='\.(md|json|jsonc|yaml|yml|toml|ini|cfg|env|txt|html|xml|csv|svg|lock|log|sh|bash|zsh|fish|ps1|bat|cmd|dockerfile|dockerignore|gitignore|gitattributes|editorconfig|prettierrc|eslintrc|stylelintrc)$'

FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ -z "$FILE_PATH" ]]; then
  exit 0
fi

# Skip non-code files
if echo "$FILE_PATH" | grep -qiE "$NONCODE_EXT_RE"; then
  exit 0
fi

# Skip if not a recognised code file
if ! echo "$FILE_PATH" | grep -qiE "$CODE_EXT_RE"; then
  exit 0
fi

# Resolve project root: prefer git toplevel, then walk up looking for
# .git or trace-mcp.config.json. Skip silently when the file isn't in a
# tracked project.
PROJECT_ROOT=""
FILE_DIR=$(dirname "$FILE_PATH")
if command -v git >/dev/null 2>&1; then
  PROJECT_ROOT=$(cd "$FILE_DIR" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [[ -z "$PROJECT_ROOT" ]]; then
  dir="$FILE_DIR"
  while [[ "$dir" != "/" && -n "$dir" ]]; do
    if [[ -d "$dir/.git" || -f "$dir/trace-mcp.config.json" ]]; then
      PROJECT_ROOT="$dir"
      break
    fi
    dir=$(dirname "$dir")
  done
fi
[[ -z "$PROJECT_ROOT" ]] && exit 0

# TRA-2032: canonicalize symlink aliases (macOS /tmp -> /private/tmp) so the
# posted root matches the spelling the daemon registered. realpath first
# (ships on macOS 15+ and GNU), python3's non-strict realpath as the portable
# fallback (never fails lexically, even for not-yet-existing paths), else keep
# the path as-is. Runs in the detached dispatch, so a python3 spawn costs the
# agent nothing.
canon_path() {
  local p="$1" out=""
  out=$(realpath "$p" 2>/dev/null) || out=""
  if [[ -z "$out" ]]; then
    out=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$p" 2>/dev/null) || out=""
  fi
  if [[ -z "$out" ]]; then
    printf '%s' "$p"
  else
    printf '%s' "$out"
  fi
}
PROJECT_ROOT=$(canon_path "$PROJECT_ROOT")
FILE_PATH=$(canon_path "$FILE_PATH")

# Daemon port: env override, then default 3741.
PORT="${TRACE_MCP_DAEMON_PORT:-3741}"

# Stats file — best-effort JSONL telemetry for `daemon stats`.
STATS_HOME="${TRACE_MCP_HOME:-$HOME/.trace-mcp}"
STATS_FILE="$STATS_HOME/hook-stats.jsonl"
STATS_MAX_BYTES=$((10 * 1024 * 1024))

# Portable millisecond clock. Prefer EPOCHREALTIME (bash 5) — strip the
# decimal point to get ms. Fall back to GNU date, then python.
#
# The strategy is resolved ONCE, at load. The previous version re-probed on
# every call, so under bash 3.2 (macOS system bash — no EPOCHREALTIME) each
# now_ms cost three processes (date + grep + python3) and a hook run cost
# nine, purely to read a clock. That is the slowest path in the hook and the
# widest window for anything watching it to time out.
if [[ -n "${EPOCHREALTIME:-}" ]]; then
  MS_CLOCK=bash
else
  _ms_probe=$(date +%s%3N 2>/dev/null || true)
  if [[ "$_ms_probe" =~ ^[0-9]+$ ]]; then
    MS_CLOCK=date
  else
    MS_CLOCK=python3
  fi
  unset _ms_probe
fi

now_ms() {
  case "$MS_CLOCK" in
    bash)
      local s="${EPOCHREALTIME%.*}"
      local us="${EPOCHREALTIME#*.}"
      # us is always 6 digits in bash 5; take first 3 for ms.
      printf '%s%s' "$s" "${us:0:3}"
      ;;
    date) date +%s%3N ;;
    *) python3 -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0 ;;
  esac
}

write_stat() {
  local path_kind="$1"
  local reason="$2"
  local wall_ms="$3"
  # Callers already read the clock to close out WALL_MS — reuse it rather
  # than paying for another reading.
  local ts="$4"
  # TRA-2032: the project root, so 404/respond-miss lines can be correlated to
  # the missed root from the stats file alone. Built with jq (already a hard
  # hook dependency for input parsing) so arbitrary bytes in the path can't
  # break the JSONL line.
  local project="${5:-}"
  mkdir -p "$STATS_HOME" 2>/dev/null || return 0
  # Rotate when over ~10 MB. Truncate (not delete) so concurrent appenders
  # keep their fd alive; data loss is acceptable for telemetry.
  if [[ -f "$STATS_FILE" ]]; then
    local size
    # Same GNU/BSD split as rotate_log in trace-mcp-launcher.sh (TRA-707): GNU
    # stat reads `-f%z FILE` as a filesystem query, prints a report on stdout
    # and exits 1, so a single `a || b` substitution captures that report with
    # the fallback's number stuck on the end. Here it was worse than a missed
    # rotation — `[[ x -gt y ]]` on that string prints a bash syntax error to
    # stderr. Try each form separately and require a plain number.
    size=$(stat -c%s "$STATS_FILE" 2>/dev/null) || size=''
    case "$size" in
      ''|*[!0-9]*) size=$(stat -f%z "$STATS_FILE" 2>/dev/null) || size='' ;;
    esac
    case "$size" in
      ''|*[!0-9]*) size=0 ;;
    esac
    if [[ "$size" -gt "$STATS_MAX_BYTES" ]]; then
      : > "$STATS_FILE" 2>/dev/null || true
    fi
  fi
  # Single jq for atomic-ish append; JSONL tolerates interleaved whole lines.
  # Wrap in a subshell with stderr suppressed so bash's own redirection errors
  # (e.g. read-only stats dir) never leak to the caller. `-c` keeps one event
  # on one line — without it jq pretty-prints and the file stops being JSONL.
  ( jq -nc --argjson ts "$ts" --arg path "$path_kind" --arg reason "$reason" \
      --argjson wallclock_ms "$wall_ms" --arg project "$project" \
      '{ts:$ts,path:$path,reason:$reason,wallclock_ms:$wallclock_ms,project:$project}' \
      >> "$STATS_FILE" ) 2>/dev/null || true
}

dispatch() {
  # Wallclock for the dispatch attempt.
  local START_MS END_MS WALL_MS HTTP_CODE REASON
  START_MS=$(now_ms)

  # Try daemon first — single curl, ~5 ms RTT. No Node startup.
  # `-w '%{http_code}'` yields "000" on any pre-HTTP failure (refused,
  # timeout, DNS). curl's exit status is deliberately ignored: the code it
  # printed is the whole signal, and `|| echo` here corrupts it (see v0.5).
  HTTP_CODE=$(curl -sS --max-time 2 -o /dev/null -w '%{http_code}' -X POST \
      -H 'Content-Type: application/json' \
      -d "$(jq -n --arg p "$PROJECT_ROOT" --arg f "$FILE_PATH" '{project:$p,path:$f}')" \
      "http://127.0.0.1:${PORT}/api/projects/reindex-file" 2>/dev/null) || true
  # Empty means curl is missing or died before writing anything — same
  # observable state as a refused connection.
  [[ -z "$HTTP_CODE" ]] && HTTP_CODE="000"

  END_MS=$(now_ms)
  WALL_MS=$((END_MS - START_MS))

  if [[ "$HTTP_CODE" =~ ^2[0-9][0-9]$ ]]; then
    write_stat "daemon" "ok" "$WALL_MS" "$END_MS" "$PROJECT_ROOT"
    return 0
  fi

  # Classify failure for stats. 000 = no daemon / connection refused / timeout
  # (curl exits before HTTP). Distinguish timeout via curl exit code is fiddly
  # under set -e; we collapse "couldn't connect" into no-daemon.
  case "$HTTP_CODE" in
    400) REASON="400" ;;
    404) REASON="404" ;;
    503) REASON="503" ;;
    5*)  REASON="5xx" ;;
    000) REASON="no-daemon" ;;
    *)   REASON="other" ;;
  esac

  # Fallback: cold spawn (legacy behavior). Only hit when daemon is down.
  # Phase 5+7 audit fix: `nohup ... &` returns immediately after fork, so any
  # duration we measure post-fork is just the spawn cost — NOT the cold CLI
  # reindex duration the user actually pays. Record only the curl wallclock so
  # `daemon stats` shows the cost of *detecting* the daemon is down, not a
  # misleading fake reindex time.
  if command -v trace-mcp >/dev/null 2>&1; then
    nohup trace-mcp index-file "$FILE_PATH" >/dev/null 2>&1 &
    write_stat "cli" "$REASON" "$WALL_MS" "$END_MS" "$PROJECT_ROOT"
  else
    write_stat "skipped" "$REASON" "$WALL_MS" "$END_MS" "$PROJECT_ROOT"
  fi
}

# Detach. Redirecting all three fds is not cosmetic: a background job that
# keeps the hook's stdout open makes the caller block reading that pipe until
# the job ends, which would reinstate exactly the wait we are removing.
dispatch </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true

exit 0
