#!/usr/bin/env bash
# PROCESS-STORM-OK
# FILE-SIZE-OK — cohesive hook install/uninstall surface; splitting would create
# artificial separation between PostToolUse and SessionStart installers that
# share lock primitive and atomic-rename plumbing.
#
# hook-install-lib.sh — Idempotent, concurrency-safe hook install/uninstall for settings.json
# Provides install_hook (now installs BOTH PostToolUse heartbeat-tick.sh AND
# SessionStart session-bind.sh as of v4.10.0 Phase 36), uninstall_hook, and
# is_hook_installed helpers. Uses flock/lockf on fd 7 (~/.claude/.settings.lock)
# for safe concurrent access.

set -euo pipefail

# iter-29 BSD-portable mktemp pattern (applies to all 8 atomic-rename sites
# in this file):
#
# Every `temp_file=$(mktemp "$claude_dir/settings.XXXXXX")` below uses the
# BSD+GNU portable mktemp form (X's at END of template, no trailing suffix).
# Pre-iter-29 the broken `mktemp -p "$claude_dir" settings.XXXXXX.json`
# form produced LITERAL `settings.XXXXXX.json` filenames on macOS BSD
# (bsdmktemp only expands X's at the very end of the template). Concurrent
# install/uninstall would silently overwrite each other's tempfiles. The
# fixed form drops the `.json` intermediate extension — irrelevant because
# the tempfile is immediately mv'd to `settings.json` (atomic rename within
# the same directory; POSIX guarantees rename(2) atomicity). See
# portable.sh::mktemp_for_atomic_rename for the full rationale.

# _capture_jq_stderr_to <var-name>
# Internal: allocate a tempfile for jq stderr capture and assign its path
# to the named variable. Caller is responsible for `rm -f "$<var>"` on
# every exit path.
#
# Wave 6.6: introduced after a real-world incident where every install_*_impl
# function silenced jq's stderr with `2>/dev/null` and surfaced only a
# generic "jq update failed" message. The user could not diagnose what
# actually broke (malformed input, special character in $hook_path, etc.).
# Replacing the silent redirect with a tempfile-capture pattern keeps
# atomicity (no shared global) and makes the real jq diagnostic surface to
# the operator on the failing path.
#
# Usage:
#   local jq_err
#   _capture_jq_stderr_to jq_err "$claude_dir"
#   new_settings=$(jq '...' "$path" 2>"$jq_err") || {
#     local err_msg
#     err_msg=$(cat "$jq_err" 2>/dev/null || echo "(stderr capture failed)")
#     rm -f "$jq_err"
#     echo "ERROR: ...: jq failed: ${err_msg:-(no stderr)}" >&2
#     return 1
#   }
#   rm -f "$jq_err"
_capture_jq_stderr_to() {
  local _var_name="$1"
  local _claude_dir="${2:-${TMPDIR:-/tmp}}"
  local _path
  if ! _path=$(mktemp "${_claude_dir}/jq-stderr.XXXXXX" 2>/dev/null); then
    _path=$(mktemp "${TMPDIR:-/tmp}/jq-stderr.XXXXXX" 2>/dev/null) || _path="${TMPDIR:-/tmp}/jq-stderr.$$"
  fi
  printf -v "$_var_name" '%s' "$_path"
}

# is_hook_installed [settings_path]
# Checks if our heartbeat hook is already installed in settings.json.
#
# Arguments:
#   $1 (optional): Override path to settings.json (for testing); defaults to ~/.claude/settings.json
#
# Output:
#   "yes" or "no" to stdout
#
# Exit code:
#   0 always (fail-graceful)
#
# Example:
#   if [ "$(is_hook_installed)" = "yes" ]; then echo "Hook installed"; fi
is_hook_installed() {
  local settings_path="${1:-$HOME/.claude/settings.json}"

  # If settings file doesn't exist, hook is not installed
  if [ ! -f "$settings_path" ]; then
    echo "no"
    return 0
  fi

  # Parse settings.json and check if our hook path is present
  # Our hook is identified by command path ending with /plugins/autoloop/hooks/heartbeat-tick.sh
  local installed
  installed=$(jq -r '
    .hooks.PostToolUse[]?.hooks[]? |
    select(.type == "command" and (.command | endswith("/plugins/autoloop/hooks/heartbeat-tick.sh"))) |
    .command
  ' "$settings_path" 2>/dev/null || echo "") || true

  if [ -n "$installed" ]; then
    echo "yes"
  else
    echo "no"
  fi
}

# hook_path_default
# Resolves the default path to our hook from this script's location.
# Returns the absolute path to heartbeat-tick.sh.
#
# Output:
#   Absolute path to heartbeat-tick.sh
#
# Exit code:
#   0 on success
#   1 if script directory cannot be determined
# _resolve_autoloop_plugin_root
# Returns the absolute path to the autoloop plugin directory using a robust
# precedence chain that survives non-bash parent shells (zsh-launched
# `bash -c`, where BASH_SOURCE may be empty), `bash -c` invocations that
# don't set BASH_SOURCE properly, and direct calls outside the source chain.
#
# Precedence (first match wins):
#   1. $AUTOLOOP_PLUGIN_ROOT — explicit override (used by tests and tinker)
#   2. $CLAUDE_PLUGIN_ROOT — set by Claude Code when invoking plugin skills
#   3. ${BASH_SOURCE[0]} — works when sourced from bash; previous behavior
#   4. $HOME/.claude/plugins/marketplaces/cc-skills/plugins/autoloop — fallback
#
# Output:
#   Absolute path to plugin root on stdout
#
# Exit code:
#   0 on success
#   1 if no candidate path resolves to a real directory containing /scripts/
#
# WHY this exists: the prior `hook_path_default*` family relied solely on
# BASH_SOURCE[0]. When a zsh user does `bash -c 'source .../hook-install-lib.sh
# && install_all_hooks'`, BASH_SOURCE inside the function call is empty and
# the path resolution silently joins to $PWD, producing a wrong heartbeat-hook
# path. Fix: use multiple candidate sources with explicit precedence.
_resolve_autoloop_plugin_root() {
  local candidate=""

  # 1. Explicit override
  if [ -n "${AUTOLOOP_PLUGIN_ROOT:-}" ] && [ -d "${AUTOLOOP_PLUGIN_ROOT}/scripts" ]; then
    echo "$AUTOLOOP_PLUGIN_ROOT"
    return 0
  fi

  # 2. CLAUDE_PLUGIN_ROOT (set by Claude Code skill invocation)
  if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
    case "${CLAUDE_PLUGIN_ROOT}" in
      */autoloop)
        echo "$CLAUDE_PLUGIN_ROOT"
        return 0
        ;;
    esac
  fi

  # 3. BASH_SOURCE[0] — original behavior, works when sourced from bash
  if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
    candidate="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || candidate=""
    if [ -n "$candidate" ]; then
      candidate="$(dirname "$candidate")"
      if [ -d "${candidate}/scripts" ] && [ -d "${candidate}/hooks" ]; then
        echo "$candidate"
        return 0
      fi
    fi
  fi

  # 4. Marketplace fallback
  local marketplace_root="$HOME/.claude/plugins/marketplaces/cc-skills/plugins/autoloop"
  if [ -d "${marketplace_root}/scripts" ] && [ -d "${marketplace_root}/hooks" ]; then
    echo "$marketplace_root"
    return 0
  fi

  echo "ERROR: _resolve_autoloop_plugin_root: cannot resolve plugin root" >&2
  echo "       Tried: AUTOLOOP_PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, BASH_SOURCE[0], $marketplace_root" >&2
  return 1
}

hook_path_default() {
  local plugin_dir
  plugin_dir="$(_resolve_autoloop_plugin_root)" || return 1

  local hook_path
  hook_path="$plugin_dir/hooks/heartbeat-tick.sh"

  if [ ! -f "$hook_path" ]; then
    echo "ERROR: hook_path_default: heartbeat hook not found at $hook_path" >&2
    return 1
  fi

  echo "$hook_path"
}

# hook_path_default_session_bind
# Returns the absolute path to session-bind.sh (SessionStart hook).
hook_path_default_session_bind() {
  local plugin_dir
  plugin_dir="$(_resolve_autoloop_plugin_root)" || return 1

  local hook_path
  hook_path="$plugin_dir/hooks/session-bind.sh"

  if [ ! -f "$hook_path" ]; then
    echo "ERROR: hook_path_default_session_bind: session-bind hook not found at $hook_path" >&2
    return 1
  fi

  echo "$hook_path"
}

# is_session_bind_installed [settings_path]
# Checks if our session-bind hook is registered in settings.json under SessionStart.
is_session_bind_installed() {
  local settings_path="${1:-$HOME/.claude/settings.json}"

  if [ ! -f "$settings_path" ]; then
    echo "no"
    return 0
  fi

  local installed
  installed=$(jq -r '
    .hooks.SessionStart[]?.hooks[]? |
    select(.type == "command" and (.command | endswith("/plugins/autoloop/hooks/session-bind.sh"))) |
    .command
  ' "$settings_path" 2>/dev/null || echo "") || true

  if [ -n "$installed" ]; then
    echo "yes"
  else
    echo "no"
  fi
}

# _with_settings_lock <fn> [args...]
# Internal: wraps settings.json mutations in file-locking serialization.
# Acquires exclusive lock on ~/.claude/.settings.lock (using lockf on macOS, flock on Linux),
# calls fn with args, releases lock.
#
# Arguments:
#   $1: Function name to invoke
#   ${@:2}: Args to pass to fn
#
# Exit code:
#   0 on success
#   1 if lock contention, temp write error, or fn exit code != 0
#
# Example:
#   _with_settings_lock install_hook_impl "$settings_path" "$hook_path"
_with_settings_lock() {
  local fn="$1"
  shift

  # Ensure ~/.claude/ directory exists
  local claude_dir="$HOME/.claude"
  if [ ! -d "$claude_dir" ]; then
    mkdir -p "$claude_dir" || {
      echo "ERROR: _with_settings_lock: failed to create $claude_dir" >&2
      return 1
    }
  fi

  local lock_file="$claude_dir/.settings.lock"

  # Clean up lock file on exit
  trap 'exec 7>&- 2>/dev/null || true' EXIT

  # Create lock file if it doesn't exist
  touch "$lock_file" || {
    echo "ERROR: _with_settings_lock: failed to create lock file" >&2
    return 1
  }

  # Acquire exclusive lock using appropriate tool
  # macOS: lockf (POSIX); Linux: flock (GNU)
  if command -v flock >/dev/null 2>&1; then
    # Linux: flock with fd 7
    exec 7>"$lock_file" || {
      echo "ERROR: _with_settings_lock: failed to open fd 7" >&2
      return 1
    }
    if ! flock --wait 5 -x 7; then
      echo "ERROR: _with_settings_lock: lock contention; another writer is active" >&2
      exec 7>&-
      return 1
    fi
  elif command -v lockf >/dev/null 2>&1; then
    # macOS: lockf with retry loop (non-blocking mode with polling)
    exec 7>"$lock_file" || {
      echo "ERROR: _with_settings_lock: failed to open fd 7" >&2
      return 1
    }
    local retries=50  # ~5 seconds with 100ms sleeps
    while ! lockf -t 0 "$lock_file" true 2>/dev/null; do
      retries=$((retries - 1))
      if [ $retries -le 0 ]; then
        echo "ERROR: _with_settings_lock: lock contention; another writer is active" >&2
        exec 7>&-
        return 1
      fi
      sleep 0.1
    done
  else
    echo "ERROR: _with_settings_lock: neither flock nor lockf found; cannot acquire lock" >&2
    return 1
  fi

  # Call fn with args. Wave 5 A3: dropped a stray `2>&1` here so errors from
  # the impl function reach stderr (where callers expect them) instead of
  # being merged into stdout — that merge silently broke `ERR=$(... 2>&1
  # 1>/dev/null)` capture in the test harness and any other caller that
  # separates streams.
  if ! "$fn" "$@"; then
    return 1
  fi

  # Lock is released by trap on EXIT
  return 0
}

# install_hook_impl <settings_path> <hook_path>
# Implementation function: installs hook into settings.json.
# Creates or updates settings.json; backs up original on first install.
#
# Arguments:
#   $1: Path to settings.json
#   $2: Absolute path to heartbeat-tick.sh
#
# Exit code:
#   0 on success (installed or already present)
#   1 on error
install_hook_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  # Wave 5 A3: ensure ~/.claude/ exists before any read/write. On a true
  # fresh install (user has never run claude-code or has just removed
  # ~/.claude entirely), the parent dir is missing and mktemp -p later
  # would fail with a confusing "no such file" error.
  if [ ! -d "$claude_dir" ]; then
    mkdir -p "$claude_dir" || {
      echo "ERROR: install_hook_impl: cannot create $claude_dir" >&2
      return 1
    }
  fi

  # Check if already installed (idempotent)
  if [ -f "$settings_path" ]; then
    # Check if our hook is already there
    local installed
    installed=$(jq -r '
      .hooks.PostToolUse[]?.hooks[]? |
      select(.type == "command" and (.command == "'"$hook_path"'")) |
      .command
    ' "$settings_path" 2>/dev/null || echo "") || true

    if [ -n "$installed" ]; then
      echo "Hook already installed at $hook_path; no-op" >&2
      return 0
    fi

    # Validate JSON before modifying
    if ! jq . "$settings_path" >/dev/null 2>&1; then
      echo "ERROR: install_hook_impl: settings.json at $settings_path is malformed JSON" >&2
      echo "       Inspect:  jq . $settings_path" >&2
      echo "       Recover:  fix the JSON manually, or back up + remove and re-run" >&2
      return 1
    fi

    # Wave 5 A3: validate schema shape. `.hooks` must be an object (or absent),
    # and `.hooks.PostToolUse` must be an array (or absent). If either is the
    # wrong type — e.g. someone hand-edited `.hooks: "command"` — the jq update
    # below would silently produce garbage. Catch it with a clear error.
    local hooks_type post_type
    hooks_type=$(jq -r '.hooks | type' "$settings_path" 2>/dev/null)
    post_type=$(jq -r '.hooks.PostToolUse | type' "$settings_path" 2>/dev/null)
    if [ "$hooks_type" != "object" ] && [ "$hooks_type" != "null" ]; then
      echo "ERROR: install_hook_impl: settings.json has .hooks of type '$hooks_type' (expected object)" >&2
      echo "       Fix manually so .hooks is an object: jq '.hooks //= {}' $settings_path" >&2
      return 1
    fi
    if [ "$post_type" != "array" ] && [ "$post_type" != "null" ]; then
      echo "ERROR: install_hook_impl: settings.json has .hooks.PostToolUse of type '$post_type' (expected array)" >&2
      return 1
    fi

    # Backup original (first install detection)
    local backup_file
    backup_file="$claude_dir/.settings.backup.$(date +%s).json"
    cp "$settings_path" "$backup_file" || {
      echo "ERROR: install_hook_impl: failed to create backup at $backup_file" >&2
      return 1
    }
  fi

  # Create or update settings.json
  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || {
    echo "ERROR: install_hook_impl: mktemp failed" >&2
    return 1
  }

  # Read existing or create new
  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  if [ -f "$settings_path" ]; then
    # Add hook to existing PostToolUse array
    new_settings=$(jq '
      .hooks.PostToolUse //= [
        { "matcher": "*", "hooks": [] }
      ] |
      (
        if (.hooks.PostToolUse | length) == 0 then
          .hooks.PostToolUse += [{ "matcher": "*", "hooks": [] }]
        else
          .
        end
      ) |
      .hooks.PostToolUse[0].hooks += [
        {
          "type": "command",
          "command": "'"$hook_path"'"
        }
      ]
    ' "$settings_path" 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_hook_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path:     $hook_path" >&2
      echo "       settings_path: $settings_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  else
    # Create new settings.json with just our hook
    new_settings=$(jq -n '
      {
        "hooks": {
          "PostToolUse": [
            {
              "matcher": "*",
              "hooks": [
                {
                  "type": "command",
                  "command": "'"$hook_path"'"
                }
              ]
            }
          ]
        }
      }
    ' 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_hook_impl: jq creation failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path: $hook_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  fi

  # Validate JSON before write
  if ! echo "$new_settings" | jq . >/dev/null 2>&1; then
    echo "ERROR: install_hook_impl: generated invalid JSON" >&2
    rm -f "$temp_file"
    return 1
  fi

  # Write to temp file
  if ! echo "$new_settings" > "$temp_file"; then
    echo "ERROR: install_hook_impl: failed to write temp file" >&2
    rm -f "$temp_file"
    return 1
  fi

  # Sync to disk
  if command -v fsync >/dev/null 2>&1; then
    fsync "$temp_file" || true
  else
    sync || true
  fi

  # Atomic rename
  if ! mv "$temp_file" "$settings_path"; then
    echo "ERROR: install_hook_impl: atomic rename failed" >&2
    rm -f "$temp_file"
    return 1
  fi

  echo "Hook installed successfully at $settings_path" >&2
  return 0
}

# install_hook [settings_path] [hook_path]
# Public: idempotent install of heartbeat hook into settings.json.
# On first install, backs up original to .settings.backup.<ts>.json.
# Subsequent installs are no-ops.
#
# Arguments:
#   $1 (optional): Override path to settings.json (for testing); defaults to ~/.claude/settings.json
#   $2 (optional): Override path to heartbeat-tick.sh (for testing); defaults to plugin's hook dir
#
# Exit code:
#   0 on success (installed or already present)
#   1 on error
#
# Example:
#   install_hook  # Uses defaults
#   install_hook "/tmp/settings.json" "/path/to/heartbeat-tick.sh"
install_hook() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"

  # Resolve default hook path if not provided
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default) || return 1
  fi

  # Validate hook_path exists
  if [ ! -f "$hook_path" ]; then
    echo "ERROR: install_hook: heartbeat hook not found at $hook_path" >&2
    return 1
  fi

  # Wave 5 A3: ensure parent dir of settings_path exists before the path
  # canonicalization below. On a truly fresh install (or when callers pass a
  # path under a freshly-mktemp'd dir), the cd would silently fall through
  # and produce a malformed absolute path.
  local settings_dir
  settings_dir=$(dirname "$settings_path")
  if [ ! -d "$settings_dir" ]; then
    mkdir -p "$settings_dir" || {
      echo "ERROR: install_hook: cannot create $settings_dir" >&2
      return 1
    }
  fi

  # Convert paths to absolute
  settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")

  # Call with lock
  _with_settings_lock install_hook_impl "$settings_path" "$hook_path" || return 1
}

# uninstall_hook_impl <settings_path> <hook_path>
# Implementation function: removes hook from settings.json.
# Idempotent: no error if hook not present.
#
# Arguments:
#   $1: Path to settings.json
#   $2: Absolute path to heartbeat-tick.sh
#
# Exit code:
#   0 always (idempotent)
uninstall_hook_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  # If settings file doesn't exist, nothing to uninstall
  if [ ! -f "$settings_path" ]; then
    echo "Settings.json not found; nothing to uninstall" >&2
    return 0
  fi

  # Validate JSON before modifying
  if ! jq . "$settings_path" >/dev/null 2>&1; then
    echo "ERROR: uninstall_hook_impl: settings.json at $settings_path is malformed JSON" >&2
    return 1
  fi

  # Remove our hook from PostToolUse (idempotent: no error if not found)
  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  new_settings=$(jq '
    .hooks.PostToolUse[]?.hooks |= map(
      select(
        (.type != "command" or .command != "'"$hook_path"'")
      )
    )
  ' "$settings_path" 2>"$jq_stderr_file") || {
    local jq_err_msg
    jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
    rm -f "$jq_stderr_file"
    echo "ERROR: uninstall_hook_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
    echo "       hook_path:     $hook_path" >&2
    echo "       settings_path: $settings_path" >&2
    return 1
  }
  rm -f "$jq_stderr_file"

  # Validate JSON before write
  if ! echo "$new_settings" | jq . >/dev/null 2>&1; then
    echo "ERROR: uninstall_hook_impl: generated invalid JSON" >&2
    return 1
  fi

  # Create temp file
  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || {
    echo "ERROR: uninstall_hook_impl: mktemp failed" >&2
    return 1
  }

  # Write to temp file
  if ! echo "$new_settings" > "$temp_file"; then
    echo "ERROR: uninstall_hook_impl: failed to write temp file" >&2
    rm -f "$temp_file"
    return 1
  fi

  # Sync to disk
  if command -v fsync >/dev/null 2>&1; then
    fsync "$temp_file" || true
  else
    sync || true
  fi

  # Atomic rename
  if ! mv "$temp_file" "$settings_path"; then
    echo "ERROR: uninstall_hook_impl: atomic rename failed" >&2
    rm -f "$temp_file"
    return 1
  fi

  echo "Hook uninstalled successfully from $settings_path" >&2
  return 0
}

# uninstall_hook [settings_path] [hook_path]
# Public: idempotent removal of heartbeat hook from settings.json.
# Leaves other PostToolUse entries intact.
#
# Arguments:
#   $1 (optional): Override path to settings.json (for testing); defaults to ~/.claude/settings.json
#   $2 (optional): Override path to heartbeat-tick.sh (for testing); defaults to plugin's hook dir
#
# Exit code:
#   0 always (idempotent; no error if hook not present)
#   1 on error
#
# Example:
#   uninstall_hook  # Uses defaults
#   uninstall_hook "/tmp/settings.json" "/path/to/heartbeat-tick.sh"
uninstall_hook() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"

  # Resolve default hook path if not provided
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default) || return 1
  fi

  # Convert paths to absolute
  if [ -f "$settings_path" ]; then
    settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  else
    # Path doesn't exist yet; still convert to absolute for consistency
    settings_path=$(cd "$(dirname "$(pwd)/$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  fi

  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")

  # Call with lock
  _with_settings_lock uninstall_hook_impl "$settings_path" "$hook_path" || return 1
}

# PROCESS-STORM-OK
# ===== SessionStart hook (session-bind.sh) install/uninstall =====
# v4.10.0 Phase 36 (BIND-01): mirrors PostToolUse installer but writes to
# .hooks.SessionStart and points at session-bind.sh. Each function is a plain
# bash function definition; no subshell or background expansion involved.

install_session_bind_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  if [ -f "$settings_path" ]; then
    local installed
    installed=$(jq -r '
      .hooks.SessionStart[]?.hooks[]? |
      select(.type == "command" and (.command == "'"$hook_path"'")) |
      .command
    ' "$settings_path" 2>/dev/null || echo "") || true

    if [ -n "$installed" ]; then
      echo "SessionStart hook already installed at $hook_path; no-op" >&2
      return 0
    fi

    if ! jq . "$settings_path" >/dev/null 2>&1; then
      echo "ERROR: install_session_bind_impl: settings.json malformed" >&2
      return 1
    fi
  fi

  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || {
    echo "ERROR: install_session_bind_impl: mktemp failed" >&2
    return 1
  }

  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  if [ -f "$settings_path" ]; then
    new_settings=$(jq '
      .hooks.SessionStart //= [
        { "matcher": "*", "hooks": [] }
      ] |
      (
        if (.hooks.SessionStart | length) == 0 then
          .hooks.SessionStart += [{ "matcher": "*", "hooks": [] }]
        else
          .
        end
      ) |
      .hooks.SessionStart[0].hooks += [
        {
          "type": "command",
          "command": "'"$hook_path"'"
        }
      ]
    ' "$settings_path" 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_session_bind_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path:     $hook_path" >&2
      echo "       settings_path: $settings_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  else
    new_settings=$(jq -n '
      {
        "hooks": {
          "SessionStart": [
            {
              "matcher": "*",
              "hooks": [
                {
                  "type": "command",
                  "command": "'"$hook_path"'"
                }
              ]
            }
          ]
        }
      }
    ' 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_session_bind_impl: jq creation failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path: $hook_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  fi

  if ! echo "$new_settings" | jq . >/dev/null 2>&1; then
    echo "ERROR: install_session_bind_impl: generated invalid JSON" >&2
    rm -f "$temp_file"
    return 1
  fi

  if ! echo "$new_settings" >"$temp_file"; then
    echo "ERROR: install_session_bind_impl: write failed" >&2
    rm -f "$temp_file"
    return 1
  fi

  sync || true

  if ! mv "$temp_file" "$settings_path"; then
    echo "ERROR: install_session_bind_impl: atomic rename failed" >&2
    rm -f "$temp_file"
    return 1
  fi

  echo "SessionStart hook installed successfully at $settings_path" >&2
  return 0
}

uninstall_session_bind_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  if [ ! -f "$settings_path" ]; then
    return 0
  fi

  if ! jq . "$settings_path" >/dev/null 2>&1; then
    echo "ERROR: uninstall_session_bind_impl: settings.json malformed" >&2
    return 1
  fi

  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  new_settings=$(jq '
    .hooks.SessionStart[]?.hooks |= map(
      select(
        (.type != "command" or .command != "'"$hook_path"'")
      )
    )
  ' "$settings_path" 2>"$jq_stderr_file") || {
    local jq_err_msg
    jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
    rm -f "$jq_stderr_file"
    echo "ERROR: uninstall_session_bind_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
    echo "       hook_path:     $hook_path" >&2
    echo "       settings_path: $settings_path" >&2
    return 1
  }
  rm -f "$jq_stderr_file"

  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || return 1

  if ! echo "$new_settings" >"$temp_file"; then
    rm -f "$temp_file"
    return 1
  fi
  sync || true
  mv "$temp_file" "$settings_path" || {
    rm -f "$temp_file"
    return 1
  }
  return 0
}

install_session_bind() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"

  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_session_bind) || return 1
  fi

  if [ ! -f "$hook_path" ]; then
    echo "ERROR: install_session_bind: session-bind hook not found at $hook_path" >&2
    return 1
  fi

  settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")

  _with_settings_lock install_session_bind_impl "$settings_path" "$hook_path" || return 1
}

uninstall_session_bind() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"

  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_session_bind) || return 1
  fi

  if [ -f "$settings_path" ]; then
    settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  fi
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")

  _with_settings_lock uninstall_session_bind_impl "$settings_path" "$hook_path" || return 1
}

# PROCESS-STORM-OK
# ===== PreToolUse pacing-veto hook (v16.6.1: anti-pacing enforcement) =====

hook_path_default_pacing_veto() {
  local plugin_dir
  plugin_dir="$(_resolve_autoloop_plugin_root)" || return 1
  local hook_path="$plugin_dir/hooks/pacing-veto.sh"
  if [ ! -f "$hook_path" ]; then
    echo "ERROR: hook_path_default_pacing_veto: pacing-veto hook not found at $hook_path" >&2
    return 1
  fi
  echo "$hook_path"
}

is_pacing_veto_installed() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  if [ ! -f "$settings_path" ]; then
    echo "no"
    return 0
  fi
  local installed
  installed=$(jq -r '
    .hooks.PreToolUse[]?.hooks[]? |
    select(.type == "command" and (.command | endswith("/plugins/autoloop/hooks/pacing-veto.sh"))) |
    .command
  ' "$settings_path" 2>/dev/null || echo "") || true
  if [ -n "$installed" ]; then echo "yes"; else echo "no"; fi
}

install_pacing_veto_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  if [ -f "$settings_path" ]; then
    local installed
    installed=$(jq -r '
      .hooks.PreToolUse[]?.hooks[]? |
      select(.type == "command" and (.command == "'"$hook_path"'")) |
      .command
    ' "$settings_path" 2>/dev/null || echo "") || true
    if [ -n "$installed" ]; then
      echo "PreToolUse pacing-veto already installed at $hook_path; no-op" >&2
      return 0
    fi
    if ! jq . "$settings_path" >/dev/null 2>&1; then
      echo "ERROR: install_pacing_veto_impl: settings.json malformed" >&2
      return 1
    fi
  fi

  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || return 1

  # Matcher restricted to ScheduleWakeup tool — cheaper than running on every tool call.
  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  if [ -f "$settings_path" ]; then
    new_settings=$(jq '
      .hooks.PreToolUse //= [] |
      .hooks.PreToolUse += [{
        "matcher": "ScheduleWakeup",
        "hooks": [
          { "type": "command", "command": "'"$hook_path"'" }
        ]
      }]
    ' "$settings_path" 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_pacing_veto_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path:     $hook_path" >&2
      echo "       settings_path: $settings_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  else
    new_settings=$(jq -n '
      {
        "hooks": {
          "PreToolUse": [
            {
              "matcher": "ScheduleWakeup",
              "hooks": [
                { "type": "command", "command": "'"$hook_path"'" }
              ]
            }
          ]
        }
      }
    ' 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_pacing_veto_impl: jq creation failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path: $hook_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  fi

  if ! echo "$new_settings" | jq . >/dev/null 2>&1; then
    rm -f "$temp_file"
    return 1
  fi
  if ! echo "$new_settings" >"$temp_file"; then
    rm -f "$temp_file"
    return 1
  fi
  sync || true
  if ! mv "$temp_file" "$settings_path"; then
    rm -f "$temp_file"
    return 1
  fi
  echo "PreToolUse pacing-veto hook installed successfully at $settings_path" >&2
  return 0
}

uninstall_pacing_veto_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1
  [ ! -f "$settings_path" ] && return 0
  if ! jq . "$settings_path" >/dev/null 2>&1; then
    return 1
  fi
  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  new_settings=$(jq '
    .hooks.PreToolUse[]?.hooks |= map(
      select((.type != "command" or .command != "'"$hook_path"'"))
    ) |
    .hooks.PreToolUse = ((.hooks.PreToolUse // []) | map(select((.hooks // []) | length > 0)))
  ' "$settings_path" 2>"$jq_stderr_file") || {
    local jq_err_msg
    jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
    rm -f "$jq_stderr_file"
    echo "ERROR: uninstall_pacing_veto_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
    echo "       hook_path:     $hook_path" >&2
    echo "       settings_path: $settings_path" >&2
    return 1
  }
  rm -f "$jq_stderr_file"
  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || return 1
  if ! echo "$new_settings" >"$temp_file"; then
    rm -f "$temp_file"
    return 1
  fi
  sync || true
  mv "$temp_file" "$settings_path" || { rm -f "$temp_file"; return 1; }
  return 0
}

install_pacing_veto() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_pacing_veto) || return 1
  fi
  if [ ! -f "$hook_path" ]; then
    return 1
  fi
  settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")
  _with_settings_lock install_pacing_veto_impl "$settings_path" "$hook_path" || return 1
}

uninstall_pacing_veto() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_pacing_veto) || return 1
  fi
  if [ -f "$settings_path" ]; then
    settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  fi
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")
  _with_settings_lock uninstall_pacing_veto_impl "$settings_path" "$hook_path" || return 1
}

# PROCESS-STORM-OK
# ===== Empty-Firing-Detector hook (Stop event) — v16.8.0 =====
# Mirrors the SessionStart installer pattern but writes to .hooks.Stop.
# Detects sessions that ended with only ScheduleWakeup and no real work.

hook_path_default_empty_firing() {
  local plugin_dir
  plugin_dir="$(_resolve_autoloop_plugin_root)" || return 1
  local hook_path="$plugin_dir/hooks/empty-firing-detector.sh"
  if [ ! -f "$hook_path" ]; then
    echo "ERROR: hook_path_default_empty_firing: hook not found at $hook_path" >&2
    return 1
  fi
  echo "$hook_path"
}

is_empty_firing_installed() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  if [ ! -f "$settings_path" ]; then echo "no"; return 0; fi
  local installed
  installed=$(jq -r '
    .hooks.Stop[]?.hooks[]? |
    select(.type == "command" and (.command | endswith("/plugins/autoloop/hooks/empty-firing-detector.sh"))) |
    .command
  ' "$settings_path" 2>/dev/null || echo "") || true
  if [ -n "$installed" ]; then echo "yes"; else echo "no"; fi
}

install_empty_firing_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  if [ -f "$settings_path" ]; then
    local installed
    installed=$(jq -r '
      .hooks.Stop[]?.hooks[]? |
      select(.type == "command" and (.command == "'"$hook_path"'")) |
      .command
    ' "$settings_path" 2>/dev/null || echo "") || true
    if [ -n "$installed" ]; then
      echo "Stop hook already installed at $hook_path; no-op" >&2
      return 0
    fi
    if ! jq . "$settings_path" >/dev/null 2>&1; then
      echo "ERROR: install_empty_firing_impl: settings.json malformed" >&2
      return 1
    fi
  fi

  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || return 1

  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  if [ -f "$settings_path" ]; then
    new_settings=$(jq '
      .hooks.Stop //= [{ "matcher": "*", "hooks": [] }] |
      ( if (.hooks.Stop | length) == 0 then .hooks.Stop += [{ "matcher": "*", "hooks": [] }] else . end ) |
      .hooks.Stop[0].hooks += [{ "type": "command", "command": "'"$hook_path"'" }]
    ' "$settings_path" 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_empty_firing_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path:     $hook_path" >&2
      echo "       settings_path: $settings_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  else
    new_settings=$(jq -n '{
      "hooks": {
        "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "'"$hook_path"'" }] }]
      }
    }' 2>"$jq_stderr_file") || {
      local jq_err_msg
      jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
      rm -f "$jq_stderr_file" "$temp_file"
      echo "ERROR: install_empty_firing_impl: jq creation failed: ${jq_err_msg:-(no stderr)}" >&2
      echo "       hook_path: $hook_path" >&2
      return 1
    }
    rm -f "$jq_stderr_file"
  fi

  if ! echo "$new_settings" | jq . >/dev/null 2>&1; then
    rm -f "$temp_file"
    return 1
  fi
  if ! echo "$new_settings" >"$temp_file"; then
    rm -f "$temp_file"
    return 1
  fi
  sync || true
  if ! mv "$temp_file" "$settings_path"; then
    rm -f "$temp_file"
    return 1
  fi
  echo "Stop hook (empty-firing-detector) installed at $settings_path" >&2
  return 0
}

uninstall_empty_firing_impl() {
  local settings_path="$1"
  local hook_path="$2"
  local claude_dir
  claude_dir=$(dirname "$settings_path") || return 1

  if [ ! -f "$settings_path" ]; then return 0; fi
  if ! jq . "$settings_path" >/dev/null 2>&1; then
    echo "ERROR: uninstall_empty_firing_impl: settings.json malformed" >&2
    return 1
  fi

  local new_settings
  local jq_stderr_file
  _capture_jq_stderr_to jq_stderr_file "$claude_dir"
  new_settings=$(jq '
    .hooks.Stop[]?.hooks |= map(
      select((.type != "command" or .command != "'"$hook_path"'"))
    )
  ' "$settings_path" 2>"$jq_stderr_file") || {
    local jq_err_msg
    jq_err_msg=$(cat "$jq_stderr_file" 2>/dev/null || echo "(stderr capture failed)")
    rm -f "$jq_stderr_file"
    echo "ERROR: uninstall_empty_firing_impl: jq update failed: ${jq_err_msg:-(no stderr)}" >&2
    echo "       hook_path:     $hook_path" >&2
    echo "       settings_path: $settings_path" >&2
    return 1
  }
  rm -f "$jq_stderr_file"

  local temp_file
  temp_file=$(mktemp "$claude_dir/settings.XXXXXX") || return 1
  if ! echo "$new_settings" >"$temp_file"; then rm -f "$temp_file"; return 1; fi
  sync || true
  mv "$temp_file" "$settings_path" || { rm -f "$temp_file"; return 1; }
  return 0
}

install_empty_firing() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_empty_firing) || return 1
  fi
  if [ ! -f "$hook_path" ]; then
    echo "ERROR: install_empty_firing: hook not found at $hook_path" >&2
    return 1
  fi
  settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")
  _with_settings_lock install_empty_firing_impl "$settings_path" "$hook_path" || return 1
}

uninstall_empty_firing() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  local hook_path="${2:-}"
  if [ -z "$hook_path" ]; then
    hook_path=$(hook_path_default_empty_firing) || return 1
  fi
  if [ -f "$settings_path" ]; then
    settings_path=$(cd "$(dirname "$settings_path")" && echo "$PWD/$(basename "$settings_path")")
  fi
  hook_path=$(cd "$(dirname "$hook_path")" && echo "$PWD/$(basename "$hook_path")")
  _with_settings_lock uninstall_empty_firing_impl "$settings_path" "$hook_path" || return 1
}

# install_all_hooks: composite — installs heartbeat-tick (PostToolUse),
# session-bind (SessionStart), pacing-veto (PreToolUse), and
# empty-firing-detector (Stop). Idempotent.
install_all_hooks() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  install_hook "$settings_path" || return 1
  install_session_bind "$settings_path" || return 1
  install_pacing_veto "$settings_path" || return 1
  install_empty_firing "$settings_path" || return 1
  return 0
}

# uninstall_all_hooks: composite — removes all four autoloop hooks.
uninstall_all_hooks() {
  local settings_path="${1:-$HOME/.claude/settings.json}"
  uninstall_hook "$settings_path" || true
  uninstall_session_bind "$settings_path" || true
  uninstall_pacing_veto "$settings_path" || true
  uninstall_empty_firing "$settings_path" || true
  return 0
}

export -f _resolve_autoloop_plugin_root
export -f hook_path_default_pacing_veto
export -f is_pacing_veto_installed
export -f install_pacing_veto_impl
export -f install_pacing_veto
export -f uninstall_pacing_veto_impl
export -f uninstall_pacing_veto
export -f hook_path_default_empty_firing
export -f is_empty_firing_installed
export -f install_empty_firing_impl
export -f install_empty_firing
export -f uninstall_empty_firing_impl
export -f uninstall_empty_firing

# run_hook_runtime_selftest [plugin_root]
# PROCESS-STORM-OK (bash function definition, not a fork bomb)
# Wave 5 A1 — runtime hook self-test. Confirms each shipped hook script:
#   1. Exists and is readable
#   2. Has the executable bit set (or can be invoked via `bash`)
#   3. Exits 0 when fed a minimal synthetic Claude Code event payload
#
# This is meaningfully different from is_hook_installed (which only checks
# settings.json structure). A hook can be registered in settings.json yet
# crash on invocation due to: missing shebang, broken shellcheck disable,
# stale path to a sibling lib, syntax error from a botched edit, missing
# dependency (jq, python3). The self-test catches all of those.
#
# The test runs in a sandbox HOME so the synthetic payloads can't pollute
# the user's real registry, hook-errors log, or heartbeat files.
#
# Output:
#   On success: nothing (silent)
#   On failure: per-hook diagnostic line on stderr ending with "FAIL: <reason>"
# Exit code: 0 if all hooks pass, 1 if any hook fails
run_hook_runtime_selftest() {
  local plugin_root="${1:-}"
  if [ -z "$plugin_root" ]; then
    local script_dir
    script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || return 1
    plugin_root="$(dirname "$script_dir")"
  fi

  local hooks_dir="$plugin_root/hooks"
  if [ ! -d "$hooks_dir" ]; then
    echo "  FAIL: hooks dir not found at $hooks_dir" >&2
    return 1
  fi

  # Sandbox: scratch HOME so synthetic payloads land in a throwaway tree.
  local sandbox
  sandbox=$(mktemp -d) || {
    echo "  FAIL: cannot create sandbox tmpdir" >&2
    return 1
  }

  local fails=0

  # heartbeat-tick.sh: PostToolUse hook. Reads session_id + cwd from stdin
  # JSON. With no registered loop matching the cwd, it should no-op + exit 0.
  local tick="$hooks_dir/heartbeat-tick.sh"
  if [ ! -r "$tick" ]; then
    echo "  FAIL: heartbeat-tick.sh missing or unreadable" >&2
    fails=$((fails + 1))
  else
    local out rc
    out=$(HOME="$sandbox" bash "$tick" <<<'{"session_id":"00000000-0000-0000-0000-000000000000","cwd":"'"$sandbox"'","tool_name":"Bash"}' 2>&1) && rc=0 || rc=$?
    if [ "$rc" != "0" ]; then
      echo "  FAIL: heartbeat-tick.sh exited rc=$rc; output: $out" >&2
      fails=$((fails + 1))
    fi
  fi

  # session-bind.sh: SessionStart hook. Reads session_id + cwd; with no
  # matching contract it should no-op + exit 0.
  local bind="$hooks_dir/session-bind.sh"
  if [ ! -r "$bind" ]; then
    echo "  FAIL: session-bind.sh missing or unreadable" >&2
    fails=$((fails + 1))
  else
    local out rc
    out=$(HOME="$sandbox" bash "$bind" <<<'{"session_id":"00000000-0000-0000-0000-000000000000","cwd":"'"$sandbox"'","source":"startup","hook_event_name":"SessionStart"}' 2>&1) && rc=0 || rc=$?
    if [ "$rc" != "0" ]; then
      echo "  FAIL: session-bind.sh exited rc=$rc; output: $out" >&2
      fails=$((fails + 1))
    fi
  fi

  # pacing-veto.sh: PreToolUse hook for ScheduleWakeup. With no registered
  # loop bound to the cwd, the hook is meant to be a no-op. We feed it a
  # minimal ScheduleWakeup payload.
  local veto="$hooks_dir/pacing-veto.sh"
  if [ -r "$veto" ]; then
    local out rc
    out=$(HOME="$sandbox" bash "$veto" <<<'{"session_id":"00000000-0000-0000-0000-000000000000","cwd":"'"$sandbox"'","tool_name":"ScheduleWakeup","tool_input":{"delaySeconds":60,"reason":"selftest"}}' 2>&1) && rc=0 || rc=$?
    if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then
      # rc=2 is the documented "deny" verdict; any other non-zero is a crash.
      echo "  FAIL: pacing-veto.sh crashed rc=$rc; output: $out" >&2
      fails=$((fails + 1))
    fi
  fi

  rm -rf "$sandbox" 2>/dev/null || true
  if [ "$fails" -gt 0 ]; then
    return 1
  fi
  return 0
}

# strip_plugin_quarantine_xattrs [plugin_root]
# Best-effort macOS quarantine attribute cleanup. After `claude plugin
# marketplace add`, scripts downloaded via curl/zip/tar inherit
# com.apple.quarantine and refuse to execute with "operation not permitted".
# Removing the xattr once at setup time is harmless and idempotent.
#
# Arguments:
#   $1 (optional): plugin root dir; defaults to two levels above this script
#                  (so .../scripts/ → .../).
#
# Output:
#   stderr — one line per stripped file (visible to the user via SKILL.md)
#   stdout — final count of stripped files
#
# Exit code: always 0 (best-effort; no-op on non-macOS or if xattr missing)
strip_plugin_quarantine_xattrs() {
  local plugin_root="${1:-}"
  if [ -z "$plugin_root" ]; then
    local script_dir
    script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || return 0
    plugin_root="$(dirname "$script_dir")"
  fi

  # macOS-only: xattr is a darwin/BSD tool. Linux installations don't have
  # quarantine bits to clean.
  case "$(uname -s 2>/dev/null)" in
    Darwin) ;;
    *) echo "0"; return 0 ;;
  esac

  command -v xattr >/dev/null 2>&1 || { echo "0"; return 0; }
  [ -d "$plugin_root" ] || { echo "0"; return 0; }

  local stripped=0
  local f
  # Scan only the directories where executables live; don't traverse the whole
  # plugin tree (templates/, schemas/, references/ don't run).
  for d in "$plugin_root/scripts" "$plugin_root/hooks"; do
    [ -d "$d" ] || continue
    while IFS= read -r f; do
      if xattr -p com.apple.quarantine "$f" >/dev/null 2>&1; then
        if xattr -d com.apple.quarantine "$f" 2>/dev/null; then
          stripped=$((stripped + 1))
          echo "  stripped com.apple.quarantine from $f" >&2
        fi
      fi
    done < <(find "$d" -type f \( -name '*.sh' -o -name '*.bash' \) 2>/dev/null)
  done

  echo "$stripped"
  return 0
}

# Export functions for sourcing by other scripts
export -f is_hook_installed
export -f is_session_bind_installed
export -f hook_path_default
export -f hook_path_default_session_bind
export -f _with_settings_lock
export -f install_hook_impl
export -f install_hook
export -f uninstall_hook_impl
export -f uninstall_hook
export -f install_session_bind_impl
export -f install_session_bind
export -f uninstall_session_bind_impl
export -f uninstall_session_bind
export -f install_all_hooks
export -f uninstall_all_hooks
export -f strip_plugin_quarantine_xattrs
export -f run_hook_runtime_selftest
