#!/usr/bin/env bash
# swap-guard — shared state/guard engine for the /swap + /handoff Claude Code integration.
# Contract: section A of the binding interface contract.
# Subcommands:
#   whoami                          own session registry JSON (compact); exit 1 if not in a session
#   sessions                        JSON array of OTHER live claude sessions (always exit 0)
#   preflight <target>              switch-safety verdict JSON for slot number / alias / email (always exit 0)
#   path flag|handoff [--cwd <dir>] print per-cwd state-file path
#   flag <json>                     validate + fill + atomic-write relaunch flag; prints path (exit 1 on bad input)
#   status                          own-session status JSON (always exit 0)
#   cancel [--cwd <dir>]            remove pending handoff + flag for cwd (always exit 0)
#   history [--cwd <dir>] [--all]   archived handoff packages (newest first, numbered) as JSON
#   restore [<n>|<path>] [--cwd <dir>] [--force]
#                                   re-arm an archived package as this cwd's pending handoff
#   auto tick|install|uninstall|status [opts]
#                                   busy-aware wrapper around `cswap auto --once` (+ opt-in LaunchAgent)
#   artifacts [--session <id>] [--cwd <dir>]
#                                   collect this session's claude.ai artifacts: copy sources to
#                                   $STATE_ROOT/handoff-artifacts/<sid>/ + manifest.json; prints manifest
#   schedule-kill <pid>             v1 stub (exit 1)
set -u

STATE_ROOT="$HOME/.claude-swap-backup"
CTX_DIR="$STATE_ROOT/ctx"
ARCHIVE_DIR="$STATE_ROOT/handoff-archive"
ARTIFACTS_DIR="$STATE_ROOT/handoff-artifacts"
SESSIONS_DIR="$HOME/.claude/sessions"

usage() {
  cat >&2 <<'EOF'
usage: swap-guard <subcommand> [args]
  whoami                          own session registry JSON (compact)
  sessions                        JSON array of other live claude sessions
  preflight <target>              switch-safety verdict for slot / alias / email
  path flag|handoff [--cwd <dir>] print per-cwd state-file path
  flag <json>                     write relaunch flag ({"mode":"handoff"|"restart",...}); prints path
  status                          own-session status JSON
  cancel [--cwd <dir>]            remove pending handoff + flag for cwd
  history [--cwd <dir>] [--all]   list archived handoff packages (newest first)
  restore [<n>|<path>] [--cwd <dir>] [--force]
                                  re-arm an archived package (default: newest for cwd)
  auto tick [--threshold PCT] [--model NAMES] [--no-notify]
                                  one busy-aware auto-switch evaluation (JSON line; exit = cswap's)
  auto install [--interval SEC] [--threshold PCT] [--model NAMES] [--no-notify]
  auto uninstall | auto status    manage the LaunchAgent that runs `auto tick` periodically
  artifacts [--session <id>] [--cwd <dir>]
                                  snapshot this session's artifacts (sources + manifest) for handoff
  schedule-kill <pid>             phase-2 stub
EOF
  exit 2
}

# HASH(cwd) per shared constants: first 12 hex of sha256
hash_cwd() { printf '%s' "$1" | /usr/bin/shasum -a 256 | awk '{print $1}' | cut -c1-12; }

flag_path_for()    { printf '%s/relaunch-%s.json\n'      "$STATE_ROOT" "$(hash_cwd "$1")"; }
pending_path_for() { printf '%s/handoff-pending-%s.md\n' "$STATE_ROOT" "$(hash_cwd "$1")"; }

# whoami_json [start_pid] — walk the PPID chain from start_pid (default $$) up to pid 1;
# print the first matching registry file's JSON (compact) and return 0, else return 1.
whoami_json() {
  local p="${1:-$$}"
  while :; do
    case "$p" in ''|*[!0-9]*) return 1;; esac
    [ "$p" -gt 1 ] || return 1
    if [ -f "$SESSIONS_DIR/$p.json" ]; then
      jq -c . "$SESSIONS_DIR/$p.json" 2>/dev/null || cat "$SESSIONS_DIR/$p.json"
      return 0
    fi
    p="$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')"
  done
}

# session_live <pid> — true iff the process exists (kill -0) AND its comm contains "claude"
session_live() {
  local pid="${1:-}" comm
  case "$pid" in ''|*[!0-9]*) return 1;; esac
  kill -0 "$pid" 2>/dev/null || return 1
  comm="$(ps -o comm= -p "$pid" 2>/dev/null)"
  case "$comm" in *claude*) return 0;; *) return 1;; esac
}

# transcript_status <cwd> <sessionId> — activity fallback for sessions whose registry
# file has no status field (clients < ~2.1.211 never publish one). An active session
# appends to ~/.claude/projects/<flattened-cwd>/<sessionId>.jsonl on every turn, so:
# mtime within TRANSCRIPT_ACTIVE_SECS -> "busy"; older -> "idle"; no transcript -> "unknown".
TRANSCRIPT_ACTIVE_SECS=120
transcript_status() {
  local cwd="${1:-}" sid="${2:-}" t mtime now
  [ -n "$cwd" ] && [ -n "$sid" ] || { printf 'unknown\n'; return 0; }
  t="$HOME/.claude/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')/$sid.jsonl"
  [ -f "$t" ] || { printf 'unknown\n'; return 0; }
  mtime="$(stat -f %m "$t" 2>/dev/null)"
  case "$mtime" in ''|*[!0-9]*) printf 'unknown\n'; return 0;; esac
  now="$(date +%s)"
  if [ $(( now - mtime )) -le "$TRANSCRIPT_ACTIVE_SECS" ]; then
    printf 'busy\n'
  else
    printf 'idle\n'
  fi
}

# sessions_json — JSON array of OTHER live sessions. status precedence: the value the
# session itself reports (statusSource "reported") > transcript-mtime fallback
# (statusSource "transcript-mtime") > "unknown" (statusSource "none").
sessions_json() {
  local own_pid="" out="[]" new_out f pid entry st
  own_pid="$(whoami_json 2>/dev/null | jq -r '.pid // empty' 2>/dev/null)" || own_pid=""
  for f in "$SESSIONS_DIR"/*.json; do
    [ -f "$f" ] || continue
    pid="$(jq -r '.pid // empty' "$f" 2>/dev/null)" || continue
    [ -n "$pid" ] || continue
    [ "$pid" = "$own_pid" ] && continue
    session_live "$pid" || continue
    entry="$(jq -c '{pid, sessionId, cwd, kind, entrypoint, status: (.status // "unknown"),
      statusSource: (if .status then "reported" else "none" end)}' "$f" 2>/dev/null)" || continue
    [ -n "$entry" ] || continue
    if [ "$(printf '%s' "$entry" | jq -r '.statusSource' 2>/dev/null)" = "none" ]; then
      st="$(transcript_status "$(printf '%s' "$entry" | jq -r '.cwd // empty')" \
                              "$(printf '%s' "$entry" | jq -r '.sessionId // empty')")"
      case "$st" in
        busy|idle) entry="$(printf '%s' "$entry" | jq -c --arg s "$st" '.status = $s | .statusSource = "transcript-mtime"' 2>/dev/null)" || continue;;
      esac
    fi
    new_out="$(printf '%s' "$out" | jq -c --argjson e "$entry" '. + [$e]' 2>/dev/null)" && [ -n "$new_out" ] && out="$new_out"
  done
  printf '%s\n' "$out"
}

# resolve_cwd [explicit] — precedence: explicit --cwd > whoami .cwd > $PWD
resolve_cwd() {
  local c="${1:-}"
  if [ -z "$c" ]; then
    c="$(whoami_json 2>/dev/null | jq -r '.cwd // empty' 2>/dev/null)" || c=""
  fi
  [ -n "$c" ] || c="$PWD"
  printf '%s\n' "$c"
}

# parse_cwd_opt "$@" — echo value of --cwd/--cwd= if present; return 1 if absent
parse_cwd_opt() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --cwd) shift; printf '%s\n' "${1:-}"; return 0;;
      --cwd=*) printf '%s\n' "${1#--cwd=}"; return 0;;
    esac
    shift
  done
  return 1
}

cmd_whoami() {
  local out
  if out="$(whoami_json)"; then
    printf '%s\n' "$out"
    exit 0
  fi
  printf '%s\n' '{"error":"not-in-session"}'
  exit 1
}

cmd_sessions() {
  sessions_json
  exit 0
}

cmd_preflight() {
  local raw="${1:-}" busy busy_n list tgt slot="" verdict detail target_json number email us
  busy="$(sessions_json | jq -c '[.[] | select(.status == "busy" or .status == "unknown")]' 2>/dev/null)" || busy="[]"
  [ -n "$busy" ] || busy="[]"
  busy_n="$(printf '%s' "$busy" | jq 'length' 2>/dev/null)" || busy_n=0

  if [ -z "$raw" ]; then
    jq -cn --argjson busy "$busy" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:"no target given"}'
    exit 0
  fi

  list="$(cswap list --json 2>/dev/null)"
  if [ -z "$list" ] || ! printf '%s' "$list" | jq -e . >/dev/null 2>&1; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("cswap list --json failed - cannot verify target " + ($t|tojson))}'
    exit 0
  fi

  # Resolve target: digits -> slot number; else alias in sequence.json; else email
  case "$raw" in
    *[!0-9]*)
      slot="$(jq -r --arg a "$raw" '.accounts | to_entries[] | select((.value.alias // "") == $a) | .key' \
        "$STATE_ROOT/sequence.json" 2>/dev/null | head -n1)" || slot=""
      ;;
    *) slot="$raw";;
  esac
  if [ -n "$slot" ]; then
    tgt="$(printf '%s' "$list" | jq -c --arg n "$slot" '[.accounts[]? | select((.number|tostring) == $n)] | first // empty' 2>/dev/null)"
  else
    tgt="$(printf '%s' "$list" | jq -c --arg e "$raw" '[.accounts[]? | select(((.email // "") | ascii_downcase) == ($e | ascii_downcase))] | first // empty' 2>/dev/null)"
  fi

  if [ -z "$tgt" ]; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("no account matches " + ($t|tojson) + " (not a slot number, alias, or email)")}'
    exit 0
  fi

  target_json="$(printf '%s' "$tgt" | jq -c '{number, email}')"
  number="$(printf '%s' "$tgt" | jq -r '.number')"
  email="$(printf '%s' "$tgt" | jq -r '.email // ""')"
  us="$(printf '%s' "$tgt" | jq -r '.usageStatus // ""' | tr '[:upper:]' '[:lower:]')"

  case "$us" in
    *login*|*quarantin*|*expired*|*invalid*|*revoked*)
      verdict="relogin-required"
      detail="account $number ($email) requires re-login (usageStatus: $us) - recover with /login then: cswap add --slot $number"
      ;;
    *)
      if [ "$busy_n" -gt 0 ] 2>/dev/null; then
        verdict="busy"
        detail="account $number ($email) is healthy, but $busy_n other session(s) are busy or possibly-busy - a switch flips ALL sessions within ~30s"
      else
        verdict="ok"
        detail="account $number ($email) is healthy and no other session is busy"
      fi
      ;;
  esac

  jq -cn --arg v "$verdict" --argjson t "$target_json" --argjson busy "$busy" --arg d "$detail" \
    '{verdict:$v,target:$t,busy:$busy,detail:$d}'
  exit 0
}

cmd_path() {
  local kind="${1:-}"
  [ $# -gt 0 ] && shift
  local cwd_opt="" cwd
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$kind" in
    flag)    flag_path_for "$cwd";;
    handoff) pending_path_for "$cwd";;
    *) usage;;
  esac
  exit 0
}

cmd_flag() {
  local input="${1:-}"
  if [ -z "$input" ] || ! printf '%s' "$input" | jq -e . >/dev/null 2>&1; then
    echo "swap-guard flag: input is not valid JSON" >&2
    exit 1
  fi
  local mode
  mode="$(printf '%s' "$input" | jq -r '.mode // empty')"
  case "$mode" in
    handoff|restart) ;;
    *) echo 'swap-guard flag: .mode must be "handoff" or "restart"' >&2; exit 1;;
  esac
  local sid cwd created out p tmp
  sid="$(printf '%s' "$input" | jq -r '.sessionId // ""')"
  cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
  cwd="$(resolve_cwd "$cwd")"
  created="$(printf '%s' "$input" | jq -r '.created // empty' 2>/dev/null)"
  case "$created" in ''|*[!0-9]*) created="$(date +%s)";; esac
  out="$(jq -cn --arg m "$mode" --arg s "$sid" --arg c "$cwd" --argjson t "$created" \
    '{mode:$m,sessionId:$s,cwd:$c,created:$t}')"
  p="$(flag_path_for "$cwd")"
  mkdir -p "$STATE_ROOT" 2>/dev/null || { echo "swap-guard flag: cannot create $STATE_ROOT" >&2; exit 1; }
  tmp="$p.tmp.$$"
  if printf '%s\n' "$out" > "$tmp" 2>/dev/null && mv "$tmp" "$p" 2>/dev/null; then
    printf '%s\n' "$p"
    exit 0
  fi
  rm -f "$tmp" 2>/dev/null
  echo "swap-guard flag: write failed" >&2
  exit 1
}

# _status_json <sessionId-or-empty> <cwd> — build the status JSON (factored for testability)
_status_json() {
  local sid="${1:-}" cwd="${2:-$PWD}"
  local pct="" fired=0 bannered=0
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.json" ]; then
    pct="$(jq -r 'if (.pct|type) == "number" then (.pct|tostring) else "" end' "$CTX_DIR/$sid.json" 2>/dev/null)" || pct=""
  fi
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.state" ]; then
    fired="$(jq -r '.fired // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || fired=0
    bannered="$(jq -r '.bannered // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || bannered=0
  fi
  case "$fired" in ''|*[!0-9]*) fired=0;; esac
  case "$bannered" in ''|*[!0-9]*) bannered=0;; esac

  local h pending flagf pending_out="" page="" flag_out="" arch="" created
  h="$(hash_cwd "$cwd")"
  pending="$STATE_ROOT/handoff-pending-$h.md"
  flagf="$STATE_ROOT/relaunch-$h.json"
  if [ -f "$pending" ]; then
    pending_out="$pending"
    created="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$pending" 2>/dev/null)"
    [ -n "$created" ] || created="$(stat -f %m "$pending" 2>/dev/null)"
    case "$created" in
      ''|*[!0-9]*) page="";;
      *) page="$(( $(date +%s) - created ))";;
    esac
  fi
  [ -f "$flagf" ] && flag_out="$flagf"
  if [ -d "$ARCHIVE_DIR" ]; then
    arch="$(ls "$ARCHIVE_DIR" 2>/dev/null | grep -E -- "-$h(-expired)?\.md$" | sort | tail -n1)"
    [ -n "$arch" ] && arch="$ARCHIVE_DIR/$arch"
  fi

  jq -cn --arg sid "$sid" --arg pct "$pct" --arg fired "$fired" --arg bannered "$bannered" \
    --arg pending "$pending_out" --arg page "$page" --arg flagp "$flag_out" --arg arch "$arch" '
    {sessionId: (if $sid == "" then null else $sid end),
     pct: (if $pct == "" then null else ($pct|tonumber) end),
     fired: ($fired|tonumber),
     bannered: ($bannered|tonumber),
     pendingHandoff: (if $pending == "" then null else $pending end),
     pendingAgeSec: (if $page == "" then null else ($page|tonumber) end),
     flag: (if $flagp == "" then null else $flagp end),
     latestArchive: (if $arch == "" then null else $arch end)}'
}

cmd_status() {
  local who sid="" cwd=""
  if who="$(whoami_json 2>/dev/null)"; then
    sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)" || sid=""
    cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)" || cwd=""
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  _status_json "$sid" "$cwd"
  exit 0
}

cmd_cancel() {
  local cwd_opt="" cwd removed=0 f
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  for f in "$(pending_path_for "$cwd")" "$(flag_path_for "$cwd")"; do
    if [ -f "$f" ]; then
      rm -f "$f" 2>/dev/null && { printf 'removed: %s\n' "$f"; removed=1; }
    fi
  done
  [ "$removed" -eq 1 ] || printf 'nothing to remove for cwd %s\n' "$cwd"
  exit 0
}

# _history_files <cwd> <all:0|1> — archive paths, newest first (max 20).
# Archive names are <UTC-ts>-<cwdhash>[-expired].md, so the cwd filter is a name match.
_history_files() {
  local cwd="$1" all="$2" h
  [ -d "$ARCHIVE_DIR" ] || return 0
  h="$(hash_cwd "$cwd")"
  ls "$ARCHIVE_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' |
    { if [ "$all" = 1 ]; then cat; else grep -E -- "-$h(-expired|-replaced)?\.md$"; fi; } |
    sort -r | head -n 20 | sed "s|^|$ARCHIVE_DIR/|"
}

cmd_history() {
  local cwd_opt="" cwd all=0 a n=0 f base hdr pcwd pcreated title bytes expired out="[]"
  for a in "$@"; do [ "$a" = "--all" ] && all=1; done
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  while IFS= read -r f; do
    [ -n "$f" ] && [ -f "$f" ] || continue
    n=$((n + 1))
    base="${f##*/}"
    hdr="$(head -n 1 "$f")"
    pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
    pcreated="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd=".*" created="\([0-9][0-9]*\)" -->.*$/\1/p')"
    title="$(sed -n '2s/^# *//p' "$f" | cut -c1-120)"
    bytes="$(LC_ALL=C wc -c < "$f" | tr -d ' ')"
    # (no `case` inside $(...): bash 3.2 mis-parses its `)` patterns there)
    expired=false; case "$base" in *-expired.md) expired=true;; esac
    out="$(printf '%s' "$out" | jq -c --argjson n "$n" --arg path "$f" --arg cwd "$pcwd" \
      --arg created "$pcreated" --arg title "$title" --arg bytes "$bytes" \
      --arg arch "$(printf '%s' "$base" | sed -E 's/^([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2}).*/\1-\2-\3T\4:\5:\6Z/')" \
      --argjson expired "$expired" '
      . + [{n: $n, path: $path, title: $title, cwd: (if $cwd == "" then null else $cwd end),
            created: (if $created == "" then null else ($created | tonumber) end),
            archivedAt: $arch, expired: $expired, bytes: ($bytes | tonumber)}]')"
  done <<EOF
$(_history_files "$cwd" "$all")
EOF
  printf '%s\n' "$out"
  exit 0
}

cmd_restore() {
  local cwd_opt="" cwd force=0 sel="" src="" hdr pcwd P created_old now age
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1;;
      --cwd) shift; cwd_opt="${1:-}";;
      --cwd=*) cwd_opt="${1#--cwd=}";;
      --*) :;;
      *) [ -z "$sel" ] && sel="$1";;
    esac
    shift
  done
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$sel" in
    '') src="$(_history_files "$cwd" 0 | head -n 1)";;
    *[!0-9]*)
      # Paths are reduced to their basename and must look like an archive name, so
      # `..`, foreign dirs, and look-alike prefixes cannot escape the archive dir.
      # Restored text is injected as trusted context — symlinks are refused too.
      local b="${sel##*/}"
      if printf '%s' "$b" | grep -Eq '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' &&
         [ -f "$ARCHIVE_DIR/$b" ] && [ ! -L "$ARCHIVE_DIR/$b" ]; then
        src="$ARCHIVE_DIR/$b"
      fi;;
    *) src="$(_history_files "$cwd" 0 | sed -n "${sel}p")";;
  esac
  if [ -z "$src" ] || [ ! -f "$src" ] || [ -L "$src" ]; then
    jq -cn --arg cwd "$cwd" --arg sel "$sel" '{error:"not-found", cwd:$cwd, selector:$sel,
      detail:"no archived package matches; run swap-guard history (paths must be inside the archive dir)"}'
    exit 1
  fi
  hdr="$(head -n 1 "$src")"
  pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
  if [ -z "$pcwd" ]; then
    jq -cn --arg src "$src" '{error:"malformed-header", path:$src}'; exit 1
  fi
  if [ "$pcwd" != "$cwd" ]; then
    jq -cn --arg src "$src" --arg pcwd "$pcwd" --arg cwd "$cwd" '{error:"cwd-mismatch", path:$src,
      packageCwd:$pcwd, cwd:$cwd, detail:"the startup injector only loads a package in its own directory: cd there, or pass --cwd <packageCwd>"}'
    exit 1
  fi
  P="$(pending_path_for "$cwd")"
  now="$(date +%s)"
  if [ -f "$P" ] && [ "$force" -ne 1 ]; then
    created_old="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$P" 2>/dev/null)"
    case "$created_old" in ''|*[!0-9]*) age=0;; *) age=$(( now - created_old ));; esac
    if [ "$age" -lt 600 ]; then
      jq -cn --arg p "$P" --argjson age "$age" '{error:"pending-exists", pending:$p, pendingAgeSec:$age,
        detail:"a live pending handoff exists for this cwd; re-run with --force to replace it"}'
      exit 1
    fi
  fi
  mkdir -p "$STATE_ROOT" "$ARCHIVE_DIR" || exit 1
  # Build the new package BEFORE touching the old one (src may be re-read below).
  { printf '<!-- handoff cwd="%s" created="%s" -->\n' "$cwd" "$now"; tail -n +2 "$src"; } > "$P.tmp.$$" ||
    { rm -f "$P.tmp.$$"; exit 1; }
  # An existing pending package (live with --force, or expired but never archived
  # because no session started here since) is moved to the archive, never lost.
  local replaced=""
  if [ -f "$P" ]; then
    replaced="$ARCHIVE_DIR/$(date -u +%Y%m%d-%H%M%S)-$(hash_cwd "$cwd")-replaced.md"
    mv "$P" "$replaced" || { rm -f "$P.tmp.$$"; exit 1; }
  fi
  mv "$P.tmp.$$" "$P" || { rm -f "$P.tmp.$$"; exit 1; }
  jq -cn --arg src "$src" --arg p "$P" --arg cwd "$cwd" --argjson now "$now" --arg rep "$replaced" \
    '{restored:$src, pending:$p, cwd:$cwd, created:$now, expiresInSec:600,
      replacedArchivedTo:(if $rep == "" then null else $rep end)}'
  exit 0
}

# --- auto: busy-aware auto-switching ------------------------------------------
# `cswap auto` knows rate limits but not whether a Claude session is mid-turn.
# A tick asks cswap for a DRY-RUN decision first; a proactive switch is deferred
# while any live session is busy (swap-guard preflight), while an at-limit /
# failover switch proceeds (every session on that account is stalled anyway).
# The real switch is a non-dry `cswap auto --once`, which re-checks under its own
# state lock and records lastSwitchAt, so cswap's cooldown keeps working.
AUTO_LABEL="com.overclaude.autoswap"
AUTO_LOG="$STATE_ROOT/auto.log"
auto_plist() { printf '%s/Library/LaunchAgents/%s.plist\n' "$HOME" "$AUTO_LABEL"; }

_auto_log() { # _auto_log <json> — append + keep the last 500 lines
  mkdir -p "$STATE_ROOT" 2>/dev/null || return 0
  printf '%s\n' "$1" >> "$AUTO_LOG"
  if [ "$(wc -l < "$AUTO_LOG" 2>/dev/null || echo 0)" -gt 600 ]; then
    tail -n 500 "$AUTO_LOG" > "$AUTO_LOG.tmp.$$" && mv "$AUTO_LOG.tmp.$$" "$AUTO_LOG"
  fi
}

_auto_notify() { # _auto_notify <message>
  command -v osascript >/dev/null 2>&1 || return 0
  osascript -e "display notification \"$(printf '%s' "$1" | tr -d '"\\')\" with title \"overclaude auto-swap\"" >/dev/null 2>&1 || true
}

_auto_tick() {
  local notify=1 probe=0 cargs=() dry rc ev trigger to_num to_email pre verdict busy_n out real_rc now
  while [ $# -gt 0 ]; do
    case "$1" in
      --no-notify) notify=0;;
      --probe) probe=1;;
      --threshold|--model|--strategy|--cooldown) cargs+=("$1" "${2:-}"); shift;;
      *) :;;
    esac
    shift
  done
  command -v cswap >/dev/null 2>&1 || { printf '%s\n' '{"event":"error","detail":"cswap not found"}'; return 1; }
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  local errf; errf="$(mktemp "${TMPDIR:-/tmp}/swap-guard-auto.XXXXXX")" || errf=/dev/null
  dry="$(cswap auto --once --dry-run --json ${cargs[@]+"${cargs[@]}"} 2>"$errf")"; rc=$?
  # argparse rejects bad arguments with rc 2 and no JSON on stdout — rc 2 alone is
  # indistinguishable from NO_ACTION, so treat "no events at all" as an error.
  if ! printf '%s\n' "$dry" | jq -e 'select(.event != null)' >/dev/null 2>&1; then
    out="$(jq -cn --arg ts "$now" --argjson rc "$rc" --arg err "$(tail -c 400 "$errf" 2>/dev/null)" \
      '{ts:$ts, action:"error", rc:$rc, detail:("cswap auto produced no decision: " + $err)}')"
    [ "$errf" = /dev/null ] || rm -f "$errf"
    _auto_log "$out"; printf '%s\n' "$out"; return 1
  fi
  [ "$errf" = /dev/null ] || rm -f "$errf"
  # --probe (used by `auto install`): the args parse and cswap decides — stop
  # here; a probe never switches and never writes the log.
  if [ "$probe" -eq 1 ]; then
    jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"probe-ok", rc:$rc}'; return 0
  fi
  ev="$(printf '%s\n' "$dry" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$rc" -ne 0 ] || [ -z "$ev" ]; then
    # no-switch / blocked / error: log the last decisive event and pass cswap's exit code through
    out="$(printf '%s\n' "$dry" | jq -c --arg ts "$now" --argjson rc "$rc" \
      'select(.event != "poll") | {ts: $ts, action: "none", rc: $rc, event, reason, detail}' 2>/dev/null | tail -n 1)"
    [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"none", rc:$rc}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$rc" -eq 3 ] && [ "$notify" -eq 1 ]; then
      _auto_notify "Every account is at its limit — nothing to switch to."
    fi
    return "$rc"
  fi
  trigger="$(printf '%s' "$ev" | jq -r '.trigger // ""')"
  to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
  to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
  pre="$( (cmd_preflight "$to_num") 2>/dev/null)"
  verdict="$(printf '%s' "$pre" | jq -r '.verdict // "unknown"' 2>/dev/null)"
  busy_n="$(printf '%s' "$pre" | jq -r '(.busy // []) | length' 2>/dev/null)"
  case "$verdict" in
    relogin-required|unknown-target)
      out="$(jq -cn --arg ts "$now" --arg v "$verdict" --arg to "$to_num" '{ts:$ts, action:"skipped", reason:$v, to:$to}')"
      _auto_log "$out"; printf '%s\n' "$out"; return 3;;
    busy)
      if [ "$trigger" = "proactive" ] || [ "$trigger" = "consume-first" ]; then
        out="$(jq -cn --arg ts "$now" --arg to "$to_num" --arg t "$trigger" --argjson n "${busy_n:-0}" \
          '{ts:$ts, action:"deferred", reason:"busy", trigger:$t, to:$to, busySessions:$n}')"
        _auto_log "$out"; printf '%s\n' "$out"; return 2
      fi;;
  esac
  out="$(cswap auto --once --json ${cargs[@]+"${cargs[@]}"} 2>/dev/null)"; real_rc=$?
  ev="$(printf '%s\n' "$out" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$real_rc" -eq 0 ] && [ -n "$ev" ]; then
    to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
    to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
    out="$(printf '%s' "$ev" | jq -c --arg ts "$now" --arg v "$verdict" --argjson n "${busy_n:-0}" \
      '{ts:$ts, action:"switched", trigger, from:.from.number, to:.to.number, warnings, preflight:$v, busySessions:$n}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$notify" -eq 1 ]; then
      local label
      label="$(jq -r --arg n "$to_num" '.accounts[$n].alias // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
      _auto_notify "Switched to ${label:-${to_email%%@*}} ($trigger). A paused session continues when you send any message."
    fi
    return 0
  fi
  out="$(printf '%s\n' "$out" | jq -c --arg ts "$now" --argjson rc "$real_rc" \
    'select(.event != "poll") | {ts:$ts, action:"none", rc:$rc, event, reason, detail, note:"dry-run wanted a switch; recheck declined"}' 2>/dev/null | tail -n 1)"
  [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$real_rc" '{ts:$ts, action:"none", rc:$rc}')"
  _auto_log "$out"; printf '%s\n' "$out"
  return "$real_rc"
}

_auto_install() {
  local interval=180 targs=() plist path_env d self uid a
  while [ $# -gt 0 ]; do
    case "$1" in
      --interval) interval="${2:-180}"; shift;;
      --threshold|--model) targs+=("$1" "${2:-}"); shift;;
      --no-notify) targs+=("$1");;
    esac
    shift
  done
  case "$interval" in ''|*[!0-9]*) echo "auto install: --interval must be whole seconds" >&2; return 1;; esac
  [ "$interval" -ge 60 ] || interval=60
  # Validate up front: a bad value would otherwise fail silently on every tick.
  local i=0 v
  while [ "$i" -lt "${#targs[@]}" ]; do
    case "${targs[$i]}" in
      --threshold)
        v="${targs[$((i + 1))]:-}"
        if ! printf '%s' "$v" | grep -Eq '^[0-9]+(\.[0-9]+)?$' ||
           ! awk -v t="$v" 'BEGIN { exit !(t >= 50 && t <= 99.9) }'; then
          echo "auto install: --threshold must be a number from 50 to 99.9 (got '$v')" >&2; return 1
        fi; i=$((i + 2));;
      --model)
        v="${targs[$((i + 1))]:-}"
        printf '%s' "$v" | grep -Eq '^[A-Za-z0-9_,.-]+$' ||
          { echo "auto install: --model must be names like Fable or 'Fable,Opus' (got '$v')" >&2; return 1; }
        i=$((i + 2));;
      *) i=$((i + 1));;
    esac
  done
  self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
  # launchd's PATH is minimal: pin the dirs of every tool a tick needs, in priority
  # order — the cswap that install.sh manages first, so a stale brew/pip copy
  # elsewhere can never shadow it.
  path_env=""
  for d in "$(dirname "$(command -v cswap 2>/dev/null || echo /usr/bin/cswap)")" "$(dirname "$self")" \
           "$(dirname "$(command -v jq 2>/dev/null || echo /usr/bin/jq)")" /opt/homebrew/bin /usr/local/bin \
           /usr/bin /bin /usr/sbin /sbin; do
    case ":$path_env:" in *":$d:"*) :;; *) path_env="${path_env:+$path_env:}$d";; esac
  done
  # Dry-run one tick with these exact args before installing anything.
  local probe
  probe="$(PATH="$path_env" _auto_tick --probe --no-notify ${targs[@]+"${targs[@]}"} 2>/dev/null | tail -n 1)"
  if [ "$(printf '%s' "$probe" | jq -r '.action // "error"' 2>/dev/null)" != "probe-ok" ]; then
    echo "auto install: a test tick failed, not installing: $probe" >&2; return 1
  fi
  plist="$(auto_plist)"
  mkdir -p "$(dirname "$plist")" "$STATE_ROOT" || return 1
  xml() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
  {
    printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
      '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
      '<plist version="1.0"><dict>' \
      "  <key>Label</key><string>$AUTO_LABEL</string>" \
      '  <key>ProgramArguments</key><array>' \
      "    <string>$(xml "$self")</string><string>auto</string><string>tick</string>"
    for a in ${targs[@]+"${targs[@]}"}; do printf '    <string>%s</string>\n' "$(xml "$a")"; done
    # stdout -> /dev/null: every tick already logs itself to the (trimmed) auto.log;
    # stderr is kept for genuine crashes and stays tiny.
    printf '%s\n' '  </array>' \
      "  <key>StartInterval</key><integer>$interval</integer>" \
      '  <key>RunAtLoad</key><true/>' \
      "  <key>EnvironmentVariables</key><dict><key>PATH</key><string>$(xml "$path_env")</string><key>HOME</key><string>$(xml "$HOME")</string></dict>" \
      '  <key>StandardOutPath</key><string>/dev/null</string>' \
      "  <key>StandardErrorPath</key><string>$(xml "$STATE_ROOT/auto-launchd.err")</string>" \
      '</dict></plist>'
  } > "$plist.tmp.$$" || { rm -f "$plist.tmp.$$"; return 1; }
  # Lint BEFORE replacing, so a bad write never clobbers a working plist.
  plutil -lint "$plist.tmp.$$" >/dev/null 2>&1 ||
    { echo "auto install: generated plist failed plutil -lint (kept: $plist.tmp.$$)" >&2; return 1; }
  mv "$plist.tmp.$$" "$plist" || return 1
  if [ -n "${SWAP_GUARD_NO_LAUNCHCTL:-}" ]; then  # tests: generate + lint only
    jq -cn --arg p "$plist" --argjson i "$interval" '{installed:false, generated:true, plist:$p, intervalSec:$i}'
    return 0
  fi
  uid="$(id -u)"
  launchctl bootout "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 || true
  if launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null; then
    jq -cn --arg p "$plist" --argjson i "$interval" --arg a "${targs[*]+${targs[*]}}" \
      '{installed:true, plist:$p, intervalSec:$i, args:$a}'
  else
    echo "auto install: launchctl bootstrap failed (plist written: $plist)" >&2; return 1
  fi
}

_auto_uninstall() {
  local plist uid removed=false
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl bootout "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && removed=true
  [ -f "$plist" ] && rm -f "$plist" && removed=true
  jq -cn --argjson r "$removed" '{uninstalled:$r}'
}

_auto_status() {
  local plist uid loaded=false last=""
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl print "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && loaded=true
  [ -f "$AUTO_LOG" ] && last="$(tail -n 5 "$AUTO_LOG" | jq -sc '.' 2>/dev/null)"
  jq -cn --argjson loaded "$loaded" --arg plist "$plist" --argjson exists "$([ -f "$plist" ] && echo true || echo false)" \
    --argjson last "${last:-[]}" --arg log "$AUTO_LOG" \
    '{loaded:$loaded, plistExists:$exists, plist:$plist, log:$log, recent:$last}'
}

cmd_auto() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    tick)      _auto_tick "$@"; exit $?;;
    install)   _auto_install "$@"; exit $?;;
    uninstall) _auto_uninstall; exit 0;;
    status)    _auto_status; exit 0;;
    *) echo "usage: swap-guard auto tick|install|uninstall|status" >&2; exit 1;;
  esac
}

# cmd_artifacts — snapshot every claude.ai artifact this session (and its subagents /
# workflows) published or touched, so a handoff can carry them across sessions and
# accounts. Source of truth is the transcript's structured toolUseResult
# ({url, artifact_id, title, path, ...}) — never the model's recollection. Sources
# live in the session-scoped /tmp scratchpad, so they are COPIED to a durable dir.
# Artifacts are owned server-side by the account that published them; the manifest
# records the account active at collection time as `account`.
cmd_artifacts() {
  local sid="" cwd="" who flat tdir out_dir manifest tmp acct now
  while [ $# -gt 0 ]; do
    case "$1" in
      --session) sid="${2:-}"; shift 2 || shift;;
      --cwd)     cwd="${2:-}"; shift 2 || shift;;
      *) shift;;
    esac
  done
  if [ -z "$sid" ] || [ -z "$cwd" ]; then
    who="$(whoami_json 2>/dev/null)" || who=""
    [ -n "$sid" ] || sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)"
    [ -n "$cwd" ] || cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)"
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  case "$sid" in ''|*/*|*..*) printf '%s\n' '{"error":"no-session-id"}'; exit 1;; esac
  flat="$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')"
  tdir="$HOME/.claude/projects/$flat"
  [ -f "$tdir/$sid.jsonl" ] || { printf '{"error":"no-transcript","path":"%s"}\n' "$tdir/$sid.jsonl"; exit 1; }

  out_dir="$ARTIFACTS_DIR/$sid"
  mkdir -p "$out_dir" || exit 1
  acct="$(jq -r '.oauthAccount.emailAddress // empty' "$HOME/.claude.json" 2>/dev/null)"
  now="$(date +%s)"

  # One record per artifact_id (last touch wins). Join each result with its tool_use
  # input to recover supporting `files` (map form: pub -> src | {from}).
  tmp="$(mktemp)"
  { printf '%s\n' "$tdir/$sid.jsonl"
    [ -d "$tdir/$sid" ] && find "$tdir/$sid" -name '*.jsonl' -type f
  } | while IFS= read -r f; do
    jq -c '
      if .type == "assistant" then
        (.message.content[]? | select(.type == "tool_use" and .name == "Artifact")
         | {k: "use", id, files: (.input.files // null), file_path: (.input.file_path // null)})
      elif .type == "user" and (.toolUseResult | type) == "object"
           and (.toolUseResult.artifact_id // .toolUseResult.url // null) != null then
        {k: "res", id: (.message.content[0].tool_use_id // null), ts: (.timestamp // ""),
         r: (.toolUseResult | {url, artifact_id, title, path, audience, capabilities})}
      else empty end' "$f" 2>/dev/null
  done > "$tmp"

  jq -s '
    (map(select(.k == "use")) | map({key: (.id // ""), value: .}) | from_entries) as $uses
    | map(select(.k == "res" and (.r.url // "") != ""))
    | sort_by(.ts)
    | map(. as $x | $x.r + {ts: $x.ts,
          path: ($x.r.path // $uses[$x.id // ""].file_path // null),
          files: ($uses[$x.id // ""].files // null)})
    | map(.artifact_id = (.artifact_id // (.url | sub(".*/artifact/"; ""))))
    | group_by(.artifact_id)
    | map(reduce .[] as $e ({}; . + ($e | with_entries(select(.value != null)))))
    | sort_by(.ts)' "$tmp" > "$tmp.recs" 2>/dev/null || printf '[]' > "$tmp.recs"
  rm -f "$tmp"

  # Copy sources: page -> <id>/<basename>; supporting files -> <id>/<published path>.
  local n i rec aid src dst saved files_saved pub fsrc
  n="$(jq 'length' "$tmp.recs")"
  printf '[]' > "$tmp.out"
  i=0
  while [ "$i" -lt "$n" ]; do
    rec="$(jq -c ".[$i]" "$tmp.recs")"
    aid="$(printf '%s' "$rec" | jq -r '.artifact_id' | tr -cd 'A-Za-z0-9_-')"
    src="$(printf '%s' "$rec" | jq -r '.path // empty')"
    saved="null"; files_saved="{}"
    if [ -n "$aid" ] && [ -n "$src" ] && [ -f "$src" ]; then
      mkdir -p "$out_dir/$aid"
      dst="$out_dir/$aid/${src##*/}"
      cp -p "$src" "$dst" 2>/dev/null && saved="$(jq -Rn --arg d "$dst" '$d')"
      while IFS=$'\t' read -r pub fsrc; do
        [ -n "$pub" ] && [ -n "$fsrc" ] || continue
        case "$pub" in /*|*..*) continue;; esac
        case "$fsrc" in /*) :;; *) fsrc="$cwd/$fsrc";; esac
        [ -f "$fsrc" ] || continue
        mkdir -p "$(dirname "$out_dir/$aid/$pub")" &&
          cp -p "$fsrc" "$out_dir/$aid/$pub" 2>/dev/null &&
          files_saved="$(printf '%s' "$files_saved" | jq -c --arg k "$pub" --arg v "$out_dir/$aid/$pub" '. + {($k): $v}')"
      done < <(printf '%s' "$rec" | jq -r '
        (.files // {}) | if type == "object" then to_entries[]
          | [.key, (if (.value | type) == "string" then .value
                    elif (.value | type) == "object" then (.value.from // empty)
                    else empty end)] | @tsv else empty end' 2>/dev/null)
    fi
    jq --argjson r "$rec" --argjson s "$saved" --argjson fs "$files_saved" \
      '. + [$r + {saved: $s, savedFiles: $fs} | del(.files)]' "$tmp.out" > "$tmp.out2" && mv "$tmp.out2" "$tmp.out"
    i=$((i + 1))
  done

  manifest="$out_dir/manifest.json"
  jq -n --arg sid "$sid" --arg cwd "$cwd" --arg acct "$acct" --argjson now "$now" \
     --slurpfile a "$tmp.out" \
     '{sessionId: $sid, cwd: $cwd, account: $acct, collected: $now, artifacts: $a[0]}' > "$manifest.tmp" &&
    mv "$manifest.tmp" "$manifest"
  rm -f "$tmp.recs" "$tmp.out"
  # Keep the store bounded: drop snapshots untouched for 30+ days.
  find "$ARTIFACTS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} + 2>/dev/null
  jq -c --arg m "$manifest" '. + {manifest: $m}' "$manifest"
  exit 0
}

cmd_schedule_kill() {
  echo "phase-2 feature not enabled" >&2
  exit 1
}

main() {
  local sub="${1:-}"
  [ $# -gt 0 ] && shift
  case "$sub" in
    whoami)        cmd_whoami "$@";;
    sessions)      cmd_sessions "$@";;
    preflight)     cmd_preflight "$@";;
    path)          cmd_path "$@";;
    flag)          cmd_flag "$@";;
    status)        cmd_status "$@";;
    cancel)        cmd_cancel "$@";;
    history)       cmd_history "$@";;
    restore)       cmd_restore "$@";;
    auto)          cmd_auto "$@";;
    artifacts)     cmd_artifacts "$@";;
    schedule-kill) cmd_schedule_kill "$@";;
    *) usage;;
  esac
}

if [ "${BASH_SOURCE[0]:-}" = "$0" ]; then
  main "$@"
fi
