#!/usr/bin/env bash
# session-bind.sh — SessionStart hook for autoloop (v4.10.0 Phase 36)
#
# Authoritatively binds owner_session_id from stdin payload because the
# $CLAUDE_SESSION_ID env var is NOT populated in skill Bash subprocesses
# (anthropics/claude-code#47018). The skills/start/SKILL.md sets
# owner_session_id="pending-bind" at registration and this hook completes the
# binding on the next SessionStart event for ANY session whose cwd lies under
# a registered loop's contract directory.
#
# Stdin payload schema (Claude Code SessionStart hook):
#   {
#     "session_id": "<uuid>",
#     "cwd": "/path/to/cwd",
#     "source": "startup|resume|clear|compact",
#     "transcript_path": "...",
#     "hook_event_name": "SessionStart"
#   }
#
# Binding state machine (per matched loop):
#   "" / unknown / unknown-session / pending-bind  → bind_first
#   matches current session_id                     → bind_resume (idempotent)
#   other UUID, owner_pid alive                    → observer (no mutation)
#   other UUID, owner_pid dead, last_updated > 1h  → stale_owner_detected
#                                                     (no auto-reclaim;
#                                                      surfaced for tinker)
#
# All paths exit 0 — SessionStart hooks must never block session startup.

set -euo pipefail

# Iter-34 bash-5.2-patsub-replacement-defense (see heartbeat-tick.sh for
# full rationale): disable bash 5.2+ `&`-as-backreference in pattern
# substitution. `|| true` makes it a graceful no-op on bash <5.2.
shopt -u patsub_replacement 2>/dev/null || true

# ===== Configuration =====
LOOPS_DIR="${HOME}/.claude/loops"
REGISTRY_PATH="${CLAUDE_LOOPS_REGISTRY:-$LOOPS_DIR/registry.json}"
HOOK_ERRORS_LOG="$LOOPS_DIR/.hook-errors.log"
STALE_OWNER_THRESHOLD_S="${SESSION_BIND_STALE_THRESHOLD_S:-3600}"  # 1h default

# ===== Error handling =====
_log_error() {
  local cwd
  cwd=$(pwd 2>/dev/null || echo 'unknown')
  local error_msg="$1"
  local exit_code="${2:-1}"
  mkdir -p "$LOOPS_DIR" 2>/dev/null || true
  jq -n \
    --arg ts_us "$(gdate +%s%6N 2>/dev/null || python3 -c "import time; print(int(time.time()*1_000_000))" 2>/dev/null || echo '0')" \
    --arg cwd "$cwd" \
    --arg error "$error_msg" \
    --arg exit_code "$exit_code" \
    --arg hook "session-bind.sh" \
    '{ts_us: $ts_us, cwd: $cwd, error: $error, exit_code: $exit_code, hook: $hook}' \
    >>"$HOOK_ERRORS_LOG" 2>/dev/null || true
  # Wave 4 W2.4: opportunistic rotation. Best-effort; missing helper or
  # rotation failure does not block the hook.
  if command -v rotate_jsonl_if_large >/dev/null 2>&1; then
    rotate_jsonl_if_large "$HOOK_ERRORS_LOG" 2>/dev/null || true
  fi
}

trap '_log_error "Unexpected error in session-bind hook" "$?"' ERR

# ===== Source libraries =====
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_SCRIPTS="$SCRIPT_DIR/../scripts"

for lib in registry-lib.sh provenance-lib.sh state-lib.sh; do
  if [ ! -f "$PLUGIN_SCRIPTS/$lib" ]; then
    _log_error "missing $lib" 1
    exit 0
  fi
  # shellcheck source=/dev/null
  source "$PLUGIN_SCRIPTS/$lib" 2>/dev/null || {
    _log_error "failed to source $lib" 1
    exit 0
  }
done

# Set agent name for provenance events
export _PROV_AGENT="session-bind.sh"

# ===== Read stdin payload =====
PAYLOAD=$(cat 2>/dev/null || echo '{}')
[ -z "$PAYLOAD" ] && PAYLOAD='{}'

# Perf (iter-28 payload-decode-tsv-batch): pre-iter-28 we spawned THREE
# separate jq processes for the three PAYLOAD fields — each ~7-10ms cold
# start on macOS. Mirrors the iter-25 heartbeat-tick.sh fix: one jq with
# TSV output, bash `read` builtin splits the columns. ~20ms saved on every
# SessionStart (user-facing first-impression latency).
IFS=$'\t' read -r SESSION_ID PAYLOAD_CWD SOURCE <<< "$(
  echo "$PAYLOAD" | jq -r '"\(.session_id // "")\t\(.cwd // "")\t\(.source // "unknown")"' 2>/dev/null \
    || printf '\t\tunknown'
)"

# Empty session_id is fatal for binding (but not an error — could be running
# outside Claude Code via direct invocation in tests).
if [ -z "$SESSION_ID" ]; then
  emit_provenance "" "bind_skipped_no_session_id" \
    reason="empty session_id from stdin payload" \
    decision="deferred" 2>/dev/null || true
  exit 0
fi

# Strict UUID validation — refuse to write a malformed session_id into the
# registry. Without this, a hostile or malformed stdin payload can pollute
# owner_session_id; downstream `claude --resume <id>` would then fail with a
# confusing error far from the source. Source portable.sh on demand
# (best-effort — if the file is missing or sourcing fails, fall through to
# the inline regex check).
PORTABLE_LIB="$PLUGIN_SCRIPTS/portable.sh"
if [ -f "$PORTABLE_LIB" ]; then
  # shellcheck source=/dev/null
  source "$PORTABLE_LIB" 2>/dev/null || true
fi
if ! [[ "$SESSION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
  if command -v log_validation_event >/dev/null 2>&1; then
    log_validation_event validation_reject session_id "$SESSION_ID" hook=session-bind.sh source="$SOURCE"
  else
    _log_error "session_id validation rejected: '$SESSION_ID'" 0
  fi
  emit_provenance "" "bind_skipped_invalid_session_id" \
    reason="session_id from stdin failed UUID validation" \
    decision="deferred" 2>/dev/null || true
  exit 0
fi

# Fall back to pwd if cwd not in payload
CWD_RAW="${PAYLOAD_CWD:-$(pwd)}"
# Wave 6.1: normalize via realpath (cd && pwd -P) so the hierarchy match
# works for symlinked paths and trailing-slash variants. macOS dev setups
# frequently route through symlinks (~/projects → /Volumes/work/projects)
# and the hooks must compare apples to apples with the registry's
# realpath-canonicalized created_at_cwd values.
if CWD_NORM=$(cd "$CWD_RAW" 2>/dev/null && pwd -P); then
  CWD="$CWD_NORM"
else
  CWD="$CWD_RAW"
fi

# ===== Read registry =====
REGISTRY=$(read_registry "$REGISTRY_PATH" 2>/dev/null) || {
  _log_error "failed to read registry" 1
  exit 0
}

# ===== Iterate loops, find ones whose path hierarchy contains CWD =====
# Match rule (Wave 6 fix for the pending-bind silent failure):
#
#   The session's cwd must lie on the path hierarchy between project_root
#   (inclusive) and contract_dir (inclusive). Equivalently:
#
#         project_root  ⊆  cwd  ⊆  contract_dir
#
#   This binds sessions opened from the project root (the natural workflow)
#   AND sessions opened inside .autoloop/<slug>--<hash>/. The old strict
#   match only handled the latter, leaving project-root-launched sessions
#   stuck at owner_session_id="pending-bind" forever.
#
#   project_root resolves to .created_at_cwd if present (registry SSoT for
#   v2 entries), else derives it on-the-fly from contract_path shape so the
#   fix applies immediately to legacy entries that haven't been backfilled.
#
#   Legacy contracts at <root>/LOOP_CONTRACT.md (no .autoloop/ prefix) keep
#   the old strict semantics — derived_root is null and stored is null, so
#   the fallback "cwd at-or-below contract_dir" predicate runs.
MATCHES=$(echo "$REGISTRY" | jq -c --arg cwd "$CWD" '
  .loops[] |
  select(
    (.contract_path | split("/") | .[:-1] | join("/")) as $contract_dir |
    (.created_at_cwd // "") as $stored_root |
    # On-the-fly derivation: peel ".autoloop/<slug>--<6hex>" off contract_path
    ((.contract_path | capture("^(?<root>.+)/\\.autoloop/[^/]+--[0-9a-f]{6}/CONTRACT\\.md$") | .root) // "") as $derived_root |
    # Corruption guard (Wave 6.1): a stored_root that is NOT a strict
    # ancestor of contract_dir is broken — the historical bug recorded
    # contract_dir itself as created_at_cwd, which makes the hierarchy
    # check collapse to cwd-equals-contract_dir only and re-introduces
    # the pending-bind silent failure. Detect by requiring contract_dir
    # to start with stored_root + "/" (trailing slash excludes equality).
    (if ($stored_root != "") and ($contract_dir | startswith($stored_root + "/"))
     then $stored_root
     else $derived_root
     end) as $project_root |
    if $project_root == "" then
      # Legacy LOOP_CONTRACT.md layout: keep strict cwd-inside-contract-dir match
      ($cwd == $contract_dir) or ($cwd | startswith($contract_dir + "/"))
    else
      # v2 hierarchy check: project_root ⊆ cwd ⊆ contract_dir
      (($cwd == $project_root) or ($cwd | startswith($project_root + "/")))
        and
      (($cwd == $contract_dir) or ($contract_dir | startswith($cwd + "/")))
    end
  )
' 2>/dev/null || echo "")

if [ -z "$MATCHES" ]; then
  # No loops matched the cwd hierarchy. Fine if no loops are registered at
  # all — but if loops EXIST and none matched, that's forensically valuable
  # (Wave 6.1 telemetry gap closure). Without this event, an operator with
  # a session that "should" be binding but isn't has no signal explaining
  # the silence. The event lists the loops that were considered and the
  # cwd that didn't match, so post-mortem can pinpoint the broken entry.
  REG_LOOP_COUNT=$(echo "$REGISTRY" | jq '.loops | length' 2>/dev/null || echo 0)
  if [ "${REG_LOOP_COUNT:-0}" -gt 0 ] && command -v emit_provenance >/dev/null 2>&1; then
    CONSIDERED=$(echo "$REGISTRY" | jq -c '[.loops[] | {loop_id, contract_path, created_at_cwd: (.created_at_cwd // null)}]' 2>/dev/null || echo "[]")
    emit_provenance "" "bind_skipped_no_match" \
      session_id="$SESSION_ID" \
      cwd_observed="$CWD" \
      reason="cwd does not lie in any registered loop's project_root..contract_dir hierarchy; considered=$CONSIDERED; loop_count=$REG_LOOP_COUNT" \
      decision="deferred" 2>/dev/null || true
  fi
  exit 0
fi

# ===== Per-match binding decision =====
#
# Perf (iter-28 python3-now-us-replacement): pre-iter-28 NOW_US used the
# python3 cold-start trick (~30-50ms on macOS for `import time`). state-lib's
# now_us() prefers gdate (~3ms cold start) and falls back to python3 only
# when gdate is missing — so this is strictly faster everywhere gdate exists,
# same speed where it doesn't.
NOW_US=$(now_us 2>/dev/null || echo "0")

while IFS= read -r match; do
  [ -z "$match" ] && continue

  # Perf (iter-28 match-decode-tsv-batch): pre-iter-28 decoded SEVEN fields
  # via seven jq invocations. Each was a ~7-10ms cold-start (~50-70ms per
  # match). With N loops matched (typically 1, sometimes 2), this stacked
  # quickly on SessionStart. One TSV-batched jq + bash `read` = 1 spawn.
  # Same fallback pattern as the iter-26 MATCHING_LOOP decode in
  # heartbeat-tick.sh — if jq dies, empty defaults via `printf` keep `read`
  # happy and OWNER_SID="" cases below handle the empty-fields branch.
  IFS=$'\t' read -r LOOP_ID OWNER_SID OWNER_PID GEN LAST_UPDATED_US CONTRACT_PATH STORED_ROOT <<< "$(
    echo "$match" | jq -r '"\(.loop_id // "")\t\(.owner_session_id // "")\t\(.owner_pid // "")\t\(.generation // 0)\t\(.owner_start_time_us // 0)\t\(.contract_path // "")\t\(.created_at_cwd // "")"' 2>/dev/null \
      || printf '\t\t\t0\t0\t\t'
  )"

  # Wave 6 telemetry: capture path-hierarchy provenance so post-mortem
  # sessions can reconstruct exactly why a bind decision was made.
  PROJECT_ROOT_FOR_LOG="$STORED_ROOT"
  if [ -z "$PROJECT_ROOT_FOR_LOG" ] && [ -n "$CONTRACT_PATH" ]; then
    # Mirror the on-the-fly derivation jq did during MATCHES selection
    case "$CONTRACT_PATH" in
      */.autoloop/*--[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/CONTRACT.md)
        PROJECT_ROOT_FOR_LOG="${CONTRACT_PATH%/.autoloop/*}"
        ;;
    esac
  fi

  [ -z "$LOOP_ID" ] && continue

  case "$OWNER_SID" in
    "" | "unknown" | "unknown-session" | "pending-bind")
      # bind_first: atomic CAS to set owner_session_id
      if update_loop_field "$LOOP_ID" ".owner_session_id" "\"$SESSION_ID\"" 2>/dev/null; then
        update_loop_field "$LOOP_ID" ".owner_start_time_us" "\"$NOW_US\"" 2>/dev/null || true
        # v2: mirror owner_session_id + created_in_session into contract frontmatter
        # (best-effort; registry is SSoT).
        #
        # Perf (iter-28 batched-frontmatter-rewrite-single-awk-pass): pre-iter-28
        # this block did 2-3 sequential set_contract_field calls; now collapsed
        # into one set_contract_frontmatter_field_batch call (1 awk + 1 mktemp +
        # 1 mv). The created_in_session line still requires a peek-first read
        # (only set when pending-bind placeholder present) — done via a single
        # awk read that exits as soon as the field is found, so it's cheap.
        if [ -n "$CONTRACT_PATH" ] && [ -f "$CONTRACT_PATH" ] && command -v set_contract_frontmatter_field_batch >/dev/null 2>&1; then
          local_created_session=$(awk '
            NR == 1 && /^---/ { in_fm = 1; next }
            in_fm && /^---/ { exit }
            in_fm && /^created_in_session:/ { sub(/^created_in_session:[[:space:]]*/, ""); print; exit }
          ' "$CONTRACT_PATH" 2>/dev/null)
          case "$local_created_session" in
            ""|"<bound by session-bind hook on first SessionStart>"|"\"\""|"pending-bind")
              # All 3 fields in one batched rewrite.
              set_contract_frontmatter_field_batch "$CONTRACT_PATH" \
                "owner_session_id" "\"$SESSION_ID\"" \
                "owner_started_us" "$NOW_US" \
                "created_in_session" "\"$SESSION_ID\"" \
                2>/dev/null || true
              ;;
            *)
              # created_in_session already set to a real value; preserve it.
              set_contract_frontmatter_field_batch "$CONTRACT_PATH" \
                "owner_session_id" "\"$SESSION_ID\"" \
                "owner_started_us" "$NOW_US" \
                2>/dev/null || true
              ;;
          esac
        fi
        emit_provenance "$LOOP_ID" "bind_first" \
          session_id="$SESSION_ID" \
          cwd_observed="$CWD" \
          cwd_bound="$CWD" \
          project_root="$PROJECT_ROOT_FOR_LOG" \
          project_root_source="$([ -n "$STORED_ROOT" ] && echo "registry" || echo "derived_from_path")" \
          registry_generation="$GEN" \
          reason="prior owner_session_id=$OWNER_SID; bound on SessionStart source=$SOURCE; cwd lies in project_root..contract_dir hierarchy" \
          decision="proceeded"
      else
        emit_provenance "$LOOP_ID" "bind_failed_cas" \
          session_id="$SESSION_ID" \
          cwd_observed="$CWD" \
          reason="update_loop_field returned non-zero" \
          decision="refused"
      fi
      ;;

    "$SESSION_ID")
      # bind_resume: same session, idempotent
      emit_provenance "$LOOP_ID" "bind_resume" \
        session_id="$SESSION_ID" \
        cwd_observed="$CWD" \
        registry_generation="$GEN" \
        reason="SessionStart source=$SOURCE; same session re-binds" \
        decision="proceeded"
      ;;

    *)
      # Different session owns this loop — check liveness.
      # Wave 6.3: split the prior single `observer` event into
      # `observer_active` (other owner is alive — we're a passive co-tenant)
      # and `observer_dead_grace` (other owner is dead but we're still inside
      # the race-window threshold and waiting). Post-mortem queries couldn't
      # previously tell these apart from the event name alone.
      if [ -n "$OWNER_PID" ] && kill -0 "$OWNER_PID" 2>/dev/null; then
        # Other owner alive → this session is an observer
        emit_provenance "$LOOP_ID" "observer_active" \
          session_id="$SESSION_ID" \
          cwd_observed="$CWD" \
          registry_generation="$GEN" \
          reason="loop owned by live session $OWNER_SID (pid $OWNER_PID); this session is an observer" \
          decision="refused"
      else
        # Other owner dead — only mark stale if last_updated is old enough
        AGE_S=$(((NOW_US - LAST_UPDATED_US) / 1000000))
        if [ "$AGE_S" -gt "$STALE_OWNER_THRESHOLD_S" ]; then
          emit_provenance "$LOOP_ID" "stale_owner_detected" \
            session_id="$SESSION_ID" \
            cwd_observed="$CWD" \
            registry_generation="$GEN" \
            owner_pid_before="$OWNER_PID" \
            reason="prior owner $OWNER_SID (pid $OWNER_PID) dead; last_updated ${AGE_S}s ago; auto-reclaim NOT performed (use /autoloop:reclaim or tinker --fix)" \
            decision="deferred"
        else
          # Recent dead-owner — log but don't act (race window)
          emit_provenance "$LOOP_ID" "observer_dead_grace" \
            session_id="$SESSION_ID" \
            cwd_observed="$CWD" \
            registry_generation="$GEN" \
            reason="prior owner $OWNER_SID (pid $OWNER_PID) dead but last_updated only ${AGE_S}s ago (<threshold ${STALE_OWNER_THRESHOLD_S}s); waiting" \
            decision="deferred"
        fi
      fi
      ;;
  esac
done <<< "$MATCHES"

# DOC-03: idempotent self-healing on SessionStart. heal-self.sh is gated by
# registry content-hash so it does nothing when the registry is unchanged.
HEAL_SCRIPT="$PLUGIN_SCRIPTS/heal-self.sh"
if [ -x "$HEAL_SCRIPT" ]; then
  "$HEAL_SCRIPT" 2>/dev/null || true
fi

exit 0
