#!/usr/bin/env bash
# heal-self.sh — Idempotent self-migration archiving stale unknown-owner
# registry entries (v4.10.0 Phase 38 DOC-03).
#
# On invocation:
#   1. Compute SHA256 of registry.json content.
#   2. If matches ~/.claude/loops/.last-healed-hash → exit 0 (gated).
#   3. Archive entries with owner_session_id ∈ {unknown, unknown-session, '',
#      pending-bind} older than 1 hour to registry.archive.jsonl.
#   4. Update .last-healed-hash to current hash.
#
# Returns 0 always (graceful — must not block SessionStart hook caller).

set -euo pipefail

LOOPS_DIR="${HOME}/.claude/loops"
REGISTRY_PATH="${CLAUDE_LOOPS_REGISTRY:-$LOOPS_DIR/registry.json}"
ARCHIVE_PATH="${LOOPS_DIR}/registry.archive.jsonl"
HASH_PATH="${LOOPS_DIR}/.last-healed-hash"
STALE_THRESHOLD_S="${HEAL_STALE_THRESHOLD_S:-3600}"

trap 'exit 0' ERR

[ -f "$REGISTRY_PATH" ] || exit 0

# Try shasum first (macOS), fall back to sha256sum (Linux)
_compute_hash() {
  if command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$REGISTRY_PATH" 2>/dev/null | awk '{print $1}'
  elif command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$REGISTRY_PATH" 2>/dev/null | awk '{print $1}'
  else
    return 0
  fi
}

CURRENT_HASH=$(_compute_hash)
[ -z "$CURRENT_HASH" ] && exit 0

if [ -f "$HASH_PATH" ]; then
  LAST_HASH=$(cat "$HASH_PATH" 2>/dev/null || echo "")
  if [ "$CURRENT_HASH" = "$LAST_HASH" ]; then
    exit 0
  fi
fi

NOW_US=$(python3 -c "import time; print(int(time.time()*1_000_000))" 2>/dev/null || echo 0)
NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Filter stale entries: owner_session_id stale AND owner_start_time_us older than threshold.
STALE_ENTRIES=$(jq -c --argjson now "$NOW_US" --argjson threshold_us "$((STALE_THRESHOLD_S * 1000000))" \
  '.loops[] | select(
    (.owner_session_id == "unknown" or .owner_session_id == "unknown-session" or
     .owner_session_id == "" or .owner_session_id == "pending-bind" or .owner_session_id == null)
    and ((.owner_start_time_us | tonumber) > 0)
    and (($now - (.owner_start_time_us | tonumber)) > $threshold_us)
  )' "$REGISTRY_PATH" 2>/dev/null || echo "")

if [ -z "$STALE_ENTRIES" ]; then
  # Nothing stale — still update hash so we don't re-scan unchanged registry next time
  echo "$CURRENT_HASH" >"$HASH_PATH" 2>/dev/null || true
  exit 0
fi

# Wave 6.1: source provenance-lib early so the auto-bind block below
# can emit heal_auto_bound events. The historical placement at line ~232
# was AFTER the auto-bind block, so emit_provenance silently no-op'd
# (`command -v emit_provenance` returned false), losing the rescue
# telemetry. Sourcing here is idempotent — the later block re-sources
# without harm.
PROV_LIB_EARLY="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/provenance-lib.sh"
if [ -f "$PROV_LIB_EARLY" ]; then
  # shellcheck source=/dev/null
  source "$PROV_LIB_EARLY" 2>/dev/null || true
fi

# Wave 6 anti-fragility: BEFORE archiving, try to auto-bind each stale
# pending-bind entry to a live Claude session whose cwd matches the
# loop's project_root. This rescues loops the bind hook missed because
# the user's session opened with cwd at project_root rather than inside
# .autoloop/<slug>--<hash>/.
#
# Detection signal: ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
# with mtime within the last 5 minutes. The encoding maps absolute path
# to "-" separators (e.g. /Users/x/eon/foo → -Users-x-eon-foo). The
# JSONL filename IS the session UUID. If a recent JSONL exists at the
# matching encoded path, that session is the natural new owner.
RESCUED_LOOP_IDS=""
PROJECTS_DIR="$HOME/.claude/projects"
if [ -d "$PROJECTS_DIR" ]; then
  RESCUED_LIST=$(mktemp -t autoloop-heal.XXXXXX) || RESCUED_LIST=""
  while IFS= read -r entry; do
    [ -z "$entry" ] && continue
    local_loop_id=$(echo "$entry" | jq -r '.loop_id // ""' 2>/dev/null)
    local_root=$(echo "$entry" | jq -r '.created_at_cwd // ""' 2>/dev/null)
    local_contract=$(echo "$entry" | jq -r '.contract_path // ""' 2>/dev/null)
    # Derive project_root if registry doesn't have it (legacy entries)
    # Wave 6.1: also handle legacy <root>/LOOP_CONTRACT.md layout — those
    # entries' project_root is the contract's parent dir (the git toplevel
    # in practice). Without this fallback, legacy contracts that the bind
    # hook's derived_root logic CAN handle were invisible to heal-self
    # auto-bind.
    if [ -z "$local_root" ] && [ -n "$local_contract" ]; then
      case "$local_contract" in
        */.autoloop/*--[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/CONTRACT.md)
          local_root="${local_contract%/.autoloop/*}"
          ;;
        */LOOP_CONTRACT.md)
          local_root="$(dirname "$local_contract")"
          ;;
      esac
    fi
    [ -z "$local_root" ] && continue

    # Wave 6.2: canonicalize via realpath before encoding. Claude Code names
    # the JSONL directory after the realpath-resolved cwd; an entry whose
    # created_at_cwd was written before realpath canonicalization existed
    # (or via a symlinked workspace) would encode to a non-matching dir
    # and the auto-bind would silently fall through to archival even
    # though a live session was sitting right there. The fallback to the
    # raw value preserves behavior when the path no longer exists on disk.
    if local_root_canon=$(cd "$local_root" 2>/dev/null && pwd -P); then
      local_root="$local_root_canon"
    fi

    # Encode: leading-slash path with "/" replaced by "-"
    encoded="${local_root//\//-}"
    project_jsonl_dir="$PROJECTS_DIR/$encoded"
    [ -d "$project_jsonl_dir" ] || continue

    # Find the most recent JSONL (mtime within 5min = live session)
    candidate=$(find "$project_jsonl_dir" -maxdepth 1 -name '*.jsonl' -mmin -5 -print 2>/dev/null | head -1)
    [ -z "$candidate" ] && continue

    candidate_sid=$(basename "$candidate" .jsonl)
    # Validate UUID shape before mutating registry
    if ! [[ "$candidate_sid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
      continue
    fi

    # Source registry-lib for atomic update_loop_field
    REG_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/registry-lib.sh"
    if [ -f "$REG_LIB" ]; then
      # shellcheck source=/dev/null
      source "$REG_LIB" 2>/dev/null || continue
      if command -v update_loop_field >/dev/null 2>&1; then
        if update_loop_field "$local_loop_id" ".owner_session_id" "\"$candidate_sid\"" 2>/dev/null; then
          update_loop_field "$local_loop_id" ".owner_start_time_us" "\"$NOW_US\"" 2>/dev/null || true

          # Wave 6.1: seed heartbeat.bound_cwd to close the spawn-refusal
          # gap. Pre-Wave-6.1, heal-self auto-bind only patched the
          # registry; the next launchd waker firing would read
          # heartbeat.json, find bound_cwd empty (heartbeat-tick hadn't
          # fired yet for the rescued session), and spawn-refuse with
          # "spawn_refused_no_bound_cwd". This left auto-bound loops
          # stuck for one extra waker cycle. Seeding bound_cwd to
          # contract_dir (which is what heartbeat-tick.sh would write
          # on first tick) lets the very next waker firing proceed.
          local_state_dir=$(echo "$entry" | jq -r '.state_dir // ""' 2>/dev/null)
          if [ -n "$local_state_dir" ] && [ -d "$local_state_dir" ]; then
            HB_FILE="$local_state_dir/heartbeat.json"
            # Wave 6.2: canonicalize so the seeded bound_cwd matches what
            # heartbeat-tick.sh and waker.sh now expect post-canonicalization.
            local_contract_dir=$(cd "$(dirname "$local_contract")" 2>/dev/null && pwd -P) || \
              local_contract_dir=$(dirname "$local_contract")
            if [ -f "$HB_FILE" ]; then
              # Merge bound_cwd into existing heartbeat
              TMP_HB=$(mktemp "$HB_FILE.heal.XXXXXX") || TMP_HB=""
              if [ -n "$TMP_HB" ]; then
                if jq --arg bc "$local_contract_dir" \
                    '. + {bound_cwd: $bc, cwd_drift_detected: false}' \
                    "$HB_FILE" >"$TMP_HB" 2>/dev/null; then
                  mv "$TMP_HB" "$HB_FILE" 2>/dev/null || rm -f "$TMP_HB"
                else
                  rm -f "$TMP_HB"
                fi
              fi
            else
              # No heartbeat yet — synthesize a minimal one so waker can
              # proceed. last_wake_us=NOW makes the loop look "fresh"
              # which is correct: the heal-self detection signal proves
              # the rescued session is alive RIGHT NOW.
              jq -nc --arg loop_id "$local_loop_id" \
                     --arg session_id "$candidate_sid" \
                     --arg last_wake_us "$NOW_US" \
                     --arg bound_cwd "$local_contract_dir" \
                '{loop_id: $loop_id, session_id: $session_id,
                  iteration: "0", last_wake_us: $last_wake_us,
                  generation: "0", bound_cwd: $bound_cwd,
                  cwd_drift_detected: false}' \
                >"$HB_FILE" 2>/dev/null || true
            fi
          fi

          if command -v emit_provenance >/dev/null 2>&1; then
            emit_provenance "$local_loop_id" "heal_auto_bound" \
              session_id="$candidate_sid" \
              project_root="$local_root" \
              project_root_source="heal-self.sh" \
              cwd_bound="$local_root" \
              reason="rescued from pending-bind: live JSONL at $candidate (mtime <5min); seeded heartbeat.bound_cwd to allow next waker firing to proceed" \
              decision="proceeded" 2>/dev/null || true
          fi
          [ -n "$RESCUED_LIST" ] && echo "$local_loop_id" >>"$RESCUED_LIST"
        fi
      fi
    fi
  done <<< "$STALE_ENTRIES"

  # Re-filter STALE_ENTRIES to drop loops we just rescued (so they don't
  # get archived in the next block).
  if [ -n "$RESCUED_LIST" ] && [ -s "$RESCUED_LIST" ]; then
    RESCUED_LOOP_IDS=$(tr '\n' '|' <"$RESCUED_LIST" | sed 's/|$//')
    if [ -n "$RESCUED_LOOP_IDS" ]; then
      STALE_ENTRIES=$(echo "$STALE_ENTRIES" | jq -c --arg rescued "$RESCUED_LOOP_IDS" \
        'select(.loop_id | test("^(" + $rescued + ")$") | not)' 2>/dev/null || echo "$STALE_ENTRIES")
    fi
    rm -f "$RESCUED_LIST"
  fi
fi

# Re-check after rescue — if everything got auto-bound, no archival needed.
if [ -z "$STALE_ENTRIES" ]; then
  echo "$CURRENT_HASH" >"$HASH_PATH" 2>/dev/null || true
  exit 0
fi

# Append each stale entry to archive (with archived_ts annotation), then remove from registry.
mkdir -p "$LOOPS_DIR" 2>/dev/null || true
ARCHIVED=0
while IFS= read -r entry; do
  [ -z "$entry" ] && continue
  local_archived=$(echo "$entry" | jq -c --arg ts "$NOW_ISO" '. + {archived_ts_iso: $ts}')
  echo "$local_archived" >>"$ARCHIVE_PATH" 2>/dev/null || true
  ARCHIVED=$((ARCHIVED + 1))
done <<< "$STALE_ENTRIES"

# Wave 6.3: remove archived entries from registry inside _with_registry_lock.
# Pre-fix this was a bare mktemp+jq+mv sequence that raced against
# concurrent update_loop_field() callers — a SessionStart firing in another
# Claude window could acquire the registry lock, write an updated owner_pid
# / owner_session_id field, release, and then heal-self's unprotected `mv`
# would overwrite that change with its filtered (older) snapshot. The lock
# primitive serializes the read-modify-write transaction.
REG_LIB_HEAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/registry-lib.sh"
if [ -f "$REG_LIB_HEAL" ]; then
  # shellcheck source=/dev/null
  source "$REG_LIB_HEAL" 2>/dev/null || true
fi

if command -v _with_registry_lock >/dev/null 2>&1; then
  # _with_registry_lock invokes its callback indirectly via "$fn" "$@"
  # (registry-lib.sh:220), so shellcheck can't see the call site.
  # shellcheck disable=SC2329
  _heal_archive_filter() {
    local now="$1" threshold_us="$2"
    jq --argjson now "$now" --argjson threshold_us "$threshold_us" '
      .loops |= map(select(
        (.owner_session_id == "unknown" or .owner_session_id == "unknown-session" or
         .owner_session_id == "" or .owner_session_id == "pending-bind" or .owner_session_id == null)
        and ((.owner_start_time_us | tonumber) > 0)
        and (($now - (.owner_start_time_us | tonumber)) > $threshold_us)
        | not
      ))
    '
  }
  _with_registry_lock _heal_archive_filter "$NOW_US" "$((STALE_THRESHOLD_S * 1000000))" \
    >/dev/null 2>&1 || exit 0
else
  # Fail-graceful: if registry-lib couldn't be sourced (test envs that
  # stub it out), fall back to the historical unsafe path. Better to
  # archive racy than to leave stale entries forever.
  TMP_REG=$(mktemp "$REGISTRY_PATH.heal.XXXXXX") || exit 0
  jq --argjson now "$NOW_US" --argjson threshold_us "$((STALE_THRESHOLD_S * 1000000))" '
    .loops |= map(select(
      (.owner_session_id == "unknown" or .owner_session_id == "unknown-session" or
       .owner_session_id == "" or .owner_session_id == "pending-bind" or .owner_session_id == null)
      and ((.owner_start_time_us | tonumber) > 0)
      and (($now - (.owner_start_time_us | tonumber)) > $threshold_us)
      | not
    ))
  ' "$REGISTRY_PATH" >"$TMP_REG" 2>/dev/null || {
    rm -f "$TMP_REG"
    exit 0
  }
  mv "$TMP_REG" "$REGISTRY_PATH" || {
    rm -f "$TMP_REG"
    exit 0
  }
fi

# Emit provenance event (best-effort)
PROV_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/provenance-lib.sh"
if [ -f "$PROV_LIB" ]; then
  # shellcheck source=/dev/null
  source "$PROV_LIB" 2>/dev/null || true
  if command -v emit_provenance >/dev/null 2>&1; then
    emit_provenance "" "heal_archived" \
      agent="heal-self.sh" \
      reason="archived $ARCHIVED stale entries with unknown/pending-bind owner" \
      decision="proceeded" 2>/dev/null || true
  fi
fi

# Update hash AFTER archival so a partial run on retry still archives remaining stale entries.
NEW_HASH=$(_compute_hash)
[ -n "$NEW_HASH" ] && echo "$NEW_HASH" >"$HASH_PATH" 2>/dev/null || true

# Wave-6 anti-fragility: scan for live registry entries missing their plist
# and best-effort regenerate via tinker-lib. This catches the documented F1
# failure mode (registered loop, no plist on disk) without waiting for the
# user to invoke /autoloop:tinker manually.
#
# Conservative: only operate on entries whose owner_session_id is a real
# UUID (already-bound loop) — never touch pending-bind entries the operator
# may still be wiring up. Logs to provenance and exits 0 either way.
DOCTOR_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/tinker-lib.sh"
if [ -f "$DOCTOR_LIB" ] && [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then
  # Find loop_ids whose owner_session_id is a real UUID
  BOUND_LOOPS=$(jq -r '
    .loops[]?
    | select(.owner_session_id | test("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))
    | .loop_id
  ' "$REGISTRY_PATH" 2>/dev/null || echo "")

  if [ -n "$BOUND_LOOPS" ]; then
    # shellcheck source=/dev/null
    source "$DOCTOR_LIB" 2>/dev/null || BOUND_LOOPS=""
    while IFS= read -r LID; do
      [ -z "$LID" ] && continue
      PLIST="$HOME/Library/LaunchAgents/com.user.claude.loop.${LID}.plist"
      if [ ! -f "$PLIST" ]; then
        if command -v emit_provenance >/dev/null 2>&1; then
          emit_provenance "$LID" "heal_self_repairing_missing_plist" \
            reason="bound loop with missing plist detected" \
            decision="repair_attempt" 2>/dev/null || true
        fi
        repair_missing_plist "$LID" 2>/dev/null || true
      fi
    done <<<"$BOUND_LOOPS"
  fi
fi

exit 0
