#!/usr/bin/env bash
# VibeControls Agent installer (Linux / macOS).
#
# Usage:
#   curl -fsSL https://app.vibecontrols.com/install | bash
#
# Environment variables (optional):
#   VIBE_PKG_MANAGER     — force "npm" or "bun" (default: bun, the agent runtime)
#   VIBE_BUN_VERSION     — override the bun floor (default: read from
#                          @vibecontrols/agent's package.json engines.bun, or
#                          1.3.0 if the manifest doesn't declare one).
#                          Existing bun installs are only upgraded when their
#                          version is BELOW the floor; we never downgrade.
#   VIBE_AGENT_VERSION   — pin @vibecontrols/agent version (default: latest)
#   VIBE_INSTALL_PLUGINS — space-separated plugin list (default: discovered core plugins)
#   VIBE_SKIP_PLUGINS    — set to "1" to skip plugin install
#   VIBE_NPM_REGISTRY    — npm registry to query/install from (default: https://registry.npmjs.org/)
#
# Behaviour:
#   1. Detects OS + arch (Linux/macOS, x64/arm64).
#   2. ALWAYS ensures Bun >= the agent's engines.bun floor — the agent's runtime
#      is Bun, so its `vibe` shim cannot run without it whatever PM is used.
#   3. Picks a package manager: bun by default (npm via VIBE_PKG_MANAGER=npm).
#   4. Cleans up stale / conflicting `vibe` shims left by a previous install
#      (nvm / npm / bun / /usr/local) so cached hashes never resolve to a dead
#      path and the canonical bun shim wins.
#   5. Installs @vibecontrols/agent globally.
#   5. Resolves each requested plugin against the npm registry; silently drops any that
#      don't exist; installs the rest globally.
#   6. Auto-rebinds a stale cached `vibe` path so reinstalls need NO `hash -r`;
#      only hints `hash -r` / a new shell when it genuinely can't fix it.

set -euo pipefail

VIBE_PKG_MANAGER="${VIBE_PKG_MANAGER:-}"
VIBE_BUN_VERSION="${VIBE_BUN_VERSION:-}"
VIBE_AGENT_VERSION="${VIBE_AGENT_VERSION:-latest}"
VIBE_NPM_REGISTRY="${VIBE_NPM_REGISTRY:-https://registry.npmjs.org/}"
VIBE_SKIP_PLUGINS="${VIBE_SKIP_PLUGINS:-0}"

# Default plugin set: only packages confirmed to exist on the registry will actually
# get installed (see resolve_plugins). Names here are the *intended* core set —
# install will skip silently if any have been renamed or aren't published yet.
DEFAULT_PLUGINS="@vibecontrols/vibe-plugin-session-tmux \
@vibecontrols/vibe-plugin-tunnel-cloudflare \
@vibecontrols/vibe-plugin-tool-ssh \
@vibecontrols/vibe-plugin-ai"

VIBE_INSTALL_PLUGINS="${VIBE_INSTALL_PLUGINS:-$DEFAULT_PLUGINS}"

log()  { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
err()  { printf '\033[1;31mERR\033[0m %s\n' "$*" >&2; exit 1; }

OS="$(uname -s)"
case "$OS" in
  Linux)  PLATFORM=linux  ;;
  Darwin) PLATFORM=darwin ;;
  *) err "Unsupported OS: $OS — on Windows use install.ps1 instead." ;;
esac

log "Detected platform: $PLATFORM"

# Path the *calling* shell would have hashed for `vibe` on its first exec. A
# child shell resolves an uncached name identically to the parent given the same
# inherited PATH, so reading it now — BEFORE ensure_bun prepends ~/.bun/bin —
# equals the parent's cached entry, if any. Only trust an absolute path. After
# install we rebind this path (step 4b) so the parent shell's cached hash
# resolves to the new binary with NO manual `hash -r`.
PRE_VIBE_PATH="$(command -v vibe 2>/dev/null || true)"
case "$PRE_VIBE_PATH" in
  /*) ;;
  *) PRE_VIBE_PATH="" ;;
esac

# --- 0. Resolve required Bun version from the agent's package.json ------------
# Single source of truth: the published @vibecontrols/agent package.json
# `engines.bun`. We pull it from the npm registry (no jq required — the
# response is small enough for a sed regex). Fall back to 1.3.0 when the
# registry is unreachable or the field is missing, so a transient outage
# never leaves the user without an install.
resolve_bun_floor() {
  if [ -n "$VIBE_BUN_VERSION" ]; then
    printf '%s' "$VIBE_BUN_VERSION"
    return
  fi
  local manifest_url="${VIBE_NPM_REGISTRY%/}/@vibecontrols%2fagent/${VIBE_AGENT_VERSION}"
  local body
  body=$(curl -fsSL --max-time 10 "$manifest_url" 2>/dev/null || echo "")
  local engines_bun
  engines_bun=$(printf '%s' "$body" | sed -nE 's/.*"engines"[^{]*\{[^}]*"bun"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -1)
  local floor
  floor=$(printf '%s' "$engines_bun" | sed -E 's/[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
  [ -z "$floor" ] && floor="1.3.0"
  printf '%s' "$floor"
}

# semver_ge a b — returns 0 iff $a >= $b (each "X.Y.Z"). Used to compare the
# current bun --version against the resolved floor; avoids the string equality
# false-mismatch that would otherwise trigger an unnecessary downgrade.
semver_ge() {
  local a="${1#v}" b="${2#v}"
  local IFS=.
  # shellcheck disable=SC2206
  local -a A=($a) B=($b)
  for i in 0 1 2; do
    if [ "${A[$i]:-0}" -gt "${B[$i]:-0}" ]; then return 0; fi
    if [ "${A[$i]:-0}" -lt "${B[$i]:-0}" ]; then return 1; fi
  done
  return 0
}

BUN_FLOOR="$(resolve_bun_floor)"
log "Required Bun floor: >=${BUN_FLOOR}"

# --- 1. Clean stale `vibe` shims from common global bin paths -----------------
# Reason: a previous install via `npm i -g` (or an earlier `bun add -g`) may have
# left a symlink in a bin directory that's now dangling. Because bash caches the
# resolved path in its in-process hash table on first exec, every shell that ran
# `vibe` once keeps trying the dead path with `ENOENT` until `hash -r`. This
# prunes DANGLING shims; the live-old-binary case is handled by the step-4b
# rebind, which makes the cached path resolve to the new binary automatically.
prune_stale_shim() {
  local p="$1"
  [ -L "$p" ] || return 0
  local target
  target=$(readlink "$p" 2>/dev/null || true)
  [ -n "$target" ] || return 0
  # Resolve relative target against the symlink's directory.
  case "$target" in
    /*) ;;
    *) target="$(dirname "$p")/$target" ;;
  esac
  if [ ! -e "$target" ]; then
    rm -f "$p" 2>/dev/null || true
    log "removed stale vibe shim: $p"
  fi
}

candidate_bins=(
  "$HOME/.bun/bin"
  "$HOME/.local/bin"
  "/usr/local/bin"
  "/opt/homebrew/bin"
)
# nvm node versions
if [ -d "$HOME/.nvm/versions/node" ]; then
  while IFS= read -r d; do candidate_bins+=("$d/bin"); done < <(find "$HOME/.nvm/versions/node" -maxdepth 1 -mindepth 1 -type d 2>/dev/null)
fi
# fnm / volta / asdf — best-effort
[ -d "$HOME/.fnm/node-versions" ] && while IFS= read -r d; do candidate_bins+=("$d/installation/bin"); done < <(find "$HOME/.fnm/node-versions" -maxdepth 1 -mindepth 1 -type d 2>/dev/null)
[ -d "$HOME/.volta/bin" ] && candidate_bins+=("$HOME/.volta/bin")

for d in "${candidate_bins[@]}"; do
  [ -d "$d" ] || continue
  prune_stale_shim "$d/vibe"
done

# --- 2. Ensure Bun (the agent runtime), then pick a package manager ----------
# The agent's `vibe` binary carries a `#!/usr/bin/env bun` shebang and the
# agent uses bun:sqlite, so Bun is a HARD prerequisite regardless of which PM
# fetches the tarball. Ensure it UNCONDITIONALLY — the previous "install bun
# only when no PM is found" path left hosts that had npm-but-not-bun with a
# `vibe` shim that couldn't find its interpreter.
ensure_bun() {
  if ! command -v bun >/dev/null 2>&1 && [ -x "$HOME/.bun/bin/bun" ]; then
    export PATH="$HOME/.bun/bin:$PATH"
  fi
  if ! command -v bun >/dev/null 2>&1; then
    log "Bun not found — installing (latest matching >=${BUN_FLOOR})"
    curl -fsSL "https://bun.com/install" | BUN_INSTALL="$HOME/.bun" bash
    export PATH="$HOME/.bun/bin:$PATH"
    command -v bun >/dev/null 2>&1 || err "bun install failed — ensure \$HOME/.bun/bin is on PATH and retry"
  fi
  # Verify the floor; upgrade only when below. Never downgrade — users who pin
  # a newer Bun keep theirs.
  current_bun_ver="$(bun --version 2>/dev/null || echo "")"
  if [ -n "$current_bun_ver" ] && semver_ge "$current_bun_ver" "$BUN_FLOOR"; then
    log "Bun ${current_bun_ver} (required: >=${BUN_FLOOR}) ✓"
  else
    log "Upgrading bun (${current_bun_ver:-not installed}) → latest (required: >=${BUN_FLOOR})"
    curl -fsSL "https://bun.com/install" | BUN_INSTALL="$HOME/.bun" bash
    export PATH="$HOME/.bun/bin:$PATH"
  fi
}
ensure_bun

# Default to bun: it is guaranteed present (ensure_bun) and IS the runtime, so
# the `vibe` shim it creates always resolves. Installing via npm under an old
# Node also prints EBADENGINE noise for the agent's Node>=20/22 deps — bun
# doesn't. npm stays an explicit opt-in via VIBE_PKG_MANAGER=npm.
pick_pm() {
  if [ -n "$VIBE_PKG_MANAGER" ]; then
    case "$VIBE_PKG_MANAGER" in
      bun) echo bun; return ;;
      npm)
        if command -v npm >/dev/null 2>&1; then echo npm; return; fi
        warn "VIBE_PKG_MANAGER=npm but npm is not on PATH — falling back to bun."
        echo bun; return ;;
      *) err "VIBE_PKG_MANAGER must be 'npm' or 'bun' (got '$VIBE_PKG_MANAGER')" ;;
    esac
  fi
  echo bun
}

PM="$(pick_pm)"

# npm path: the generated `vibe` shim still needs Bun at runtime (ensured
# above). Warn on old Node so the EBADENGINE warnings read as harmless.
if [ "$PM" = "npm" ]; then
  node_major="$(node --version 2>/dev/null | sed -E 's/^v?([0-9]+).*/\1/')"
  if [ -n "$node_major" ] && [ "$node_major" -lt 20 ] 2>/dev/null; then
    warn "Node $(node --version 2>/dev/null) is below 20 — npm will print harmless EBADENGINE warnings for the agent's deps. The agent runs on Bun, so they don't affect it. Use bun (the default) to avoid them."
  fi
fi

log "Using package manager: $PM"

# --- 3. Validate plugin existence against the npm registry --------------------
# A registry HEAD on the package endpoint returns 200 if it exists, 404 if not.
# We use curl (already a hard dep of this script) so we don't depend on either
# pm being installed yet.
plugin_exists() {
  local pkg="$1"
  local url
  # Encode the @scope/name → @scope%2fname for the registry URL.
  url="${VIBE_NPM_REGISTRY%/}/$(printf '%s' "$pkg" | sed 's|/|%2f|')"
  local code
  # Note: no -f here — we want the HTTP code on 404 too. -f would emit both the
  # status code (via -w) AND short-circuit to "|| echo 000", concatenating them.
  code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null || echo "000")
  [ "$code" = "200" ]
}

resolve_plugins() {
  local out=""
  local pkg
  for pkg in $VIBE_INSTALL_PLUGINS; do
    if plugin_exists "$pkg"; then
      out="$out $pkg"
    else
      warn "skipping $pkg — not found on $VIBE_NPM_REGISTRY"
    fi
  done
  echo "$out"
}

# --- 4. Install agent + plugins ----------------------------------------------
install_global() {
  local args="$*"
  [ -z "${args// /}" ] && return 0
  if [ "$PM" = "npm" ]; then
    # shellcheck disable=SC2086
    npm install -g --registry "$VIBE_NPM_REGISTRY" $args
  else
    # shellcheck disable=SC2086
    bun add -g $args
  fi
}

log "Installing @vibecontrols/agent@${VIBE_AGENT_VERSION} via $PM"
install_global "@vibecontrols/agent@${VIBE_AGENT_VERSION}"

if [ "$VIBE_SKIP_PLUGINS" != "1" ]; then
  log "Resolving core plugins against $VIBE_NPM_REGISTRY"
  PLUGINS_TO_INSTALL="$(resolve_plugins)"
  if [ -n "${PLUGINS_TO_INSTALL// /}" ]; then
    log "Installing plugins:${PLUGINS_TO_INSTALL}"
    # shellcheck disable=SC2086
    install_global $PLUGINS_TO_INSTALL
  else
    warn "No core plugins resolved — agent installed without plugins."
  fi
fi

# When bun is the installer, drop any npm-generated `vibe` shim so the bun shim
# in ~/.bun/bin is canonical regardless of PATH order. We remove only the shim
# file — never run `npm uninstall`, which would trigger the agent's
# preuninstall data wipe.
if [ "$PM" = "bun" ] && command -v npm >/dev/null 2>&1; then
  npm_bin="$(npm bin -g 2>/dev/null || true)"
  [ -z "$npm_bin" ] && npm_bin="$(npm prefix -g 2>/dev/null)/bin"
  if [ -n "$npm_bin" ] && [ "$npm_bin" != "$HOME/.bun/bin" ]; then
    if [ -e "$npm_bin/vibe" ] || [ -L "$npm_bin/vibe" ]; then
      rm -f "$npm_bin/vibe" 2>/dev/null && log "removed conflicting npm vibe shim: $npm_bin/vibe"
    fi
  fi
fi

# --- 4b. Stable-path auto-fix ------------------------------------------------
# Make the *calling* shell's cached `vibe` resolve to the freshly-installed
# binary with NO manual `hash -r`. Acts only when the pre-existing hashed path
# differs from the new canonical binary, its directory is writable, and the
# basename is exactly `vibe` (never clobber an unrelated tool). The dangling-shim
# case is already handled by step 1's pruner; this closes the LIVE-old-binary
# (nvm / npm `vibe`) gap the pruner intentionally skips — the reported footgun.
NEW_VIBE="$HOME/.bun/bin/vibe"
[ "$PM" = "npm" ] && NEW_VIBE="$(command -v vibe 2>/dev/null || echo "$NEW_VIBE")"
STABLE_PATH_FIXED=0
NEEDS_NEW_SHELL=0
auto_stable_path() {
  [ -n "$PRE_VIBE_PATH" ] || return 0       # nothing before → fresh-PATH case, handled below
  [ -e "$NEW_VIBE" ]      || return 0
  [ "$PRE_VIBE_PATH" -ef "$NEW_VIBE" ] && return 0   # already the same inode
  case "$PRE_VIBE_PATH" in */vibe) ;; *) return 0 ;; esac
  local dir; dir="$(dirname "$PRE_VIBE_PATH")"
  [ -w "$dir" ] || return 0                 # can't fix in place → `&& hash -r` suffix recovers it
  # Atomic replace via temp symlink + mv so a concurrent `vibe` never sees a gap.
  local tmp; tmp="$(mktemp "$dir/.vibe.XXXXXX" 2>/dev/null)" || return 0
  rm -f "$tmp" 2>/dev/null || true
  if ln -s "$NEW_VIBE" "$tmp" 2>/dev/null && mv -f "$tmp" "$PRE_VIBE_PATH" 2>/dev/null; then
    STABLE_PATH_FIXED=1
    log "rebound cached vibe path $PRE_VIBE_PATH -> $NEW_VIBE (no 'hash -r' needed)"
  else
    rm -f "$tmp" 2>/dev/null || true
  fi
}
auto_stable_path

# Detect the FRESH-PATH case precisely: no prior vibe, and ~/.bun/bin not on the
# live PATH yet. `hash -r` does NOT help here — only a new shell / re-source does.
if [ -z "$PRE_VIBE_PATH" ]; then
  case ":$PATH:" in
    *":$HOME/.bun/bin:"*) ;;
    *) NEEDS_NEW_SHELL=1 ;;
  esac
fi

# --- 5. Next steps -----------------------------------------------------------
# Hint is CONDITIONAL: step 4b already rebinds a stale cached path on disk, so
# the common reinstall case needs nothing. We only mention hash -r / a new shell
# when we genuinely couldn't fix it for the user.
printf '\n\033[1;32m✓ VibeControls agent installed\033[0m\n\n'
if [ "$NEEDS_NEW_SHELL" = "1" ]; then
  # Fresh install; ~/.bun/bin is in your rc but not in THIS shell's live PATH.
  printf "Almost there — %s isn't on this shell's PATH yet (hash -r will NOT help).\n" "$HOME/.bun/bin"
  printf 'Run:  export PATH="%s:$PATH"; hash -r\n' "$HOME/.bun/bin"
  printf '  (or just open a new terminal — your shell rc already has it)\n\n'
elif [ "$STABLE_PATH_FIXED" = "1" ]; then
  printf 'Your shell is ready — just run:  vibe start\n\n'
else
  printf "If 'vibe' still resolves to an old path, run:  hash -r   (zsh: rehash)\n\n"
fi
printf 'Next steps:\n  1. Start the agent:          vibe start\n'
printf '  2. Print your tunnel + key:  vibe tunnel agent && vibe key\n'
printf '  3. Register in the UI:       Targets & Agents -> Add Agent\n\n'
printf 'Docs:  https://vibecontrols.com/docs/agent\n'
