#!/usr/bin/env bash
# Pensato Compute Fabric — no-sudo Linux bootstrap installer
# See: docs/plans/curl-bash-no-sudo-linux-installer-2026-06-08.md

set -euo pipefail

DOCS_URL="https://fabric.pensato.io/"
DEFAULT_BASE_URL="https://fabric.pensato.io"

# Lifecycle flag — defaults to true (persistent user services active).
# exec_run flips to "false" when the Polkit-linger fallback fires.
PENSATO_LINGER_ACTIVE=true

die() {
  printf '[FAIL] %s\n' "$*" >&2
  exit 2
}

detect_platform() {
  # Manifest-driven dispatch (matches rustup / uv pattern):
  # Build the full platform triple (arch + libc) and let manifest lookup decide
  # whether an artifact is available. Don't reject arch or libc at detection.
  local uname_s="${PENSATO_UNAME_S:-$(uname -s)}"
  local uname_m="${PENSATO_UNAME_M:-$(uname -m)}"

  case "$uname_s" in
    Linux) ;;
    Darwin) die "macOS uses the .dmg installer, not curl|bash. See ${DOCS_URL}" ;;
    MINGW*|MSYS*|CYGWIN*) die "Windows uses the .exe installer, not curl|bash. See ${DOCS_URL}" ;;
    *) die "Unsupported OS: ${uname_s}. See ${DOCS_URL}" ;;
  esac

  local arch
  case "$uname_m" in
    x86_64|amd64) arch="x64" ;;
    aarch64|arm64) arch="arm64" ;;
    *) die "Unsupported architecture: ${uname_m}. See ${DOCS_URL}" ;;
  esac

  local libc_suffix=""
  if detect_musl; then
    libc_suffix="-musl"
  fi

  printf 'linux-%s%s\n' "$arch" "$libc_suffix"
}

require_command() {
  local cmd="$1"
  local hint="$2"
  command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: ${cmd}. ${hint}"
}

detect_package_manager() {
  # Order matters: prefer dnf over yum (Fedora/RHEL 8+), apt-get over apt
  # (apt is interactive-only on some distros). Mirrors Docker get.docker.com
  # and Tailscale install.sh dispatch logic.
  for pm in dnf apt-get yum pacman zypper apk; do
    if command -v "$pm" >/dev/null 2>&1; then
      printf '%s\n' "$pm"
      return 0
    fi
  done
  return 1
}

# Map a required command to its package name on the detected PM. Returns
# the package name on stdout, or empty if the cmd needs no package (already
# part of base system on all supported distros — like awk, tail).
map_command_to_package() {
  local cmd="$1"
  local pm="$2"
  case "$cmd:$pm" in
    # zstd binary (needed by tar --zstd on most distros that shell-out to it)
    zstd:dnf|zstd:yum|zstd:apt-get|zstd:pacman|zstd:zypper|zstd:apk) printf 'zstd\n' ;;
    # findutils — find(1) on minimal images (notably AL2023 minimal)
    find:dnf|find:yum)     printf 'findutils\n' ;;
    find:apt-get)          printf 'findutils\n' ;;
    find:pacman)           printf 'findutils\n' ;;
    find:zypper)           printf 'findutils\n' ;;
    find:apk)              printf 'findutils\n' ;;
    # tar (rarely missing; included for completeness)
    tar:dnf|tar:yum|tar:apt-get|tar:pacman|tar:zypper|tar:apk) printf 'tar\n' ;;
    # sha256sum lives in coreutils on Linux; absent only on alpine if coreutils
    # not installed (alpine has busybox sha256sum by default).
    sha256sum:apk) printf 'coreutils\n' ;;
    # awk/tail are POSIX baseline — assume present (no package mapping).
    *) return 1 ;;
  esac
}

ensure_os_deps() {
  # curl|bash promise: the one-liner installs everything it needs. Matches
  # Docker get.docker.com / Tailscale install.sh / Sentry self-hosted pattern.
  # Detect missing OS-level commands, map to packages per detected PM, install
  # via sudo (or directly if root). Friendly fallback if PM or sudo missing.
  local missing=()
  local cmd
  for cmd in tar find sha256sum awk tail; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
      missing+=("$cmd")
    fi
  done
  # tar --zstd shells out to the standalone zstd binary at runtime (verified on
  # AL2023/Ubuntu — tar's --help advertises --zstd unconditionally but extraction
  # fails with 'zstd: Cannot exec' when the binary is absent). Always require
  # the zstd binary if it's missing; small cost (~700KB), prevents the silent
  # crash mid-extraction.
  if ! command -v zstd >/dev/null 2>&1; then
    missing+=("zstd")
  fi

  [ ${#missing[@]} -eq 0 ] && return 0

  local pm
  pm=$(detect_package_manager) || die "Missing required commands: ${missing[*]}. No supported package manager detected (dnf/apt/yum/pacman/zypper/apk). Install them manually and retry."

  # Build pkg list — dedupe via a sorted-uniq pass
  local pkgs=()
  local seen_pkgs=""
  for cmd in "${missing[@]}"; do
    local pkg
    pkg=$(map_command_to_package "$cmd" "$pm") || continue
    case " $seen_pkgs " in
      *" $pkg "*) ;;  # already in list
      *) pkgs+=("$pkg"); seen_pkgs="$seen_pkgs $pkg" ;;
    esac
  done
  [ ${#pkgs[@]} -eq 0 ] && return 0  # Nothing mappable — silently skip

  # Sudo handling: root → no prefix; non-root → require sudo. Don't prompt
  # silently; print what we're about to do so the user sees the sudo prompt
  # in context.
  local sudo_cmd=""
  if [ "$(id -u)" -ne 0 ]; then
    command -v sudo >/dev/null 2>&1 \
      || die "Need to install: ${pkgs[*]}. Re-run this script as root or install sudo first."
    sudo_cmd="sudo"
  fi

  printf '[INFO] Installing OS prereqs via %s (sudo may prompt): %s\n' "$pm" "${pkgs[*]}" >&2

  case "$pm" in
    dnf)
      # --allowerasing handles curl-minimal → curl-style conflicts on AL2023
      $sudo_cmd dnf install -y --allowerasing "${pkgs[@]}" >&2 \
        || die "dnf install failed for: ${pkgs[*]}"
      ;;
    yum)
      $sudo_cmd yum install -y "${pkgs[@]}" >&2 \
        || die "yum install failed for: ${pkgs[*]}"
      ;;
    apt-get)
      $sudo_cmd apt-get update -q >&2 \
        || printf '[WARN] apt-get update failed; trying install anyway\n' >&2
      DEBIAN_FRONTEND=noninteractive $sudo_cmd apt-get install -y "${pkgs[@]}" >&2 \
        || die "apt-get install failed for: ${pkgs[*]}"
      ;;
    pacman)
      $sudo_cmd pacman -Sy --noconfirm "${pkgs[@]}" >&2 \
        || die "pacman install failed for: ${pkgs[*]}"
      ;;
    zypper)
      $sudo_cmd zypper --non-interactive install "${pkgs[@]}" >&2 \
        || die "zypper install failed for: ${pkgs[*]}"
      ;;
    apk)
      $sudo_cmd apk add --no-cache "${pkgs[@]}" >&2 \
        || die "apk add failed for: ${pkgs[*]}"
      ;;
  esac

  # Verify each missing cmd is now present.
  for cmd in "${missing[@]}"; do
    command -v "$cmd" >/dev/null 2>&1 \
      || die "Package install completed but '$cmd' still not on PATH. Investigate manually."
  done
  printf '[INFO] OS prereqs ready.\n' >&2
}

detect_musl() {
  if [ "${PENSATO_FORCE_MUSL_DETECT:-0}" = "1" ]; then
    return 0
  fi
  if [ -f /lib/libc.musl-x86_64.so.1 ] || [ -f /lib/libc.musl-aarch64.so.1 ]; then
    return 0
  fi
  # Defensive: guard ldd in case it's missing (some minimal/non-Linux hosts)
  if command -v ldd >/dev/null 2>&1 && ldd /bin/ls 2>&1 | grep -q musl; then
    return 0
  fi
  return 1
}

python_version_ok() {
  # Returns 0 if python3 exists AND is >= 3.14, else 1.
  command -v python3 >/dev/null 2>&1 || return 1
  python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 14) else 1)' 2>/dev/null
}

ensure_python_via_uv() {
  # Layer 3b (uv-mediated Python install) — matches uv/nvm/mise pattern.
  # Industry pattern for curl|bash installers that need a specific runtime:
  # provision in user space via the project's existing toolchain (uv here).
  # See https://docs.astral.sh/uv/concepts/python-versions/
  #
  # Side effects: creates ~/.pensato/bootstrap-bin/python3 symlinked to the
  # uv-managed CPython 3.14+ interpreter, then prepends that dir to PATH for
  # the rest of this script's execution. NO sudo. NO system package install.
  if python_version_ok; then
    return 0
  fi

  local current="(missing)"
  if command -v python3 >/dev/null 2>&1; then
    current=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' 2>/dev/null || echo "(unknown)")
  fi
  printf '[INFO] python3 is %s; need >= 3.14. Provisioning via uv (user-space, no sudo).\n' "$current" >&2

  # 1. Install uv if absent.
  if ! command -v uv >/dev/null 2>&1; then
    printf '[INFO] Installing uv (zero-admin) to ~/.local/bin ...\n' >&2
    curl -LsSf https://astral.sh/uv/install.sh | sh >&2 \
      || die "Failed to install uv. See https://docs.astral.sh/uv/getting-started/installation/"
  fi
  # Ensure uv is reachable in this shell session.
  if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
    export PATH="$HOME/.local/bin:$PATH"
  fi
  command -v uv >/dev/null 2>&1 || die "uv install ran but uv not on PATH. Add ~/.local/bin to PATH and retry."

  # 2. Install CPython 3.14 via uv (downloads python-build-standalone to ~/.local/share/uv/python/).
  printf '[INFO] Downloading CPython 3.14 via uv (cached at ~/.local/share/uv/python/) ...\n' >&2
  uv python install 3.14 >&2 \
    || die "uv python install 3.14 failed. Try manually: uv python install 3.14"

  # 3. Locate the uv-managed python3.14 binary.
  local uv_python
  uv_python=$(uv python find 3.14 2>/dev/null || true)
  if [ -z "$uv_python" ] || [ ! -x "$uv_python" ]; then
    die "uv python install reported success but 'uv python find 3.14' returned no executable."
  fi

  # 4. Create a python3 shim so the .run SFX (and its embedded wizard) pick up
  # this interpreter via the standard 'python3' name. The shim dir is prepended
  # to PATH for THIS shell only. The .run inherits this PATH when we exec it.
  local shim_dir="$HOME/.pensato/bootstrap-bin"
  mkdir -p "$shim_dir"
  ln -sf "$uv_python" "$shim_dir/python3"
  export PATH="$shim_dir:$PATH"

  # 5. Sanity check.
  python_version_ok \
    || die "After provisioning, python3 still resolves to <3.14. PATH=$PATH"
  printf '[INFO] python3 now resolves to %s\n' "$(python3 -c 'import sys; print(sys.executable, sys.version.split()[0])' 2>&1 | head -1)" >&2
}

check_prereqs() {
  # Note: musl detection is NOT a reject here — it's handled at manifest lookup.
  # The bootstrap is platform-agnostic in design; coverage is whatever the
  # manifest lists. Matches uv / rustup pattern.

  # Layer 1 (OS-level deps): tar/zstd/find/sha256sum/awk/tail. Auto-install
  # via sudo + detected package manager. Matches Docker get.docker.com /
  # Tailscale install.sh / Sentry self-hosted pattern. Side effect: may
  # prompt for sudo password on first install.
  ensure_os_deps

  # Layer 3b (Python runtime): if system python3 is too old (e.g. AL2023
  # ships 3.9), provision Python 3.14+ via uv in user-space — zero-admin.
  # Matches uv / nvm / mise / asdf / pyenv pattern.
  ensure_python_via_uv

  if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
    die "Either curl or wget is required but neither is installed."
  fi
  if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then
    die "Need sha256sum (Linux coreutils) or shasum -a 256."
  fi
  # podman is warning-only, not blocking
  if ! command -v podman >/dev/null 2>&1; then
    printf '[WARN] podman not found — required by some seats. Install with `apt install podman` / `dnf install podman` and rerun if seat bringup fails.\n' >&2
  fi
  # WSL detection — warn but proceed
  if [ -r /proc/version ] && grep -qi microsoft /proc/version; then
    printf '[WARN] WSL2 detected. Ensure systemd is enabled in /etc/wsl.conf for bringup to succeed.\n' >&2
  fi
}

download_url() {
  local url="$1"
  local output="${2:-}"
  if command -v curl >/dev/null 2>&1; then
    if [ -n "$output" ]; then
      curl -fsSL -o "$output" "$url"
    else
      curl -fsSL "$url"
    fi
  else
    if [ -n "$output" ]; then
      wget -q -O "$output" "$url"
    else
      wget -q -O - "$url"
    fi
  fi
}

extract_checksum_from_manifest() {
  local manifest_json="$1"
  local platform="$2"
  if command -v jq >/dev/null 2>&1; then
    echo "$manifest_json" | jq -r ".platforms[\"${platform}\"].checksum // empty"
    return
  fi
  # Fallback: bash regex (mirrors Claude Code bootstrap.sh)
  local oneline
  oneline=$(echo "$manifest_json" | tr -d '\n\r\t' | sed 's/ \+/ /g')
  if [[ $oneline =~ \"${platform}\"[^}]*\"checksum\"[[:space:]]*:[[:space:]]*\"([a-f0-9]{64})\" ]]; then
    echo "${BASH_REMATCH[1]}"
  fi
}

extract_artifact_from_manifest() {
  local manifest_json="$1"
  local platform="$2"
  if command -v jq >/dev/null 2>&1; then
    echo "$manifest_json" | jq -r ".platforms[\"${platform}\"].artifact // empty"
    return
  fi
  local oneline
  oneline=$(echo "$manifest_json" | tr -d '\n\r\t' | sed 's/ \+/ /g')
  if [[ $oneline =~ \"${platform}\"[^}]*\"artifact\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
    echo "${BASH_REMATCH[1]}"
  fi
}

list_available_platforms() {
  # List platform keys in the manifest as a comma-separated string.
  # Used in friendly error when user's platform isn't available.
  local manifest_json="$1"
  if command -v jq >/dev/null 2>&1; then
    echo "$manifest_json" | jq -r '.platforms | keys | join(", ")' 2>/dev/null
    return
  fi
  # Fallback: bash regex pulls platform keys from the platforms object.
  local oneline
  oneline=$(echo "$manifest_json" | tr -d '\n\r\t' | sed 's/ \+/ /g')
  local keys=""
  local rest="$oneline"
  while [[ $rest =~ \"([a-z0-9-]+)\"[[:space:]]*:[[:space:]]*\{[^}]*\"artifact\" ]]; do
    [ -n "$keys" ] && keys="${keys}, "
    keys="${keys}${BASH_REMATCH[1]}"
    rest="${rest#*${BASH_REMATCH[0]}}"
  done
  echo "$keys"
}

compute_sha256() {
  local file="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$file" | cut -d' ' -f1
  else
    shasum -a 256 "$file" | cut -d' ' -f1
  fi
}

fetch_artifact() {
  # Usage: fetch_artifact <base_url> <platform> -> echoes path to verified .run
  local base_url="$1"
  local platform="$2"
  local download_dir="${PENSATO_DOWNLOAD_DIR:-${HOME}/.pensato/downloads}"
  mkdir -p "$download_dir"

  local version
  version=$(download_url "${base_url}/latest")
  if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
    die "Unexpected content from ${base_url}/latest (got: ${version:0:60}...). The hosting endpoint may be unreachable."
  fi

  local manifest_json
  manifest_json=$(download_url "${base_url}/${version}/manifest.json")

  local checksum
  checksum=$(extract_checksum_from_manifest "$manifest_json" "$platform")
  if [ -z "$checksum" ] || [[ ! "$checksum" =~ ^[a-f0-9]{64}$ ]]; then
    local available
    available=$(list_available_platforms "$manifest_json")
    die "No artifact for platform '${platform}' in version ${version}. Available: ${available:-none}. Track follow-ups at ${DOCS_URL}"
  fi

  local artifact_relpath
  artifact_relpath=$(extract_artifact_from_manifest "$manifest_json" "$platform")
  if [ -z "$artifact_relpath" ]; then
    die "Platform ${platform} artifact path missing from manifest (version ${version})."
  fi

  local artifact_url="${base_url}/${version}/${artifact_relpath}"
  local artifact_name
  artifact_name=$(basename "$artifact_relpath")
  local artifact_path="${download_dir}/${artifact_name}"

  download_url "$artifact_url" "$artifact_path" \
    || { rm -f "$artifact_path"; die "Failed to download artifact: ${artifact_url}"; }

  local actual_sha
  actual_sha=$(compute_sha256 "$artifact_path")
  if [ "$actual_sha" != "$checksum" ]; then
    rm -f "$artifact_path"
    die "Checksum verification failed for ${artifact_name}. expected=${checksum} actual=${actual_sha}"
  fi
  echo "$artifact_path"
}

exec_run() {
  # Usage: exec_run <artifact_path>
  # Returns 0 on success, non-zero on hard failure.
  # If a Polkit linger prompt is detected, retries once without --enable-linger.
  local artifact_path="$1"
  chmod +x "$artifact_path"

  # --quiet-install: REQUIRED for headless install. Without it the .run defaults
  # to launching a PyQt6 GUI wizard, which fails on servers (no display, no PyQt6
  # installed). The curl|bash flow is inherently headless.
  # --skip-bringup: install writes systemd units but DOES NOT start them. We
  # prompt the user afterward — same UX as rustup asking about PATH at the end.
  local base_flags=(--quiet-install --accept-disclosures --register-systemd-units --auto-install-uv --skip-bringup)

  local stderr_capture
  stderr_capture=$(mktemp)
  # Use ${var:-} expansion so RETURN trap survives set -u when the local
  # goes out of scope as the function returns.
  trap 'rm -f "${stderr_capture:-}"' RETURN

  # Capture rc via `|| rc=$?` so we get the actual failure code.
  # (After `if cmd; then ...; fi` with no body executed, $? is 0 — not the failure code.)
  local rc=0
  "$artifact_path" "${base_flags[@]}" --enable-linger 2>"$stderr_capture" || rc=$?

  if [ "$rc" -eq 0 ]; then
    cat "$stderr_capture" >&2
    return 0
  fi

  # Detect Polkit linger prompt signature
  if grep -qiE 'set-self-linger|Authentication is required.*linger' "$stderr_capture"; then
    cat "$stderr_capture" >&2
    printf '[INFO] persistent user services (loginctl enable-linger) requires elevation on this distro — retrying without it.\n' >&2
    local retry_rc=0
    "$artifact_path" "${base_flags[@]}" 2>"$stderr_capture" || retry_rc=$?
    cat "$stderr_capture" >&2
    if [ "$retry_rc" -eq 0 ]; then
      PENSATO_LINGER_ACTIVE=false
      printf 'NOTE: Install succeeded but persistent user services not enabled. Run:\n  loginctl enable-linger $USER\nto allow services to survive logout / reboot.\n'
      return 0
    fi
    return $retry_rc
  fi

  cat "$stderr_capture" >&2
  return $rc
}

run_install() {
  local base_url="$1"
  check_prereqs
  local platform
  platform=$(detect_platform)
  local artifact_path
  artifact_path=$(fetch_artifact "$base_url" "$platform")
  exec_run "$artifact_path"
  local rc=$?
  # Cleanup downloaded artifact regardless of outcome
  rm -f "$artifact_path"
  if [ "$rc" -eq 0 ]; then
    print_post_install_summary "$PENSATO_LINGER_ACTIVE"
    post_install_interactive || true  # never fail the install on prompt errors
  fi
  return $rc
}

# Dispatcher for command-line flags
main() {
  local base_url="$DEFAULT_BASE_URL"
  local mode="install"
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --print-platform) mode="print-platform" ;;
      --check-prereqs) mode="check-prereqs" ;;
      --fetch-only) mode="fetch-only" ;;
      --base-url) [ "${2:-}" != "" ] || die "--base-url requires a value"; base_url="$2"; shift ;;
      # Post-install behavior flags. Set the same env vars that
      # post_install_interactive consults — keeps the surface uniform whether
      # the user passes flags or exports env vars before piping to bash.
      --yes|-y) export PENSATO_START_SERVICES=1 ;;  # start with defaults (no expose)
      --no-prompt) export PENSATO_NO_PROMPT=1 ;;
      --start|--start-services) export PENSATO_START_SERVICES=1 ;;
      --no-start|--no-start-services) export PENSATO_START_SERVICES=0 ;;
      --expose-ix-ui) export PENSATO_EXPOSE_IX_UI=1 ;;
      --no-expose-ix-ui) export PENSATO_EXPOSE_IX_UI=0 ;;
      --help|-h) usage; exit 0 ;;
      *) die "Unknown argument: $1" ;;
    esac
    shift
  done

  case "$mode" in
    print-platform) detect_platform ;;
    check-prereqs) check_prereqs ;;
    fetch-only)
      check_prereqs
      local platform
      platform=$(detect_platform)
      fetch_artifact "$base_url" "$platform"
      ;;
    install) run_install "$base_url" ;;
  esac
}

# ============================================================================
# Interactive post-install flow (curl|bash prompts via /dev/tty)
# ============================================================================
#
# Industry validated against rustup, which reads /dev/tty for its post-install
# prompts even when invoked through `curl | sh` (stdin is the pipe, not a TTY).
# We follow the same pattern.

ask_yn() {
  # Usage: ask_yn "Question?" [default]   where default is "y" or "n"
  local question="$1"
  local default="${2:-n}"
  local prompt_suffix
  if [ "$default" = "y" ]; then
    prompt_suffix=" [Y/n] "
  else
    prompt_suffix=" [y/N] "
  fi
  local reply=""
  while true; do
    printf '%s%s' "$question" "$prompt_suffix" >&2
    # read returns 1 on EOF — guard against set -e killing us.
    if ! read -r reply </dev/tty 2>/dev/null; then
      reply=""
    fi
    case "$reply" in
      [Yy]|[Yy][Ee][Ss]) return 0 ;;
      [Nn]|[Nn][Oo]) return 1 ;;
      '') [ "$default" = "y" ] && return 0 || return 1 ;;
      *) printf '  Please answer y or n.\n' >&2 ;;
    esac
  done
}

interactive_supported() {
  # We need a TTY to ask questions. curl|bash's stdin is the pipe; /dev/tty
  # is the real terminal. If neither is available (CI, daemon), we're
  # non-interactive.
  [ -r /dev/tty ] && [ -w /dev/tty ]
}

resolve_install_root() {
  # The .run defaults to ~/.local/share/pensato/ComputeFabric for user-scope
  # installs. We do NOT pass --install-root, so this is always the value.
  # Reading the install-receipt would be more robust if that ever changes.
  printf '%s\n' "$HOME/.local/share/pensato/ComputeFabric"
}

resolve_cli_binary() {
  local install_root
  install_root="$(resolve_install_root)"
  printf '%s/Tools/pensato-compute-fabric\n' "$install_root"
}

start_services_and_wait() {
  # Calls the installed CLI's `bringup` command, which:
  #  - starts pensato-compute-fabric.target
  #  - polls compute-gateway /health until 200 (up to ~120s)
  #  - writes activation-state.json receipt
  # Returns 0 on healthy, non-zero on timeout/health-fail.
  local cli
  cli="$(resolve_cli_binary)"
  if [ ! -x "$cli" ]; then
    printf '[FAIL] CLI not found at %s. Install may not have completed.\n' "$cli" >&2
    return 1
  fi
  printf '\n[INFO] Starting services. First sync downloads dependencies (~60-90s).\n' >&2
  if "$cli" bringup; then
    return 0
  fi
  return 1
}

expose_ix_ui_to_network() {
  # Writes a systemd drop-in that overrides ExecStart to bind 0.0.0.0:7249,
  # then restarts compute-gateway. This is OPT-IN with a security warning;
  # the default install binds to 127.0.0.1.
  local install_root
  install_root="$(resolve_install_root)"
  local dropin_dir="$HOME/.config/systemd/user/pensato-compute-gateway.service.d"
  mkdir -p "$dropin_dir"
  cat > "$dropin_dir/expose-network.conf" <<EOF
# Generated by curl|bash bootstrap on $(date -u +%Y-%m-%dT%H:%M:%SZ)
# Removes the default 127.0.0.1 binding and exposes IX UI on 0.0.0.0:7249.
# To revert: rm $dropin_dir/expose-network.conf && systemctl --user daemon-reload && systemctl --user restart pensato-compute-gateway.service
[Service]
ExecStart=
ExecStart=/usr/bin/env bash ${install_root}/SourceRepos/pensato-compute-gateway/scripts/runtime/run-compute-gateway.sh --host 0.0.0.0 --port 7249
EOF
  systemctl --user daemon-reload 2>/dev/null || true
  systemctl --user restart pensato-compute-gateway.service 2>/dev/null || {
    printf '[WARN] Failed to restart compute-gateway. Try manually: systemctl --user restart pensato-compute-gateway\n' >&2
    return 1
  }
  # Brief wait for re-bind
  local i
  for i in 1 2 3 4 5 6 7 8 9 10; do
    if curl -sf -o /dev/null --max-time 2 http://127.0.0.1:7249/health 2>/dev/null; then
      return 0
    fi
    sleep 1
  done
  printf '[WARN] Service restarted but /health did not respond within 10s.\n' >&2
  return 1
}

detect_public_endpoint() {
  # Best-effort: print HOSTNAME or external IP. Cloud metadata services not
  # queried (would block on non-cloud hosts). User can replace with their
  # actual public IP/DNS in the printed instructions.
  if command -v hostname >/dev/null 2>&1; then
    hostname -f 2>/dev/null || hostname
  else
    printf '%s\n' "${HOSTNAME:-<this-host>}"
  fi
}

print_ssh_tunnel_instructions() {
  local endpoint
  endpoint="$(detect_public_endpoint)"
  cat >&2 <<EOF

🔒 SSH tunnel access (recommended):
   From YOUR LOCAL machine, run:
     ssh -L 7249:127.0.0.1:7249 ${USER:-<user>}@${endpoint}

   Then open in your browser:
     http://localhost:7249/api/v1/ix/zero-run

   Tunnel stays open while that SSH session is connected.
EOF
}

print_public_access_instructions() {
  local endpoint
  endpoint="$(detect_public_endpoint)"
  cat >&2 <<EOF

🌐 Network access (IX UI bound to 0.0.0.0:7249):
   Open in your browser:
     http://${endpoint}:7249/api/v1/ix/zero-run

   Make sure your firewall/security group allows TCP 7249 from your client IP.
   To revert to localhost-only later:
     rm ~/.config/systemd/user/pensato-compute-gateway.service.d/expose-network.conf
     systemctl --user daemon-reload
     systemctl --user restart pensato-compute-gateway.service
EOF
}

print_manual_start_instructions() {
  cat >&2 <<'EOF'

To start services later, run on this machine:
  export XDG_RUNTIME_DIR=/run/user/$(id -u)
  pensato-compute-fabric bringup     # blocks until IX UI is healthy

Or just start the target without health-wait:
  systemctl --user start pensato-compute-fabric.target

IX UI will be at http://127.0.0.1:7249/api/v1/ix/zero-run once healthy.
EOF
}

post_install_interactive() {
  # Called after install completes successfully. Asks two questions:
  #   1. Start services now?
  #   2. Bind to localhost (default, SSH tunnel) or expose to network?
  # Honors --yes (defaults), --no-prompt (no start), --start, --expose flags.
  if [ "${PENSATO_NO_PROMPT:-0}" = "1" ]; then
    print_manual_start_instructions
    return 0
  fi

  # Resolve Q1 (start services?)
  local should_start=""
  if [ "${PENSATO_START_SERVICES:-}" = "1" ]; then
    should_start="yes"
  elif [ "${PENSATO_START_SERVICES:-}" = "0" ]; then
    should_start="no"
  elif interactive_supported; then
    if ask_yn $'\nStart Compute Fabric services now? (IX UI on http://127.0.0.1:7249)' y; then
      should_start="yes"
    else
      should_start="no"
    fi
  else
    # No TTY and no flag — safest default is to NOT auto-start, leave to user.
    printf '\n[INFO] Non-interactive environment detected; not auto-starting services.\n' >&2
    should_start="no"
  fi

  if [ "$should_start" != "yes" ]; then
    print_manual_start_instructions
    return 0
  fi

  if ! start_services_and_wait; then
    printf '\n[WARN] Services did not become healthy. Run `pensato-compute-fabric doctor` to investigate.\n' >&2
    return 0
  fi

  printf '\n✅ Services healthy. IX UI is reachable on this host at http://127.0.0.1:7249/api/v1/ix/zero-run\n' >&2

  # Resolve Q2 (expose vs tunnel?)
  local should_expose=""
  if [ "${PENSATO_EXPOSE_IX_UI:-}" = "1" ]; then
    should_expose="yes"
  elif [ "${PENSATO_EXPOSE_IX_UI:-}" = "0" ]; then
    should_expose="no"
  elif interactive_supported; then
    cat >&2 <<'EOF'

How will you reach the IX UI from your browser?
  • SSH tunnel (default, secure) — keeps binding on 127.0.0.1, you forward via ssh -L
  • Network access — rebinds to 0.0.0.0; ANY client that can reach this machine on
    port 7249 can hit the UI. There is NO authentication. Only choose this if the
    machine is on a network you trust or your firewall restricts who can connect.
EOF
    if ask_yn "Expose IX UI to the network (bind 0.0.0.0:7249)?" n; then
      should_expose="yes"
    else
      should_expose="no"
    fi
  else
    should_expose="no"  # Non-interactive default: keep localhost binding (safe)
  fi

  if [ "$should_expose" = "yes" ]; then
    if expose_ix_ui_to_network; then
      print_public_access_instructions
    else
      printf '[WARN] Could not expose service. Falling back to SSH tunnel instructions.\n' >&2
      print_ssh_tunnel_instructions
    fi
  else
    print_ssh_tunnel_instructions
  fi
}

print_post_install_summary() {
  # Usage: print_post_install_summary <linger_active>  ("true" | "false")
  local linger_active="$1"
  cat <<EOF

✅ Pensato Compute Fabric install complete.

Install root: ~/.local/share/pensato/ComputeFabric/
Status:       ~/.local/share/pensato/ComputeFabric/Tools/pensato-compute-fabric status
Doctor:       ~/.local/share/pensato/ComputeFabric/Tools/pensato-compute-fabric doctor
IX URL:       http://127.0.0.1:7249  (reachable once seats finish their first 'uv sync')
EOF
  if [ "$linger_active" = "true" ]; then
    printf '\nServices will keep running across logout and reboots.\n'
  fi
}

usage() {
  cat <<'USAGE'
Pensato Compute Fabric — no-sudo Linux bootstrap installer

Usage:
  curl -fsSL https://fabric.pensato.io | bash

Options:
  --base-url <URL>       Override the manifest/artifact base URL (for testing).
  --check-prereqs        Verify host has required tools, then exit.
  --print-platform       Print detected platform (e.g. linux-x64), then exit.
  --fetch-only           Download + verify the artifact, then exit (no install).
  --help, -h             Show this help.

Post-install behavior (alternatives to interactive prompts):
  --yes, -y              Start services with safe defaults (localhost binding).
                         Equivalent to PENSATO_START_SERVICES=1.
  --no-prompt            Skip all post-install prompts; do not start services.
                         Equivalent to PENSATO_NO_PROMPT=1.
  --start-services       Start services (yes to Q1) without asking.
  --no-start-services    Do NOT start services (no to Q1).
  --expose-ix-ui         Bind IX UI to 0.0.0.0:7249 instead of 127.0.0.1.
                         WARNING: no auth — only safe on trusted networks.
  --no-expose-ix-ui      Keep IX UI on 127.0.0.1 (use SSH tunnel from client).

The same effects are available via env vars before piping to bash:
  PENSATO_START_SERVICES=1 PENSATO_EXPOSE_IX_UI=0 curl ... | bash
USAGE
}

main "$@"
