#!/usr/bin/env bash
# bgsh — background shell sessions (tmux), local and remote.
#
# Encodes the mechanics that are silently wrong when hand-typed: pane geometry,
# sentinel/exit-code correlation, quoting across ssh->send-keys->shell, and keeping the
# remote-session registry in step with reality. Judgment (when to use this at all, local vs
# remote, permission guardrails) lives in ../SKILL.md.
#
# The script is a CONSTRUCTOR, not a proxy: it builds real tmux sessions that you inspect
# with real tmux. `@purpose` on the session stays the source of truth.
#
# bash 3.2 compatible (stock macOS): no mapfile, no associative arrays, no ${v,,}.
set -uo pipefail

BG_HOME="${BG_HOME:-$HOME/.claude/background-shell}"
REG="$BG_HOME/remote-sessions.json"
TARGETS="$BG_HOME/targets.json"
LOGS="$BG_HOME/logs"
# Deliberately an unexpanded tilde: this string is interpolated into heredocs that the
# REMOTE shell parses, so it must expand there, not here. Replacing it with $HOME expands
# locally and silently points every remote path at this Mac's home directory.
# shellcheck disable=SC2088
REMOTE_DIR='~/.cache/cc-bg'
GEOM_X=200
GEOM_Y=50

die() { printf 'bgsh: %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }

init() {
  mkdir -p "$BG_HOME" "$LOGS"
  [ -f "$REG" ] || printf '[]\n' > "$REG"
  # Starts empty on purpose: guessing someone else's hosts is worse than requiring --reach
  # once. Add entries as  "name": "<full command prefix that gets a shell on that target>",
  # e.g.  "buildbox": "ssh -o ConnectTimeout=8 -o BatchMode=yes user@buildbox.example.com"
  [ -f "$TARGETS" ] || printf '{}\n' > "$TARGETS"
}

# Atomic registry write: never truncate on interrupt.
reg_write() { cat > "$REG.tmp" && mv "$REG.tmp" "$REG"; }

reach_for() {  # target -> reach string
  local t="$1" r
  r=$(jq -r --arg t "$t" '.[$t] // empty' "$TARGETS")
  [ -n "$r" ] || die "unknown target '$t' — add it to $TARGETS or pass --reach '<cmd>'"
  printf '%s' "$r"
}

# A sentinel tag must identify the RUN, not the session: the pane keeps scrollback, so a
# completed sentinel from an earlier run still matches and `wait` would return that run's
# exit code immediately. The tag is recorded locally so both local and remote reads agree.
new_nonce() {
  local n; n="${1}_$(date +%s)$$"
  printf '%s' "$n" > "$LOGS/${1}.nonce"
  printf '%s' "$n"
}
cur_nonce() { cat "$LOGS/${1}.nonce" 2>/dev/null; }

reg_reach() { jq -r --arg s "$1" '.[] | select(.session==$s) | .reach' "$REG" | head -1; }
reg_has()   { [ -n "$(reg_reach "$1")" ]; }

# Run a script (stdin) on a remote target. The command is NEVER passed as a quoted
# argument — that is what makes the remote shell see a bare '#' and treat it as a comment,
# silently truncating everything after it.
remote_exec() { local reach="$1"; eval "$reach" bash -s; }

# ---------------------------------------------------------------- new

cmd_new() {
  local target="" reach="" name="" purpose=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --on)    target="$2"; shift 2 ;;
      --reach) reach="$2";  shift 2 ;;
      -*)      die "unknown flag: $1" ;;
      *)       if [ -z "$name" ]; then name="$1"; else purpose="$purpose${purpose:+ }$1"; fi; shift ;;
    esac
  done
  [ -n "$name" ]    || die "usage: bgsh new [--on TARGET|--reach CMD] NAME \"purpose\""
  [ -n "$purpose" ] || die "a purpose is required — an unlabelled session is the thing this exists to prevent"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  # reach_for dies on an unknown target, but it is called in $( ) — a subshell — so its
  # exit(1) would only kill the subshell and we would fall through to creating a LOCAL
  # session under a name meant for a remote host. Propagate the failure explicitly.
  if [ -z "$reach" ] && [ -n "$target" ]; then
    reach=$(reach_for "$target") || exit 1
  fi

  if [ -z "$reach" ]; then
    if tmux has-session -t "$name" 2>/dev/null; then
      printf 'exists (local): %s :: %s\n' "$name" "$(tmux show-options -v -t "$name" @purpose 2>/dev/null)"
      return 0
    fi
    tmux new-session -d -s "$name" -x "$GEOM_X" -y "$GEOM_Y" || die "could not create $name"
    # window-size manual: without it, a human attaching permanently resizes the session
    # and every later capture-pane wraps at their terminal width instead of $GEOM_X.
    tmux set-option -t "$name" window-size manual >/dev/null
    tmux set-option -t "$name" @purpose "$purpose" >/dev/null
    tmux set-option -t "$name" status-right '#[bold] #{@purpose} ' >/dev/null
    printf 'created (local): %s :: %s\n' "$name" "$purpose"
  else
    remote_exec "$reach" <<EOS || die "remote create failed on ${target:-$reach}"
mkdir -p $REMOTE_DIR
tmux has-session -t $name 2>/dev/null || {
  tmux new-session -d -s $name -x $GEOM_X -y $GEOM_Y
  tmux set-option -t $name window-size manual
  tmux set-option -t $name @purpose '$purpose'
  tmux set-option -t $name status-right '#[bold] #{@purpose} '
}
EOS
    # Register in the SAME operation that created it. Split these and the registry drifts,
    # which is the exact failure the registry exists to prevent.
    jq --arg t "${target:-custom}" --arg r "$reach" --arg s "$name" --arg p "$purpose" \
       --arg c "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
       'map(select(.session != $s)) + [{target:$t,reach:$r,session:$s,purpose:$p,created:$c}]' \
       "$REG" | reg_write
    printf 'created (%s): %s :: %s  [registered]\n' "${target:-remote}" "$name" "$purpose"
  fi
}

# ---------------------------------------------------------------- run / peek
#
# DEFAULT MODE IS TTY: the command is typed into the pane and its output stays there, so
# this drives a real terminal — a held ssh, a REPL, a TUI. `cd` and exported vars persist,
# which is the whole point of a session.
#
# `--log` is the ALTERNATE mode: stdout/stderr are redirected to a file for a one-shot
# payload you want to capture rather than watch. It is wrong for a held session — it hides
# output from the pane and strands the sentinel where `wait` (which greps the pane) cannot
# see it. Read tty output with `peek`, logged output with `out`.

cmd_run() {
  local dry=0 log=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --dry) dry=1; shift ;;
      --log) log=1; shift ;;
      *) break ;;
    esac
  done
  local name="${1:-}"; shift || true
  local cmd="$*"
  [ -n "$name" ] && [ -n "$cmd" ] || die "usage: bgsh run [--dry] [--log] NAME 'command'"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")

  # ---- tty mode (default) ----
  if [ "$log" = 0 ]; then
    [ -z "$reach" ] || die "$name is a REMOTE session — those are launch-and-walk-away; use --log. \
To drive a tty on another host, hold an ssh inside a LOCAL session instead."
    tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name (bgsh new first)"
    # No ( ) wrap: cd and exports must persist across calls. A payload that calls `exit`
    # therefore ends the session — in a held ssh that is how you leave, not a bug.
    local nonce; nonce=$(new_nonce "$name")
    local payload="$cmd; printf '\\n__BG_${nonce}_RC=%s\\n' \"\$?\""
    if [ "$dry" = 1 ]; then printf 'would send-keys -t %s:\n  %s\n' "$name" "$payload"; return 0; fi
    tmux send-keys -t "$name" "$payload" Enter
    printf 'sent (tty): %s — read with: bgsh peek %s\n' "$name" "$name"
    return 0
  fi

  # ---- --log mode ----
  if [ -z "$reach" ]; then
    tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name (bgsh new first)"
    # --log redirects using a LOCAL path, but the redirect is performed by whatever shell the
    # pane is currently running. If the pane is holding an ssh, that path is resolved on the
    # REMOTE host, where it does not exist — the payload fails silently.
    case "$(tmux display-message -p -t "$name" '#{pane_current_command}' 2>/dev/null)" in
      ssh*|mosh*|telnet|kubectl|docker|autossh)   # ssh* also covers sshpass
        die "$name is holding an $(tmux display-message -p -t "$name" '#{pane_current_command}') \
session, so --log would redirect to a local path on the REMOTE host and fail silently. \
Use tty mode (drop --log) and read with: bgsh peek $name" ;;
    esac
    local out="$LOGS/$name.out"
    # ( ) subshell here because a logged payload is one-shot: a bare `exit` would otherwise
    # kill the session's own shell and strand the sentinel forever.
    local nonce; nonce=$(new_nonce "$name")
    local payload="( $cmd ) > '$out' 2>&1; printf '\\n__BG_${nonce}_RC=%s\\n' \"\$?\""
    if [ "$dry" = 1 ]; then printf 'would send-keys -t %s:\n  %s\n' "$name" "$payload"; return 0; fi
    tmux send-keys -t "$name" "$payload" Enter
    printf 'sent (log): %s -> %s\n' "$name" "$out"
  else
    if [ "$dry" = 1 ]; then printf 'would run on %s in %s:\n  ( %s )\n' "$reach" "$name" "$cmd"; return 0; fi
    # Write a script and send only its PATH. Inline commands cross ssh -> send-keys ->
    # shell (three quoting layers); "$?" written inline arrives as the literal text $?.
    local nonce; nonce=$(new_nonce "$name")
    remote_exec "$reach" <<EOS || die "remote run failed"
mkdir -p $REMOTE_DIR
cat > $REMOTE_DIR/$name.sh <<'SCRIPT'
#!/usr/bin/env bash
( $cmd ) > $REMOTE_DIR/$name.out 2>&1
printf '\n__BG_${nonce}_RC=%s\n' "\$?"
SCRIPT
chmod +x $REMOTE_DIR/$name.sh
tmux send-keys -t $name '$REMOTE_DIR/$name.sh' Enter
EOS
    printf 'sent (log, %s): %s -> %s/%s.out\n' "$reach" "$name" "$REMOTE_DIR" "$name"
  fi
}

cmd_peek() {
  local name="${1:-}" lines="${2:-40}"
  [ -n "$name" ] || die "usage: bgsh peek NAME [lines]"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")
  # Without this, peeking a REMOTE session reads local tmux, finds nothing, and exits 0 —
  # indistinguishable from "the job produced no output".
  if [ -n "$reach" ]; then
    remote_exec "$reach" <<EOS 2>/dev/null | grep -vE '^$|__BG_.*_RC=' | tail -n "$lines"
tmux capture-pane -pJ -S -5000 -t $name 2>/dev/null
EOS
    return 0
  fi
  tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name"
  tmux capture-pane -pJ -S -5000 -t "$name" 2>/dev/null \
    | grep -vE '^$|__BG_.*_RC=' | tail -n "$lines"
}

# ---------------------------------------------------------------- wait

# Sentinel grep REQUIRES digits: the pane also contains the echoed command line, which
# literally includes __BG_<name>_RC=%s. [0-9]* matches that and reports done mid-run.
sentinel_local()  {
  local n; n=$(cur_nonce "$1"); [ -n "$n" ] || return 0
  tmux capture-pane -pJ -S -2000 -t "$1" 2>/dev/null | grep -oE "__BG_${n}_RC=[0-9]+" | tail -1
}
sentinel_remote() {
  local n; n=$(cur_nonce "$1"); [ -n "$n" ] || return 0
  remote_exec "$2" <<EOS 2>/dev/null
tmux capture-pane -pJ -S -2000 -t $1 2>/dev/null | grep -oE '__BG_${n}_RC=[0-9]+' | tail -1
EOS
}

cmd_wait() {
  local name="${1:-}" timeout="${2:-300}"
  [ -n "$name" ] || die "usage: bgsh wait NAME [timeout_seconds]"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")
  local waited=0 hit=""
  while [ "$waited" -lt "$timeout" ]; do
    if [ -z "$reach" ]; then
      tmux has-session -t "$name" 2>/dev/null || die "session $name is GONE (payload may have exited the shell)"
      hit=$(sentinel_local "$name")
    else
      hit=$(sentinel_remote "$name" "$reach")
    fi
    [ -n "$hit" ] && { printf '%s\n' "$hit"; return "${hit##*=}"; }
    sleep 3; waited=$((waited + 3))
  done
  printf 'bgsh: no sentinel after %ss — still running, hung, or never started\n' "$timeout" >&2
  [ -z "$reach" ] && printf '  pane is running: %s\n' \
    "$(tmux display-message -p -t "$name" '#{pane_current_command}' 2>/dev/null)" >&2
  return 124
}

# ---------------------------------------------------------------- out

cmd_out() {
  local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh out NAME"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")
  if [ -z "$reach" ]; then
    [ -f "$LOGS/$name.out" ] || die "no output yet for $name"
    cat "$LOGS/$name.out"
  else
    remote_exec "$reach" <<EOS
cat $REMOTE_DIR/$name.out 2>/dev/null || echo "bgsh: no output yet for $name" >&2
EOS
  fi
}

# ---------------------------------------------------------------- ls / remote-ls

cmd_ls() {
  printf '== local ==\n'
  tmux ls -F '#{session_name}  started=#{t:session_created}  cmd=#{pane_current_command}  :: #{@purpose}' 2>/dev/null \
    || printf '(none)\n'
  local n; n=$(jq 'length' "$REG")
  [ "$n" -gt 0 ] && { printf '\n'; cmd_remote_ls; }
  return 0
}

cmd_remote_ls() {
  printf '== remote (registry: %s) ==\n' "$REG"
  local n; n=$(jq 'length' "$REG")
  [ "$n" -eq 0 ] && { printf '(none registered)\n'; return 0; }
  # Read into an array FIRST. Calling ssh inside `while read` consumes the loop's stdin
  # and silently checks only the first entry.
  local entries=() line
  while IFS= read -r line; do entries+=("$line"); done \
    < <(jq -r '.[] | [.target,.reach,.session] | @tsv' "$REG")
  local e target reach session out
  for e in "${entries[@]}"; do
    IFS=$(printf '\t') read -r target reach session <<< "$e"
    out=$(remote_exec "$reach" <<EOS 2>/dev/null
tmux ls -F '#{session_name}|#{pane_current_command}|#{@purpose}' 2>/dev/null | grep "^$session|"
EOS
)
    if [ -n "$out" ]; then printf 'LIVE   %-8s %s\n' "$target" "$out"
    elif remote_exec "$reach" <<< 'echo ok' >/dev/null 2>&1; then
      printf 'STALE  %-8s %s (host reachable, session gone -> bgsh prune)\n' "$target" "$session"
    else
      printf 'UNSURE %-8s %s (host unreachable — NOT evidence it ended; check ssh-add -l)\n' "$target" "$session"
    fi
  done
}

# ---------------------------------------------------------------- prune / kill

cmd_prune() {
  local entries=() line
  while IFS= read -r line; do entries+=("$line"); done \
    < <(jq -r '.[] | [.target,.reach,.session] | @tsv' "$REG")
  [ "${#entries[@]}" -eq 0 ] && { printf 'registry empty\n'; return 0; }
  local e target reach session out
  for e in "${entries[@]}"; do
    IFS=$(printf '\t') read -r target reach session <<< "$e"
    out=$(remote_exec "$reach" <<EOS 2>/dev/null
tmux has-session -t $session 2>/dev/null && echo LIVE
EOS
)
    if [ -n "$out" ]; then printf 'keep  %s (live)\n' "$session"; continue; fi
    # Only prune on a VERIFIED stale. An unreachable host is not evidence the session
    # ended, and dropping the entry loses the only pointer to something still running.
    if remote_exec "$reach" <<< 'echo ok' >/dev/null 2>&1; then
      jq --arg s "$session" 'map(select(.session != $s))' "$REG" | reg_write
      printf 'prune %s (verified gone)\n' "$session"
    else
      printf 'keep  %s (host unreachable — refusing to prune on no evidence)\n' "$session"
    fi
  done
}

cmd_kill() {
  local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh kill NAME"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")
  if [ -z "$reach" ]; then
    tmux kill-session -t "$name" 2>/dev/null && printf 'killed (local): %s\n' "$name" || printf 'no local session: %s\n' "$name"
    rm -f "$LOGS/$name.out"
  else
    # Deregister ONLY if the host actually answered. Dropping the entry after an
    # unreachable kill loses the only pointer to a session that may still be running —
    # the exact failure `prune` refuses to make.
    if remote_exec "$reach" <<EOS
tmux kill-session -t $name 2>/dev/null && echo "killed (remote): $name" || echo "no remote session: $name"
rm -f $REMOTE_DIR/$name.sh $REMOTE_DIR/$name.out
EOS
    then
      jq --arg s "$name" 'map(select(.session != $s))' "$REG" | reg_write
      printf 'deregistered: %s\n' "$name"
    else
      printf 'bgsh: %s unreachable — session NOT killed and entry KEPT (retry later)\n' "$reach" >&2
      return 1
    fi
  fi
}

cmd_attach() {
  local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh attach NAME"
  case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
  local reach; reach=$(reg_reach "$name")
  printf 'Run this yourself (bgsh cannot attach for you):\n'
  if [ -z "$reach" ]; then printf '  tmux attach -t %s        # ^b d to detach\n' "$name"
  else printf '  %s -t "tmux attach -t %s"\n' "$reach" "$name"; fi
}

usage() {
  cat <<'EOF'
bgsh — background shell sessions (tmux), local and remote

  bgsh ls                                 local + registry roll call
  bgsh new [--on TGT|--reach CMD] NAME "purpose"
  bgsh run [--dry] NAME 'command'         TTY (default): output stays in the pane, cd persists
  bgsh run --log NAME 'command'           alternate: capture to a file instead (one-shot)
  bgsh peek NAME [lines]                  read the pane (tty mode)
  bgsh wait NAME [timeout=300]            blocks; exits with the payload's exit code
  bgsh out NAME                           read the --log file
  bgsh attach NAME                        prints the attach command
  bgsh remote-ls                          verify every registry entry
  bgsh prune                              drop only VERIFIED-stale entries
  bgsh kill NAME                          kill + deregister + remove logs

Remote sessions are registered automatically by `new --on`. Local ones are not — tmux ls
already finds those. Guardrail: host mutation (config-management apply, sudo, /etc) stays on plain
Bash; a session is not a way around a permission prompt.
EOF
}

need tmux; need jq; init
case "${1:-}" in
  ls)        shift; cmd_ls "$@" ;;
  new)       shift; cmd_new "$@" ;;
  run)       shift; cmd_run "$@" ;;
  send)      shift; cmd_run "$@" ;;
  peek)      shift; cmd_peek "$@" ;;
  wait)      shift; cmd_wait "$@" ;;
  out)       shift; cmd_out "$@" ;;
  attach)    shift; cmd_attach "$@" ;;
  remote-ls) shift; cmd_remote_ls "$@" ;;
  prune)     shift; cmd_prune "$@" ;;
  kill)      shift; cmd_kill "$@" ;;
  ""|-h|--help|help) usage ;;
  *) die "unknown subcommand: $1 (try: bgsh help)" ;;
esac
