#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# Dory Worker — Install & Manage Script
#
# This script ships INSIDE the @nikx/dory-worker npm package and is invoked via
# the package's `dory-worker` bin (cli.js dispatches management subcommands here).
# It can also be run directly during development: ./scripts/setup-worker.sh <cmd>
#
# Usage:
#   dory-worker setup          Full one-command setup (recommended)
#   dory-worker install        Install Node.js, Docker, and dory-worker
#   dory-worker configure      Interactive .env configuration
#   dory-worker start          Start the worker (foreground)
#   dory-worker start-bg       Start the worker as a systemd service
#   dory-worker stop           Stop the systemd service
#   dory-worker restart        Restart the systemd service
#   dory-worker status         Show worker service status
#   dory-worker logs           Tail the worker logs
#   dory-worker update         Update dory-worker to latest version
#   dory-worker pull-image     Pull the latest Docker scraper image
#   dory-worker health         Check connectivity (Redis, API, Docker)
#   dory-worker uninstall      Remove systemd service
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail

# How this script refers to itself in user-facing messages. When invoked through
# the package bin the entrypoint is `dory-worker <cmd>`, so default to that; allow
# an override (e.g. a curl|bash bootstrap) via DORY_SELF.
SELF="${DORY_SELF:-dory-worker}"

WORKER_DIR="${DORY_WORKER_DIR:-$HOME/dory-worker}"
SERVICE_NAME="dory-worker"
ENV_FILE="$WORKER_DIR/.env"
# 20.6 is the first release with stable `node --env-file`, which both the
# foreground and service (systemd/launchd) paths rely on to load .env.
NODE_MIN_MAJOR=20
NODE_MIN_MINOR=6
REQUIRED_CMDS=(node npm docker)

# Set to true when Docker was freshly installed (or the user isn't in the
# docker group yet), so the final setup summary can tell them to re-login.
NEEDS_DOCKER_RELOGIN=false

# ── Helpers ──────────────────────────────────────────────────────────────────

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'

info()  { echo -e "${CYAN}[INFO]${NC}  $*"; }
ok()    { echo -e "${GREEN}[OK]${NC}    $*"; }
warn()  { echo -e "${YELLOW}[WARN]${NC}  $*"; }
err()   { echo -e "${RED}[ERROR]${NC} $*"; }
die()   { err "$@"; exit 1; }

# Check for a real executable on PATH. Uses `type -P` (not `command -v`) so it
# matches ONLY disk binaries — the `docker()` shell function defined below would
# otherwise make `command -v docker` succeed even when Docker isn't installed.
command_exists() { type -P "$1" &>/dev/null; }

# Run docker, falling back to `sudo docker` when the current shell can't reach
# the daemon. This is the common case right after a fresh Docker install: the
# user was added to the `docker` group but that membership isn't active until
# they re-login, so plain `docker` fails with a permission error mid-setup.
# Using sudo lets `setup` finish in one run without forcing a logout.
docker() {
  if docker_works; then
    command docker "$@"
  else
    sudo docker "$@"
  fi
}

# True when the current session can talk to the Docker daemon without sudo.
# Cached so we don't probe on every call.
_DOCKER_DIRECT=""
docker_works() {
  if [[ -z "$_DOCKER_DIRECT" ]]; then
    if command docker info &>/dev/null; then
      _DOCKER_DIRECT=yes
    else
      _DOCKER_DIRECT=no
    fi
  fi
  [[ "$_DOCKER_DIRECT" == "yes" ]]
}

detect_os() {
  if [[ "$OSTYPE" == "linux-gnu"* ]]; then
    if command_exists apt-get; then echo "debian"
    elif command_exists yum; then echo "rhel"
    elif command_exists apk; then echo "alpine"
    else echo "linux"; fi
  elif [[ "$OSTYPE" == "darwin"* ]]; then echo "macos"
  elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then echo "windows"
  else echo "unknown"; fi
}

# dory-worker setup supports Linux and macOS only. Native Windows has no
# systemd/launchd and Docker there is GUI-driven Docker Desktop, so we can't do
# a hands-off install. Fail early with a clear pointer to WSL2 (where this script
# runs as a normal Linux install) instead of dying cryptically deep in setup.
require_supported_os() {
  local os; os=$(detect_os)
  if [[ "$os" == "windows" || "$os" == "unknown" ]]; then
    err "dory-worker setup supports Linux and macOS only (detected: $os)."
    if [[ "$os" == "windows" ]]; then
      err "On Windows, install WSL2 (a Linux distro) and run this inside it:"
      err "  wsl --install        # then reopen the WSL terminal and re-run setup"
    fi
    die "Unsupported platform."
  fi
}

check_node_version() {
  if ! command_exists node; then return 1; fi
  local major minor
  major=$(node -v | sed 's/v//' | cut -d. -f1)
  minor=$(node -v | sed 's/v//' | cut -d. -f2)
  (( major > NODE_MIN_MAJOR )) || { (( major == NODE_MIN_MAJOR )) && (( minor >= NODE_MIN_MINOR )); }
}

# ── install ──────────────────────────────────────────────────────────────────

cmd_install() {
  local os
  os=$(detect_os)
  info "Detected OS: $os"

  # --- Node.js (must be pre-installed) ---
  # Node/npm is the bootstrap dependency: this CLI and the dory-worker package
  # are themselves installed via npm, so we can't install Node from here. Just
  # require it and tell the user where to get it.
  if check_node_version; then
    ok "Node.js $(node -v) already installed"
  else
    die "Node.js >= $NODE_MIN_MAJOR.$NODE_MIN_MINOR is required. Install it first: https://nodejs.org/"
  fi

  # --- Docker ---
  if command_exists docker; then
    ok "Docker already installed: $(docker --version)"
  else
    info "Installing Docker..."
    case "$os" in
      debian|rhel|linux)
        curl -fsSL https://get.docker.com | sudo sh
        sudo usermod -aG docker "$USER"
        # Make sure the daemon is up (get.docker.com enables it on most distros,
        # but not all) so the rest of setup can use it via sudo.
        sudo systemctl enable --now docker 2>/dev/null || true
        # Verify it actually came up; if not, the later pull/health steps would
        # fail confusingly. Surface it clearly instead.
        if ! sudo docker info &>/dev/null; then
          warn "Docker installed but the daemon isn't running yet."
          warn "Start it with: sudo systemctl start docker   (then re-run '$SELF setup')"
        fi
        NEEDS_DOCKER_RELOGIN=true
        warn "Added '$USER' to the docker group. Setup will continue using sudo,"
        warn "but you must log out and back in before the worker service can run."
        ;;
      macos)
        # Docker containers are Linux, so on macOS they must run inside a VM.
        # We use Colima (headless, license-free) as the runtime plus the docker
        # CLI, both via Homebrew. Homebrew is a hard prerequisite here.
        if ! command_exists brew; then
          die "Homebrew is required to install Docker on macOS.
  Install it with:
    /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"
  Then re-run '$SELF install'."
        fi
        # docker = CLI client, colima = headless Linux VM running the daemon.
        brew install colima docker
        # Start the VM so the daemon is reachable for the rest of setup
        # (image pull, health checks). No re-login needed — colima runs as the
        # current user.
        info "Starting Colima (Docker runtime)..."
        colima start
        # Auto-start Colima at login/boot so the launchd worker service finds a
        # running daemon after a reboot (the macOS analogue of Linux's
        # docker.service dependency).
        brew services start colima 2>/dev/null || true
        ;;
      *) die "Install Docker manually: https://docs.docker.com/engine/install/" ;;
    esac
  fi

  # --- Worker directory ---
  mkdir -p "$WORKER_DIR"
  info "Worker directory: $WORKER_DIR"

  # --- Install dory-worker into WORKER_DIR ---
  # Even when this script runs from a global `dory-worker` install, the SERVICE
  # gets its own pinned, self-contained install under WORKER_DIR (alongside .env
  # and the helper scripts). This keeps the systemd/launchd ExecStart path stable
  # and immune to unrelated `npm i -g` churn, and lets `update` bump the service
  # copy independently of the launcher.
  #
  # DORY_WORKER_PACKAGE lets you override the install source — pin a version,
  # install from a tarball, or use a local yalc/file: package for testing.
  # Defaults to the latest published release.
  local worker_pkg="${DORY_WORKER_PACKAGE:-@nikx/dory-worker@latest}"
  info "Installing $worker_pkg..."
  cd "$WORKER_DIR"
  npm init -y &>/dev/null 2>&1 || true
  npm install "$worker_pkg"
  ok "@nikx/dory-worker installed ($(npx dory-worker --version 2>/dev/null || echo 'latest'))"

  # --- Generate .env template, or offer to reconfigure an existing one ---
  # On a brand-new node we always run the wizard. On a re-run (the common case
  # for `update`/OTA or just re-invoking setup) a .env already exists; offer to
  # reconfigure but DEFAULT TO KEEPING it so idempotent re-runs don't clobber a
  # working config.
  #
  # Whether to reconfigure is decided in this order:
  #   1. DORY_RECONFIGURE=1  → force reconfigure (works non-interactively too,
  #      e.g. CI, `curl|bash`, or any wrapper that pipes stdin — these have no
  #      TTY, so a prompt would never appear there).
  #   2. interactive TTY      → ask, defaulting to keep.
  #   3. otherwise (no TTY, no flag) → keep silently rather than hang on `read`.
  if [[ ! -f "$ENV_FILE" ]]; then
    cmd_configure
  elif [[ "${DORY_RECONFIGURE:-}" == "1" ]]; then
    ok ".env already exists at $ENV_FILE"
    info "DORY_RECONFIGURE=1 — reconfiguring (current values shown as defaults)."
    cmd_configure
  else
    ok ".env already exists at $ENV_FILE"
    if [[ -t 0 ]]; then
      local reconfigure
      read -rp "Reconfigure it (Worker ID)? [y/N] " reconfigure
      if [[ "$reconfigure" =~ ^[Yy]$ ]]; then
        cmd_configure
      else
        info "Keeping existing configuration. Run '$SELF configure' anytime to change it."
      fi
    else
      warn "Non-interactive shell — keeping existing config."
      warn "Run '$SELF configure' to change it, or re-run with DORY_RECONFIGURE=1."
    fi
  fi

  echo ""
  ok "Installation complete!"
  info "Run '$SELF setup' for a full one-command setup (install + pull + health + start)"
}

# ── configure ────────────────────────────────────────────────────────────────

cmd_configure() {
  info "Generating .env configuration..."
  echo ""

  local worker_id

  # If a .env already exists, pull its current Worker ID so it becomes the
  # bracketed default below — pressing Enter then KEEPS the existing setting
  # instead of reverting to the generic placeholder. (Last assignment wins, per
  # dotenv semantics; ignore the commented escape-hatch lines.)
  local cur_worker_id=""
  if [[ -f "$ENV_FILE" ]]; then
    cur_worker_id=$(grep -E '^WORKER_ID=' "$ENV_FILE" | tail -n1 | cut -d= -f2-)
  fi
  local def_worker_id="${cur_worker_id:-dory-worker-$(hostname -s)}"

  # The API URL is set ONCE, by `dory-worker login`, and is the single source of
  # truth — setup never prompts for it and never lets it diverge from the URL the
  # worker key was issued against (a mismatch would mean authenticating to one
  # API but talking to another). Read it from ~/.dory/credentials. The
  # credential stores it WITH a /api suffix (login normalises it); .env's
  # API_BASE_URL is the base WITHOUT /api (the worker appends it at runtime), so
  # strip the suffix here.
  local cred_file="$HOME/.dory/credentials"
  local api_url=""
  if [[ -f "$cred_file" ]]; then
    api_url=$(grep -oE '"apiBaseUrl"[[:space:]]*:[[:space:]]*"[^"]*"' "$cred_file" \
      | head -n1 | sed -E 's/.*"apiBaseUrl"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')
    api_url="${api_url%/api}"
    api_url="${api_url%/}"
  fi
  if [[ -z "$api_url" ]]; then
    err "No API URL found. Run 'dory-worker login' first."
    exit 1
  fi

  # The API is the single source of truth for EVERYTHING operational — Redis
  # connection, queue name, docker image, concurrency, container count, log
  # level. The worker fetches it over HTTP on startup (before connecting to
  # Redis) and hot-reloads on change. Results upload through the API too, so no
  # GCS/GCP creds here. The API URL comes from your login; setup only needs a
  # name for this worker.
  echo "Everything operational (Redis, queue, image, concurrency, …) is served"
  echo "by the API and fetched on startup."
  echo "API URL (from your login): ${api_url}"
  echo ""

  read -rp "Worker ID [${def_worker_id}]: " worker_id
  worker_id="${worker_id:-$def_worker_id}"

  # Write .env — only the two bootstrap keys, plus a commented escape hatch.
  cat > "$ENV_FILE" <<ENVEOF
# ──────────────────────────────────────────────────────────────
# Dory Worker Configuration (bootstrap only)
# Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
#
# The API is the source of truth for ALL operational config — Redis URL, queue
# name (QUEUE_RUN_EXECUTION), DOCKER_IMAGE, CRAWLER_REDIS_URL, CONTAINER_COUNT,
# MAX_CONCURRENT_RUNS, LOG_LEVEL. The worker fetches it on startup and
# hot-reloads on change, so do NOT set those here. Results upload through the
# API, so no GCS bucket or GCP credentials are needed on this node.
#
# Only these two keys live locally (a worker needs them just to reach the API):
# ──────────────────────────────────────────────────────────────

# dory-api URL — must be reachable from this machine AND from Docker containers
API_BASE_URL=${api_url}

# Unique worker identifier (shown in run participants and used for per-worker
# config overrides in the API)
WORKER_ID=${worker_id}
ENVEOF

  # Commented escape hatches. Two independent uses:
  #   1. Bootstrap fallback — if the API is unreachable on a brand-new node
  #      (no cached config yet), uncomment REDIS_URL + QUEUE_RUN_EXECUTION so
  #      the worker can still start. Normally the API supplies both.
  #   2. CONFIG_SOURCE=local — ignore the API entirely and pin this one node.
  cat >> "$ENV_FILE" <<'ENVEOF'

# ── Optional: bootstrap fallback (only if the API is unreachable at first boot) ─
# The API normally serves these; uncomment to let a fresh node start offline.
# Must match the Redis/queue the API uses.
# REDIS_URL=redis://localhost:6379
# QUEUE_RUN_EXECUTION=run-execution

# ── Optional: pin this node to local config (ignore API config) ──────────────
# Uncomment CONFIG_SOURCE and any keys below to override the API on this machine.
# CONFIG_SOURCE=local
# DOCKER_IMAGE=bynikx/dory-actor:v3
# CRAWLER_REDIS_URL=redis://localhost:6379
# CONTAINER_COUNT=3
# MAX_CONCURRENT_RUNS=2
# LOG_LEVEL=info
ENVEOF

  chmod 600 "$ENV_FILE"
  ok "Configuration written to $ENV_FILE"
}

# ── start (foreground) ───────────────────────────────────────────────────────

cmd_start() {
  cd "$WORKER_DIR"
  [[ -f "$ENV_FILE" ]] || die ".env not found. Run '$SELF configure' first."
  info "Starting dory-worker (foreground)... Press Ctrl+C to stop."
  exec node --env-file="$ENV_FILE" node_modules/@nikx/dory-worker/dist/cli.js
}

# ── systemd service ─────────────────────────────────────────────────────────

_systemd_unit_path() { echo "/etc/systemd/system/${SERVICE_NAME}.service"; }

# Path to the connectivity-wait helper, installed alongside the worker.
_wait_helper_path() { echo "$WORKER_DIR/wait-for-connectivity.sh"; }

# Write a small script that blocks until the API host:port from .env accepts a
# TCP connection, then exits 0. Used as the boot-time gate (systemd ExecStartPre
# / launchd wrapper) so the worker process is only launched once the network is
# actually up. This closes the "boots offline and the restart guard gives up"
# gap WITHOUT hot-looping: while offline the worker process never starts, so it
# never produces failing exits that trip StartLimitBurst.
#
# Uses only bash + /dev/tcp so it runs inside the systemd ProtectSystem=strict /
# NoNewPrivileges sandbox (no curl/nc/extra binaries needed). A bounded overall
# timeout means a genuinely-misconfigured endpoint (wrong API_BASE_URL, dead
# DNS) still fails loudly instead of hanging "activating" forever.
_install_wait_helper() {
  local helper
  helper=$(_wait_helper_path)

  cat > "$helper" <<'WAITEOF'
#!/usr/bin/env bash
# Block until the worker's API endpoint is TCP-reachable. Generated by
# setup-worker.sh — do not edit; re-run 'setup-worker.sh start-bg' to refresh.
set -euo pipefail

ENV_FILE="${1:?usage: wait-for-connectivity.sh <env-file>}"

# Read API_BASE_URL without sourcing the env file (it may contain values that
# aren't safe to eval). Last assignment wins, matching dotenv semantics.
api_url="$(grep -E '^API_BASE_URL=' "$ENV_FILE" | tail -n1 | cut -d= -f2-)"
[[ -n "$api_url" ]] || { echo "wait-for-connectivity: API_BASE_URL not set in $ENV_FILE" >&2; exit 1; }

# Strip scheme, path, and any user@; derive host and port (default by scheme).
hostport="${api_url#*://}"
hostport="${hostport%%/*}"
hostport="${hostport##*@}"
host="${hostport%%:*}"
if [[ "$hostport" == *:* ]]; then
  port="${hostport##*:}"
elif [[ "$api_url" == https://* ]]; then
  port=443
else
  port=80
fi

# Overall budget so a permanently-bad endpoint fails instead of hanging forever.
# Generous enough to ride out slow Wi-Fi/VPN/DHCP at boot.
deadline=$(( $(date +%s) + 300 ))

until (exec 3<>"/dev/tcp/${host}/${port}") 2>/dev/null; do
  if (( $(date +%s) >= deadline )); then
    echo "wait-for-connectivity: ${host}:${port} unreachable after 300s; giving up" >&2
    exit 1
  fi
  sleep 5
done
exec 3>&- 2>/dev/null || true
echo "wait-for-connectivity: ${host}:${port} reachable"
WAITEOF

  chmod +x "$helper"
}

# When SELinux is enforcing (Fedora/RHEL default), a system service runs as the
# confined `init_t` domain and is DENIED access to files labeled user_home_t —
# so a worker installed under /home fails to start with a confusing
# "Permission denied / Failed to load environment files" even though the Unix
# perms are correct. Relabel WORKER_DIR to bin_t (which init_t may read/exec) and
# make it persistent so a relabel/reboot doesn't revert it. No-op when SELinux is
# off or the dir already lives somewhere init_t can reach (e.g. /opt).
_fix_selinux_context() {
  command_exists getenforce || return 0
  [[ "$(getenforce 2>/dev/null)" == "Enforcing" ]] || return 0
  # Only home dirs carry the problematic label; leave /opt, /srv, etc. alone.
  case "$WORKER_DIR" in /home/*|"$HOME"/*) ;; *) return 0 ;; esac

  info "SELinux is enforcing — labeling $WORKER_DIR so the service can read it..."

  # Persistent rule (survives reboots / `restorecon -R /`). semanage lives in
  # policycoreutils-python-utils; install it if missing so this stays one-shot.
  if ! command_exists semanage; then
    if command_exists dnf; then
      sudo dnf install -y policycoreutils-python-utils &>/dev/null || true
    elif command_exists yum; then
      sudo yum install -y policycoreutils-python-utils &>/dev/null || true
    fi
  fi

  if command_exists semanage; then
    sudo semanage fcontext -a -t bin_t "${WORKER_DIR}(/.*)?" 2>/dev/null \
      || sudo semanage fcontext -m -t bin_t "${WORKER_DIR}(/.*)?" 2>/dev/null || true
  else
    warn "semanage unavailable — applying a temporary label (lost on full relabel)."
  fi

  # Apply now. With the fcontext rule above, restorecon makes it stick; without
  # it (semanage missing) chcon at least gets the service running this boot.
  if command_exists restorecon && command_exists semanage; then
    sudo restorecon -R "$WORKER_DIR" &>/dev/null || true
  elif command_exists chcon; then
    sudo chcon -R -t bin_t "$WORKER_DIR" &>/dev/null || true
  fi
  ok "SELinux context applied to $WORKER_DIR"
}

cmd_start_bg() {
  local os
  os=$(detect_os)

  if [[ "$os" == "macos" ]]; then
    _launchd_install
    return
  fi

  [[ -f "$ENV_FILE" ]] || die ".env not found. Run '$SELF configure' first."

  local unit_path
  unit_path=$(_systemd_unit_path)

  _install_wait_helper
  _fix_selinux_context

  info "Creating systemd service..."
  sudo tee "$unit_path" > /dev/null <<EOF
[Unit]
Description=Dory Worker — distributed scraping agent
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
# Restart-loop guard: if the worker restarts more than 5 times in 5 minutes
# (e.g. Redis is genuinely down so the watchdog trips every cycle), stop
# trying instead of hot-looping and hammering the API/Redis. A node stuck in
# this state shows as "failed" and stops heartbeating, which surfaces it in
# the dashboard. systemctl reset-failed (or a reboot) clears it once fixed.
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
User=$USER
WorkingDirectory=$WORKER_DIR
EnvironmentFile=$ENV_FILE
# Boot-time connectivity gate: block until the API host is TCP-reachable before
# launching the worker, so a machine that boots offline (slow Wi-Fi/VPN/DHCP)
# waits for the network instead of failing fast and tripping the restart guard.
# Bounded by TimeoutStartSec below so a misconfigured endpoint still fails loud.
ExecStartPre=$(_wait_helper_path) $ENV_FILE
TimeoutStartSec=320
ExecStart=$(which node) $WORKER_DIR/node_modules/@nikx/dory-worker/dist/cli.js
# Exit-code-driven restart so the worker can distinguish stop from restart
# without SSH access:
#   exit 0  (EXIT_STOP)    → SuccessExitStatus → stay down (clean operator stop)
#   exit 75 (EXIT_RESTART) → RestartForceExitStatus → relaunch
#                            (remote restart, OTA self-update, watchdog trip)
# on-failure also covers crashes (non-zero, non-listed) and watchdog kills.
Restart=on-failure
RestartForceExitStatus=75
SuccessExitStatus=0
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$SERVICE_NAME

# Resource limits
LimitNOFILE=65536
MemoryMax=1G

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=$WORKER_DIR /tmp

[Install]
WantedBy=multi-user.target
EOF

  sudo systemctl daemon-reload
  sudo systemctl enable "$SERVICE_NAME"
  sudo systemctl start "$SERVICE_NAME"
  ok "Service started. Check: $SELF status"
}

_launchd_install() {
  [[ -f "$ENV_FILE" ]] || die ".env not found. Run '$SELF configure' first."

  local plist_path="$HOME/Library/LaunchAgents/com.dory.worker.plist"
  local node_path
  node_path=$(which node)

  # NB: no connectivity wait-helper on macOS — the worker self-times-out its API
  # calls, and launchd SIGKILLs bash /dev/tcp probes. (The helper is still used
  # by the Linux systemd ExecStartPre path.)

  # On macOS the Docker daemon is Colima, which isn't guaranteed up when the
  # launchd agent fires at login (it may race brew services). Ensure it's
  # running before exec'ing the worker. Resolve the absolute path now since
  # launchd runs with a minimal PATH that omits Homebrew's bin dir.
  # On macOS the Docker daemon is Colima. Docker is only needed lazily (when a
  # job spawns a container), NOT to start the worker, so we must NOT gate the
  # worker on it: a slow/failing 'colima start' would otherwise crash-loop the
  # whole service via launchd KeepAlive (the worker exits, relaunches, retries
  # colima, repeat). Instead, kick Colima off in the BACKGROUND and let the
  # worker come up and heartbeat immediately; Colima warms up in parallel and is
  # ready by the time a job arrives. If it never comes up, only container
  # spawning fails (surfaced per-job), not the worker's liveness.
  local colima_ensure=""
  local colima_path
  colima_path=$(command -v colima 2>/dev/null || true)
  if [[ -n "$colima_path" ]]; then
    # If Colima is already up (the normal case — brew services starts it at
    # login), do nothing. Only if it's down do we kick a start in the
    # BACKGROUND so it can't block/crash-loop the worker. The start runs in a
    # detached subshell '( ... ) &' with its own output redirected to a log;
    # note the redirect is INSIDE the subshell so it doesn't collide with the
    # '&' that backgrounds it.
    colima_ensure="echo \"[wrapper] checking Colima\"; if ! ${colima_path} status >/dev/null 2>&1; then echo \"[wrapper] Colima down — starting in background\"; ( ${colima_path} start >\"${WORKER_DIR}/colima.log\" 2>&1 ) & fi; "
  fi

  # launchd agents run with a minimal environment, so the wrapper's HOME/PATH
  # can differ from the login shell. Colima keeps per-user state under $HOME and
  # its CLI lives in Homebrew's bin — if either is wrong, 'colima status' inside
  # the wrapper misreports "not up" and needlessly restarts it (the restart loop
  # we saw). Pin HOME and a PATH that includes the dirs where colima/node
  # actually live so the wrapper sees the same Colima the user does.
  local svc_path="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
  [[ -n "$colima_path" ]] && svc_path="$(dirname "$colima_path"):$svc_path"
  svc_path="$(dirname "$node_path"):$svc_path"

  info "Creating launchd service..."

  # Load the .env via Node's --env-file (same as the foreground path) rather
  # than hand-parsing it into the plist. This keeps both platforms on one env
  # mechanism and avoids truncating values that contain '=' (e.g. Redis URLs).
  cat > "$plist_path" <<EOF
<?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>com.dory.worker</string>
    <!-- Nudge Colima up (non-blocking) then exec the worker directly. We do NOT
         gate on a connectivity wait here: the worker now bounds its own API
         fetches/heartbeats with timeouts and retries, and macOS launchd
         SIGKILLs bash's /dev/tcp probes (the old wait-helper failed with
         "Killed: 9"). 'exec' keeps node as the supervised process so KeepAlive
         tracks the real worker, not the wrapper. -->
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>-c</string>
        <string>${colima_ensure}exec ${node_path} --env-file="${ENV_FILE}" ${WORKER_DIR}/node_modules/@nikx/dory-worker/dist/cli.js</string>
    </array>
    <key>WorkingDirectory</key>
    <string>${WORKER_DIR}</string>
    <!-- Match the login shell's HOME/PATH so the wrapper's 'colima status'
         resolves the same per-user VM and binaries the user sees. -->
    <key>EnvironmentVariables</key>
    <dict>
        <key>HOME</key>
        <string>${HOME}</string>
        <key>PATH</key>
        <string>${svc_path}</string>
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <!-- Relaunch only on a non-successful exit so a clean stop (exit 0) stays
         down, while a restart/OTA/watchdog exit (75) or a crash relaunches.
         Mirrors the systemd Restart=on-failure policy. -->
    <key>KeepAlive</key>
    <dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>
    <!-- Throttle relaunches to once per 30s so a persistently-wedged worker
         (e.g. Redis down) can't hot-loop. launchd has no burst cap like
         systemd's StartLimitBurst, but throttling avoids the tight spin. -->
    <key>ThrottleInterval</key>
    <integer>30</integer>
    <key>StandardOutPath</key>
    <string>${WORKER_DIR}/worker.log</string>
    <key>StandardErrorPath</key>
    <string>${WORKER_DIR}/worker-error.log</string>
</dict>
</plist>
EOF

  _install_log_rotation

  launchctl unload "$plist_path" 2>/dev/null || true
  launchctl load "$plist_path"
  ok "launchd service loaded. Logs: $WORKER_DIR/worker.log"
}

# launchd writes StandardOut/ErrorPath to plain files with NO rotation, so the
# worker logs grow unbounded (the Linux systemd path logs to size-capped
# journald instead). Register the three log files with macOS's built-in
# newsyslog — the OS-native log rotator, already scheduled, so no extra tooling.
# Rotate at 5 MB, keep 5 compressed generations (~25 MB ceiling per file).
_install_log_rotation() {
  local conf="/etc/newsyslog.d/dory-worker.conf"
  # newsyslog format: <logfile> <owner:group> <mode> <count> <size_KB> <when> <flags>
  # 'J' = compress rotated files with bzip2; '*' in 'when' = size-triggered only.
  local user; user=$(id -un)
  local group; group=$(id -gn)
  local body
  body=$(cat <<CONF
# Managed by dory-worker setup — rotate worker logs so they can't fill the disk.
# logfile                                  owner:group   mode count size  when flags
$WORKER_DIR/worker.log                      $user:$group  644  5     5120  *    J
$WORKER_DIR/worker-error.log                $user:$group  644  5     5120  *    J
$WORKER_DIR/colima.log                      $user:$group  644  3     5120  *    J
CONF
)
  # /etc/newsyslog.d needs root. Don't fail setup if sudo is declined — rotation
  # is a nice-to-have; warn so the user can add it later.
  if printf '%s\n' "$body" | sudo tee "$conf" >/dev/null 2>&1; then
    ok "Log rotation configured (newsyslog): $conf"
  else
    warn "Could not write $conf (needs sudo) — worker logs won't auto-rotate."
    warn "Re-run setup with sudo access, or add the file manually, to enable it."
  fi
}

cmd_stop() {
  local os
  os=$(detect_os)
  if [[ "$os" == "macos" ]]; then
    launchctl unload "$HOME/Library/LaunchAgents/com.dory.worker.plist" 2>/dev/null
    ok "Service stopped"
  else
    sudo systemctl stop "$SERVICE_NAME"
    ok "Service stopped"
  fi
}

cmd_restart() {
  local os
  os=$(detect_os)
  if [[ "$os" == "macos" ]]; then
    cmd_stop
    cmd_start_bg
  else
    sudo systemctl restart "$SERVICE_NAME"
    ok "Service restarted"
  fi
}

# Print the effective worker config (image, queue, …). The API is the source of
# truth and the worker caches the last-applied config to .last-good-config.json,
# so read the docker image and friends from there.
print_config_summary() {
  # Installed dory-worker package version (what this node is running).
  local pkgver
  pkgver=$(node -p "require('@nikx/dory-worker/package.json').version" 2>/dev/null \
    || node -p "require('$WORKER_DIR/node_modules/@nikx/dory-worker/package.json').version" 2>/dev/null \
    || echo "unknown")
  info "dory-worker version: ${pkgver}"

  local cache="$WORKER_DIR/.last-good-config.json"
  [[ -f "$cache" ]] || { echo ""; return 0; }
  local image queue version
  image=$(grep -o '"dockerImage":"[^"]*"' "$cache" | head -1 | cut -d'"' -f4)
  queue=$(grep -o '"runExecutionQueue":"[^"]*"' "$cache" | head -1 | cut -d'"' -f4)
  version=$(grep -o '"version":[0-9]*' "$cache" | head -1 | cut -d: -f2)
  info "Effective config (from API, cached v${version:-?}):"
  echo "    Docker image : ${image:-<not set — pulled from API per-run>}"
  echo "    Queue        : ${queue:-<unknown>}"
  echo ""
}

cmd_status() {
  local os
  os=$(detect_os)
  print_config_summary
  if [[ "$os" == "macos" ]]; then
    # launchd's `list` line is "PID  LastExitStatus  Label". Parse it so we can
    # report the same kind of state systemctl gives on Linux instead of a raw
    # three-column dump the user has to decode.
    local line pid exit_code
    line=$(launchctl list | grep -i 'com\.dory\.worker' || true)
    if [[ -z "$line" ]]; then
      warn "Service not loaded (run '$SELF start-bg' to install it)."
    else
      pid=$(awk '{print $1}' <<<"$line")
      exit_code=$(awk '{print $2}' <<<"$line")
      if [[ "$pid" == "-" ]]; then
        # No PID = launchd loaded the agent but the process isn't running.
        # A non-zero last exit means it started and died — the wrapper or the
        # worker bailed. Point at the error log.
        err "Worker process NOT running (last exit code: $exit_code)."
        warn "It started and exited — check the error log below."
      else
        ok "Worker process running (PID $pid, last exit code: $exit_code)."
      fi
    fi

    # Is Colima (the Docker daemon) up? A wedged 'colima start' in the launchd
    # wrapper is the usual reason a running PID produces an empty worker.log.
    echo ""
    if command_exists colima && colima status &>/dev/null; then
      ok "Colima (Docker runtime) is running."
    else
      warn "Colima is NOT running — the worker's boot wrapper may be stuck"
      warn "starting it, which blocks heartbeats. Try: colima start"
    fi

    echo ""
    if [[ -s "$WORKER_DIR/worker.log" ]]; then
      info "Last 10 log lines ($WORKER_DIR/worker.log):"
      tail -10 "$WORKER_DIR/worker.log"
    else
      warn "worker.log is empty/missing — the worker hasn't reached its main"
      warn "loop yet (likely blocked in the boot wrapper on Colima/connectivity)."
    fi
    if [[ -s "$WORKER_DIR/worker-error.log" ]]; then
      echo ""
      warn "Recent errors ($WORKER_DIR/worker-error.log):"
      tail -10 "$WORKER_DIR/worker-error.log"
    fi
  else
    sudo systemctl status "$SERVICE_NAME" --no-pager -l
  fi
}

cmd_logs() {
  local os
  os=$(detect_os)
  if [[ "$os" == "macos" ]]; then
    [[ -f "$WORKER_DIR/worker.log" ]] || die "No logs found at $WORKER_DIR/worker.log"
    tail -f "$WORKER_DIR/worker.log"
  else
    journalctl -u "$SERVICE_NAME" -f --no-pager
  fi
}

# ── update ───────────────────────────────────────────────────────────────────

cmd_update() {
  cd "$WORKER_DIR"
  info "Updating @nikx/dory-worker..."
  npm update @nikx/dory-worker
  ok "Updated to $(npm list @nikx/dory-worker --depth=0 2>/dev/null | grep dory-worker || echo 'latest')"
  warn "Restart the service to apply: $SELF restart"
}

# ── pull-image ───────────────────────────────────────────────────────────────

cmd_pull_image() {
  [[ -f "$ENV_FILE" ]] || die ".env not found. Run '$SELF configure' first."
  # DOCKER_IMAGE is normally served by the API and is the single source of truth;
  # .env usually does NOT set it. There is no sane local default — guessing an
  # image name only produces confusing pull failures. So if .env doesn't pin one,
  # skip the convenience pre-pull entirely; the worker pulls the API-provided
  # image at run time.
  source <(grep '^DOCKER_IMAGE=' "$ENV_FILE" || true)
  local image="${DOCKER_IMAGE:-}"
  if [[ -z "$image" ]]; then
    info "DOCKER_IMAGE not set in .env — skipping pre-pull; the worker will pull the API-provided image at run time."
    return 0
  fi
  info "Pulling Docker image: $image"
  # Pre-pulling is a convenience, not a hard requirement: the actor image may be
  # in a private registry the worker authenticates to later, and the worker
  # always uses the API-provided image name at run time. So a failed pull must
  # NOT abort setup — warn and continue. (Without this guard, set -e kills the
  # whole run on a private/unreachable image.)
  if docker pull "$image" 2>&1; then
    ok "Image pulled: $image"
  else
    warn "Could not pre-pull '$image' (private registry or not published yet)."
    warn "The worker will pull the API-provided image at run time. Continuing."
    return 0
  fi
}

# ── health ───────────────────────────────────────────────────────────────────

cmd_health() {
  [[ -f "$ENV_FILE" ]] || die ".env not found. Run '$SELF configure' first."

  local all_ok=true

  # Source relevant vars. Redis is normally served by the API, so REDIS_URL may
  # be absent here — that's expected. We resolve it from the API below.
  local api_url="" worker_id="" redis_url="" docker_image=""
  while IFS='=' read -r key value; do
    [[ -z "$key" || "$key" == \#* ]] && continue
    case "$key" in
      API_BASE_URL) api_url="$value" ;;
      WORKER_ID) worker_id="$value" ;;
      REDIS_URL) redis_url="$value" ;;
      DOCKER_IMAGE) docker_image="$value" ;;
    esac
  done < "$ENV_FILE"

  # Check Docker. Distinguish three states:
  #   - reachable directly        → all good
  #   - only reachable via sudo   → daemon is up, but THIS user isn't in the
  #     docker group yet (fresh install). setup can finish via sudo, but the
  #     worker service runs as $USER and won't be able to spawn containers
  #     until the user re-logins. Surface this loudly.
  #   - not reachable at all      → daemon down / not installed
  if command docker info &>/dev/null; then
    ok "Docker is running"
  elif sudo docker info &>/dev/null; then
    warn "Docker is running, but '$USER' can't access it without sudo yet."
    warn "Log out and back in (or run 'newgrp docker') so the worker service can spawn containers."
    NEEDS_DOCKER_RELOGIN=true
  else
    err "Docker is not running or not accessible"
    all_ok=false
  fi

  # Check Docker image. Not having it locally is NOT fatal: the worker pulls the
  # API-provided image at run time, and that image often lives in a private
  # registry. So this is informational — don't fail health (or block setup's
  # service start) just because the image hasn't been pre-pulled.
  if [[ -z "$docker_image" ]]; then
    info "DOCKER_IMAGE not set in .env — image is served by the API and pulled at run time."
  elif docker image inspect "$docker_image" &>/dev/null; then
    ok "Docker image exists: $docker_image"
  else
    warn "Docker image '$docker_image' not present locally — the worker will pull it at run time."
  fi

  # Check API via its dedicated health endpoint (unauthenticated, side-effect
  # free). Returns 200 when Mongo+Redis are up, 503 otherwise.
  if [[ -n "$api_url" ]]; then
    local health_body
    if health_body=$(curl -sf --connect-timeout 5 "$api_url/api/health" 2>/dev/null); then
      ok "API healthy: $api_url ($health_body)"
    else
      err "API unhealthy or unreachable: $api_url/api/health"
      all_ok=false
    fi
  fi

  # Resolve the Redis URL the worker will actually use: a local REDIS_URL
  # override wins (escape hatch); otherwise ask the API — the same source the
  # worker reads — so health checks exactly what the worker will connect to.
  if [[ -z "$redis_url" && -n "$api_url" ]]; then
    local cfg_url cfg_json
    cfg_url="${api_url%/}/api/workers/config?workerId=$(printf '%s' "${worker_id}" | sed 's/ /%20/g')"
    if cfg_json=$(curl -sf --connect-timeout 5 "$cfg_url" 2>/dev/null); then
      # Pull redisUrl out of the JSON without requiring jq.
      redis_url=$(printf '%s' "$cfg_json" | grep -oE '"redisUrl"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"redisUrl"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')
    fi
  fi

  if [[ -z "$redis_url" ]]; then
    warn "Could not resolve a Redis URL (API didn't provide one and no REDIS_URL override)."
    warn "Skipping Redis check — the worker resolves Redis from the API at startup."
  else
    local rhost rport hostport
    # Strip scheme, then any userinfo up to the LAST '@' (the password may itself
    # contain ':' or '@', e.g. redis://default:PASS@host:port), then any /path.
    hostport="${redis_url#*://}"
    hostport="${hostport##*@}"
    hostport="${hostport%%/*}"
    rhost="${hostport%:*}"
    if [[ "$hostport" == *:* ]]; then
      rport="${hostport##*:}"
    else
      rport=6379
    fi

    if _redis_reachable "$rhost" "$rport"; then
      ok "Redis reachable: $rhost:$rport"
    else
      err "Redis unreachable: $rhost:$rport"
      all_ok=false
    fi
  fi

  echo ""
  if $all_ok; then
    ok "All health checks passed — ready to run!"
  else
    err "Some checks failed — fix the issues above before starting"
    return 1
  fi
}

# True if Redis answers at host:port. Prefers redis-cli PING; falls back to a
# bare TCP connect when redis-cli isn't installed.
_redis_reachable() {
  local rhost="$1" rport="$2"
  if command_exists redis-cli; then
    redis-cli -h "$rhost" -p "$rport" ping &>/dev/null
  else
    (echo > /dev/tcp/"$rhost"/"$rport") &>/dev/null 2>&1
  fi
}

# ── uninstall ────────────────────────────────────────────────────────────────

cmd_uninstall() {
  local os
  os=$(detect_os)

  read -rp "Remove dory-worker service? [y/N] " confirm
  [[ "$confirm" =~ ^[Yy]$ ]] || { info "Cancelled."; return; }

  if [[ "$os" == "macos" ]]; then
    launchctl unload "$HOME/Library/LaunchAgents/com.dory.worker.plist" 2>/dev/null || true
    rm -f "$HOME/Library/LaunchAgents/com.dory.worker.plist"
    sudo rm -f /etc/newsyslog.d/dory-worker.conf 2>/dev/null || true
  else
    sudo systemctl stop "$SERVICE_NAME" 2>/dev/null || true
    sudo systemctl disable "$SERVICE_NAME" 2>/dev/null || true
    sudo rm -f "$(_systemd_unit_path)"
    sudo systemctl daemon-reload
  fi

  ok "Service removed. Worker files remain at $WORKER_DIR"
  info "To fully remove: rm -rf $WORKER_DIR"
}

# ── setup (all-in-one) ───────────────────────────────────────────────────────

cmd_setup() {
  require_supported_os
  echo ""
  echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo -e "${CYAN}  Dory Worker — Full Setup${NC}"
  echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  echo ""

  # Step 1: Install deps + configure
  info "Step 1/4: Installing dependencies & configuring..."
  cmd_install
  echo ""

  # Step 2: Pull Docker image
  info "Step 2/4: Pulling Docker image..."
  cmd_pull_image
  echo ""

  # Step 3: Health check
  info "Step 3/4: Running health checks..."
  if ! cmd_health; then
    err "Health checks failed — fix the issues above, then run: $SELF start-bg"
    return 1
  fi
  echo ""

  # Step 4: Start as background service
  info "Step 4/4: Starting background service..."
  cmd_start_bg

  echo ""
  if [[ "$NEEDS_DOCKER_RELOGIN" == "true" ]]; then
    echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
    echo -e "${YELLOW}  Almost done — ONE manual step required${NC}"
    echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
    echo ""
    warn "Docker was just installed, so '$USER' isn't in the docker group yet."
    warn "The worker service is installed but can't spawn containers until you:"
    echo ""
    info "  1. Log out and back in   (or run: newgrp docker)"
    info "  2. Restart the worker:   $SELF restart"
    echo ""
    info "Then verify with: $SELF health  &&  $SELF status"
  else
    echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
    echo -e "${GREEN}  Dory Worker is running!${NC}"
    echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
  fi
  echo ""
  info "Useful commands:"
  info "  $SELF status    — check if running"
  info "  $SELF logs      — tail live logs"
  info "  $SELF restart   — restart after config change"
  info "  $SELF update    — update to latest version"
}

# ── Main dispatch ────────────────────────────────────────────────────────────

case "${1:-help}" in
  setup)       cmd_setup ;;
  install)     cmd_install ;;
  configure)   cmd_configure ;;
  start)       cmd_start ;;
  start-bg)    cmd_start_bg ;;
  stop)        cmd_stop ;;
  restart)     cmd_restart ;;
  status)      cmd_status ;;
  logs)        cmd_logs ;;
  update)      cmd_update ;;
  pull-image)  cmd_pull_image ;;
  health)      cmd_health ;;
  uninstall)   cmd_uninstall ;;
  help|*)
    echo "Usage: $SELF <command>"
    echo ""
    echo "Quick start:"
    echo "  login        Authenticate to Dory and provision this worker (run first)"
    echo "  setup        Full one-command setup (install + configure + pull + health + start)"
    echo ""
    echo "Auth commands:"
    echo "  login        Log in with your Dory credentials; saves a worker key"
    echo "  logout       Remove the stored worker key"
    echo "  whoami       Show the current login"
    echo ""
    echo "Individual commands:"
    echo "  install      Install Node.js, Docker, and @nikx/dory-worker"
    echo "  configure    Interactive .env configuration wizard"
    echo "  start        Start worker in foreground"
    echo "  start-bg     Install and start as background service (systemd/launchd)"
    echo "  stop         Stop the background service"
    echo "  restart      Restart the background service"
    echo "  status       Show service status"
    echo "  logs         Tail worker logs"
    echo "  update       Update dory-worker to latest npm version"
    echo "  pull-image   Pull the latest Docker scraper image"
    echo "  health       Check connectivity (Docker, API, Redis)"
    echo "  uninstall    Remove the background service"
    ;;
esac
