#!/usr/bin/env bash
set -euo pipefail

### --- OS CHECK --- ###
if [[ $OSTYPE != linux-gnu* ]]; then
  echo "[ERROR] This script supports Linux only. Aborting."
  exit 1
fi

# --- Auto-save if running from a pipe ---
if [ -p /dev/stdin ]; then
  TMP_SCRIPT="/tmp/install-autonomy-node.sh"
  echo "[INFO] Detected script running from a pipe. Saving to $TMP_SCRIPT..."
  cat >"$TMP_SCRIPT"
  chmod +x "$TMP_SCRIPT"

  echo "[INFO] Re-running saved script..."
  exec /usr/bin/env bash "$TMP_SCRIPT" "$@"
fi

### --- CONFIGURATION --- ###
NODE_IMAGE="ghcr.io/autonomy-logic/autonomy-node:latest"
ORCHESTRATOR_IMAGE="ghcr.io/autonomy-logic/orchestrator-agent:latest"
NETMON_IMAGE="ghcr.io/autonomy-logic/autonomy-netmon:latest"
GITEA_IMAGE="gitea/gitea:1.25"
# Pre-seeded so the first vPLC creation doesn't block on a full GHCR pull.
# Best-effort: a failed pull here only warns (see PULL IMAGES), since the
# orchestrator still pulls on-demand when a vPLC actually needs the image.
RUNTIME_IMAGE="ghcr.io/autonomy-logic/openplc-runtime:latest"

NODE_CONTAINER="autonomy-node"
ORCHESTRATOR_CONTAINER="orchestrator_agent"
NETMON_CONTAINER="autonomy_netmon"
GITEA_CONTAINER="autonomy-gitea"
# Network alias the node resolves for git operations (GITEA_URL=http://gitea:3000).
# The container name differs, so the alias is what makes `gitea:3000` resolve.
GITEA_ALIAS="gitea"

INTERNAL_NETWORK="autonomy-internal"
SHARED_VOLUME="orchestrator-shared"
# Upload drop-box only. Deliberately separate from SHARED_VOLUME: the node
# needs to hand the agent an archive, not read-write access to the agent's
# whole state directory (clients.json, config.json, logs, netmon socket).
UPLOADS_VOLUME="orchestrator-uploads"
DATA_VOLUME="autonomy-data"
GITEA_DATA_VOLUME="gitea-data"
# Tiny shared volume carrying only the Gitea API token: written by the Gitea
# bootstrap (read-write) and read by the node (read-only).
GITEA_SHARED_VOLUME="gitea-shared"

# Path on the host where the embedded Gitea bootstrap script is written, then
# bind-mounted into the Gitea container (mirrors docker/gitea-bootstrap.sh).
# Under /var/lib rather than /opt: hosts with an image-based, read-only root
# filesystem (e.g. Weidmüller u-OS) only leave /var writable.
GITEA_BOOTSTRAP_HOST="/var/lib/autonomy-node/gitea-bootstrap.sh"

# Host CLI wrapper — lets operators run backend subcommands (e.g. password
# recovery) as `autonomy-node <cmd>` instead of a raw `docker exec`. Optional:
# on a read-only /usr/local the install carries on without it.
CLI_WRAPPER_HOST="/usr/local/bin/autonomy-node"

# Gitea admin = the repo owner namespace. Overridable via env before running.
GITEA_OWNER="${GITEA_OWNER:-autonomy}"
GITEA_ADMIN_PASSWORD="${GITEA_ADMIN_PASSWORD:-AutonomyNode-local-01}"
GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@autonomy.local}"

# WS_PORT must match the value the autonomy-node container listens on. It is
# never published to the host — orchestrator reaches it over the internal
# bridge as ${NODE_CONTAINER}:${WS_PORT}.
WS_PORT="${WS_PORT:-3001}"

# How long to wait for Gitea to write its API token before starting the node.
GITEA_TOKEN_TIMEOUT_SECONDS=120

# Health-check timing for the node container.
HEALTH_TIMEOUT_SECONDS=120
HEALTH_POLL_INTERVAL_SECONDS=3

# Minimum free space required on the docker data root before pulling the
# runtime image, in KB (~1 GB). A simple fixed headroom, not a manifest-size
# check — the orchestrator container can't see host disk, so this is the only
# place that can gate the pull on available space.
MIN_RUNTIME_FREE_KB=1048576

# Host directory holding the vendor plugin packages this Node ships with,
# mounted read-only into the node container (see --vpp).
VPP_SEED_HOST_DIR="/var/lib/autonomy-node/vpps"

# Check for root privileges
# sudo resets the environment, so the settings above are handed over through
# env explicitly; `curl … | WS_PORT=4001 bash` would otherwise lose them.
# The arguments are rebuilt from the parsed options because the parsing loop
# below has already shifted them away by the time this runs.
check_root() {
  if [[ $EUID -ne 0 ]]; then
    echo "[INFO] Root privileges are required. Trying to elevate with sudo..."
    local args=() source
    for source in ${VPP_SOURCES[@]+"${VPP_SOURCES[@]}"}; do
      args+=(--vpp "$source")
    done
    if [ -n "$HTTP_PORT" ]; then
      args+=(--http-port "$HTTP_PORT")
    fi
    exec sudo /usr/bin/env \
      "WS_PORT=${WS_PORT}" \
      "GITEA_OWNER=${GITEA_OWNER}" \
      "GITEA_ADMIN_PASSWORD=${GITEA_ADMIN_PASSWORD}" \
      "GITEA_ADMIN_EMAIL=${GITEA_ADMIN_EMAIL}" \
      bash "$0" ${args[@]+"${args[@]}"}
  fi
}

usage() {
  cat <<EOF
Usage: $(basename "$0") [--http-port <port>] [--vpp <https-url-or-path>]...

Installs Autonomy Node and its sibling containers.

Options:
  --http-port <port>         Publish the web UI (plain HTTP) on this host port
                             only, instead of 80 and 443. For hosts where
                             something else already owns 80/443, e.g. the
                             reverse proxy on Weidmüller u-OS controllers.
  --vpp <https-url-or-path>  Seed a vendor plugin package (.vpp) the Node ships
                             with. Repeatable. Accepts https:// URLs and local
                             paths only.
  -h, --help                 Show this help
EOF
}

VPP_SOURCES=()
HTTP_PORT=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --http-port)
      if [ "$#" -lt 2 ]; then
        echo "[ERROR] --http-port requires a port number" >&2
        exit 2
      fi
      HTTP_PORT="$2"
      shift 2
      ;;
    --http-port=*)
      HTTP_PORT="${1#--http-port=}"
      shift
      ;;
    --vpp)
      if [ "$#" -lt 2 ]; then
        echo "[ERROR] --vpp requires a value (https:// URL or a local path)" >&2
        exit 2
      fi
      VPP_SOURCES+=("$2")
      shift 2
      ;;
    --vpp=*)
      VPP_SOURCES+=("${1#--vpp=}")
      shift
      ;;
    -h | --help)
      usage
      exit 0
      ;;
    *)
      echo "[ERROR] Unknown option: $1" >&2
      usage
      exit 2
      ;;
  esac
done

# Unset: publish 80 (HTTP) and 443 (HTTPS). Set: publish only
# ${HTTP_PORT} -> 80 and leave 443 alone.
if [ -n "$HTTP_PORT" ]; then
  if ! [[ "$HTTP_PORT" =~ ^[0-9]+$ ]] || [ "$HTTP_PORT" -lt 1 ] || [ "$HTTP_PORT" -gt 65535 ]; then
    echo "[ERROR] --http-port must be a port number between 1 and 65535, got: $HTTP_PORT" >&2
    exit 2
  fi
  NODE_PORT_ARGS=(-p "${HTTP_PORT}:80")
  NODE_PORTS_LABEL="port ${HTTP_PORT}"
  WEB_UI_URL_SUFFIX=":${HTTP_PORT}"
else
  NODE_PORT_ARGS=(-p 80:80 -p 443:443)
  NODE_PORTS_LABEL="ports 80/443"
  WEB_UI_URL_SUFFIX=""
fi

check_root

# Free space (KB) on the docker data root is at least $1 KB. Falls back to
# checking / if the docker data root can't be determined.
check_disk_space() {
  local required_kb="$1"
  local docker_root
  docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)
  [ -d "$docker_root" ] || docker_root="/"

  local available_kb
  available_kb=$(df -Pk "$docker_root" | awk 'NR==2 {print $4}')
  [ -n "$available_kb" ] && [ "$available_kb" -ge "$required_kb" ]
}

### --- VPP SEED PACKAGES (--vpp) --- ###
# Vendor plugin packages the Node ships with. Staged on the host and mounted
# read-only at /data/vpp-seed, not baked into the image, so a package is
# swapped by replacing the file and restarting the container. The backend
# verifies every file there and refuses to serve one that fails.
stage_vpp_seeds() {
  mkdir -p "$VPP_SEED_HOST_DIR"
  chmod 755 "$VPP_SEED_HOST_DIR"
  [ "${#VPP_SOURCES[@]}" -gt 0 ] || return 0

  local staging source file_name tmp
  staging="$(mktemp -d "$VPP_SEED_HOST_DIR/.staging.XXXXXX")"

  # Fetch and validate every source before publishing any of it: a bad --vpp
  # must fail while the previous seed set is still whole.
  for source in "${VPP_SOURCES[@]}"; do
    file_name="$(basename "${source%%[?#]*}")"
    case "$file_name" in
      *.vpp) ;;
      *)
        rm -rf "$staging"
        echo "[ERROR] --vpp expects a .vpp file, got: $source" >&2
        exit 1
        ;;
    esac

    tmp="$staging/$file_name"

    case "$source" in
      https://*)
        echo "[INFO] Downloading VPP package: $source"
        if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp" "$source"; then
          rm -rf "$staging"
          echo "[ERROR] Failed to download $source" >&2
          exit 1
        fi
        ;;
      http://*)
        rm -rf "$staging"
        echo "[ERROR] --vpp refuses plain http. Use https:// or a local path: $source" >&2
        exit 1
        ;;
      *://*)
        rm -rf "$staging"
        echo "[ERROR] --vpp accepts only https:// URLs and local paths: $source" >&2
        exit 1
        ;;
      *)
        if [ ! -f "$source" ]; then
          rm -rf "$staging"
          echo "[ERROR] --vpp file not found: $source" >&2
          exit 1
        fi
        cp -- "$source" "$tmp"
        ;;
    esac

    if [ ! -s "$tmp" ]; then
      rm -rf "$staging"
      echo "[ERROR] --vpp source is empty: $source" >&2
      exit 1
    fi
    chmod 644 "$tmp"
  done

  for source in "${VPP_SOURCES[@]}"; do
    file_name="$(basename "${source%%[?#]*}")"
    [ -f "$staging/$file_name" ] || continue
    mv -f "$staging/$file_name" "$VPP_SEED_HOST_DIR/$file_name"
    echo "[SUCCESS] Seeded VPP package: $file_name"
  done
  rm -rf "$staging"
}

### --- DEPENDENCIES --- ###
echo "Checking and installing required dependencies..."
PKG_MANAGER=""

if command -v apt-get &>/dev/null; then
  PKG_MANAGER="apt-get"
elif command -v dnf &>/dev/null; then
  PKG_MANAGER="dnf"
elif command -v yum &>/dev/null; then
  PKG_MANAGER="yum"
else
  echo "[WARN] No supported package manager found (apt, dnf, or yum)."
  echo "Required packages: curl, docker"
  echo "Attempting to continue without automatic dependency installation..."
  PKG_MANAGER="none"
fi

declare -A PKG_MAP
if [[ "$PKG_MANAGER" == "apt-get" ]]; then
  PKG_MAP=(
    [curl]="curl"
    [docker]="docker.io"
  )
elif [[ "$PKG_MANAGER" == "dnf" ]] || [[ "$PKG_MANAGER" == "yum" ]]; then
  PKG_MAP=(
    [curl]="curl"
    [docker]="docker"
  )
fi

MISSING_PKGS=()
for cmd in curl docker; do
  if ! command -v "$cmd" &>/dev/null; then
    echo "Missing dependency: $cmd"
    if [[ -n "${PKG_MAP[$cmd]:-}" ]]; then
      MISSING_PKGS+=("${PKG_MAP[$cmd]}")
    fi
  else
    echo "[SUCCESS] $cmd is already installed."
  fi
done

if [ ${#MISSING_PKGS[@]} -ne 0 ]; then
  echo "Installing missing dependencies: ${MISSING_PKGS[*]}"
  case "$PKG_MANAGER" in
  apt-get)
    apt-get update -y
    apt-get install -y "${MISSING_PKGS[@]}"
    ;;
  dnf)
    dnf install -y "${MISSING_PKGS[@]}"
    ;;
  yum)
    yum install -y "${MISSING_PKGS[@]}"
    ;;
  none)
    echo "[ERROR] Cannot install dependencies automatically. Please install: ${MISSING_PKGS[*]}"
    exit 1
    ;;
  esac
fi

# Make sure the docker daemon is up before we start poking it.
if ! docker info &>/dev/null; then
  echo "[INFO] Starting docker daemon..."
  if command -v systemctl &>/dev/null; then
    systemctl enable --now docker || true
  elif command -v service &>/dev/null; then
    service docker start || true
  fi
fi

if ! docker info &>/dev/null; then
  echo "[ERROR] Docker is installed but the daemon is not reachable. Start docker and re-run."
  exit 1
fi

### --- COLOR DETECTION --- ###
if [ -t 1 ] && command -v tput >/dev/null && [ "$(tput colors 2>/dev/null)" -ge 8 ]; then
  GREEN="$(tput setaf 2)"
  CYAN="$(tput setaf 6)"
  YELLOW="$(tput setaf 3)"
  RED="$(tput setaf 1)"
  GRAY="$(tput setaf 8)"
  BOLD="$(tput bold)"
  RESET="$(tput sgr0)"
else
  GREEN=""
  CYAN=""
  YELLOW=""
  RED=""
  GRAY=""
  BOLD=""
  RESET=""
fi

### --- NETWORK & VOLUMES --- ###
echo "Ensuring internal docker network and named volumes exist..."

if docker network inspect "$INTERNAL_NETWORK" &>/dev/null; then
  echo "[SUCCESS] Network $INTERNAL_NETWORK already exists"
else
  docker network create --driver bridge "$INTERNAL_NETWORK"
  echo "[SUCCESS] Created network $INTERNAL_NETWORK"
fi

for vol in "$SHARED_VOLUME" "$DATA_VOLUME" "$GITEA_DATA_VOLUME" "$GITEA_SHARED_VOLUME"; do
  if docker volume inspect "$vol" &>/dev/null; then
    echo "[SUCCESS] Volume $vol already exists"
  else
    docker volume create "$vol" >/dev/null
    echo "[SUCCESS] Created volume $vol"
  fi
done

### --- DETECT EXISTING CONTAINERS --- ###
# Only the node container is upgraded on re-runs. If the orchestrator or
# netmon already exist, they stay on whatever image they were started with —
# the operator can replace them deliberately by removing the container first.
NODE_EXISTS=false
ORCHESTRATOR_EXISTS=false
NETMON_EXISTS=false
GITEA_EXISTS=false
EXISTING_CONTAINERS=$(docker ps -a --format '{{.Names}}')
if echo "$EXISTING_CONTAINERS" | grep -q "^${NODE_CONTAINER}$"; then NODE_EXISTS=true; fi
if echo "$EXISTING_CONTAINERS" | grep -q "^${ORCHESTRATOR_CONTAINER}$"; then ORCHESTRATOR_EXISTS=true; fi
if echo "$EXISTING_CONTAINERS" | grep -q "^${NETMON_CONTAINER}$"; then NETMON_EXISTS=true; fi
if echo "$EXISTING_CONTAINERS" | grep -q "^${GITEA_CONTAINER}$"; then GITEA_EXISTS=true; fi

if [ "$NODE_EXISTS" = true ]; then
  echo
  echo -e "${BOLD}${CYAN}=====================================================${RESET}"
  echo -e "${BOLD}${CYAN}  EXISTING NODE DETECTED — UPGRADING NODE ONLY        ${RESET}"
  echo -e "${BOLD}${CYAN}  (Device Agent and netmon will be left in place)     ${RESET}"
  echo -e "${BOLD}${CYAN}=====================================================${RESET}"
  echo
fi

### --- PULL IMAGES --- ###
# Always pull the node image (the node container is always recreated).
# Pull orchestrator/netmon images only when those containers don't already
# exist — there's no point re-downloading when we're not going to recreate.
echo "Pulling images from GHCR..."
echo "  - $NODE_IMAGE"
if ! docker pull "$NODE_IMAGE"; then
  echo -e "${RED}[ERROR] Failed to pull $NODE_IMAGE${RESET}"
  echo "Check network connectivity and that the image exists for this architecture ($(uname -m))."
  exit 1
fi

if [ "$ORCHESTRATOR_EXISTS" = false ]; then
  echo "  - $ORCHESTRATOR_IMAGE"
  if ! docker pull "$ORCHESTRATOR_IMAGE"; then
    echo -e "${RED}[ERROR] Failed to pull $ORCHESTRATOR_IMAGE${RESET}"
    exit 1
  fi
fi

if [ "$NETMON_EXISTS" = false ]; then
  echo "  - $NETMON_IMAGE"
  if ! docker pull "$NETMON_IMAGE"; then
    echo -e "${RED}[ERROR] Failed to pull $NETMON_IMAGE${RESET}"
    exit 1
  fi
fi

if [ "$GITEA_EXISTS" = false ]; then
  echo "  - $GITEA_IMAGE"
  if ! docker pull "$GITEA_IMAGE"; then
    echo -e "${RED}[ERROR] Failed to pull $GITEA_IMAGE${RESET}"
    exit 1
  fi
fi

# Best-effort: the runtime image isn't required for the node to boot (the
# orchestrator still pulls it on-demand), so low disk space or a failed pull
# only warns instead of aborting the install.
echo "  - $RUNTIME_IMAGE"
if ! check_disk_space "$MIN_RUNTIME_FREE_KB"; then
  echo -e "${YELLOW}[WARN] Not enough free disk space to pre-pull $RUNTIME_IMAGE (need ~$((MIN_RUNTIME_FREE_KB / 1024 / 1024)) GB free); skipping. The Device Agent will pull it on-demand when a vPLC needs it.${RESET}"
elif ! docker pull "$RUNTIME_IMAGE"; then
  echo -e "${YELLOW}[WARN] Failed to pull $RUNTIME_IMAGE; continuing. The Device Agent will pull it on-demand when a vPLC needs it.${RESET}"
fi

echo "[SUCCESS] Image pulls complete."

# Before the container goes away: a failed download or a bad path must not take
# a running Node down over an optional package.
stage_vpp_seeds

### --- REMOVE EXISTING NODE CONTAINER --- ###
# Only the node container is replaced. Existing orchestrator/netmon containers
# stay as-is (see the upgrade banner above). Named volumes are preserved.
if [ "$NODE_EXISTS" = true ]; then
  echo "Removing existing $NODE_CONTAINER for upgrade..."
  docker rm -f "$NODE_CONTAINER" >/dev/null
fi

### --- START NETMON (host network) --- ###
if [ "$NETMON_EXISTS" = false ]; then
  echo "Starting network monitor sidecar: $NETMON_CONTAINER"
  docker run -d \
    --name "$NETMON_CONTAINER" \
    --network=host \
    --pid=host \
    --privileged \
    --restart unless-stopped \
    -v "$SHARED_VOLUME:/var/orchestrator" \
    -v /dev:/dev \
    -v /run/udev:/run/udev:ro \
    "$NETMON_IMAGE" >/dev/null
  echo "[SUCCESS] $NETMON_CONTAINER started"
else
  echo "[INFO] $NETMON_CONTAINER already present — leaving in place"
fi

### --- START GITEA (internal git server) --- ###
# The canonical git server / repository store. Not published to the host — only
# the node reaches it, over the internal network as ${GITEA_ALIAS}:3000. A
# bootstrap script (written to the host and bind-mounted) provisions the admin
# user and an API token on the shared volume that the node reads.
if [ "$GITEA_EXISTS" = false ]; then
  echo "Writing Gitea bootstrap script to $GITEA_BOOTSTRAP_HOST"
  mkdir -p "$(dirname "$GITEA_BOOTSTRAP_HOST")"
  cat >"$GITEA_BOOTSTRAP_HOST" <<'BOOTSTRAP_EOF'
#!/bin/sh
# Gitea bootstrap for Autonomy Node — provisions the admin user (= repo owner
# namespace) and an API token on the shared volume for the backend to read.
# Runs in the background alongside the real Gitea entrypoint. Idempotent.
set -eu

GITEA_BIN="${GITEA_BIN:-gitea}"
GITEA_WORK_DIR="${GITEA_WORK_DIR:-/data/gitea}"
APP_INI="${GITEA_APP_INI:-/data/gitea/conf/app.ini}"
ADMIN_USER="${GITEA_ADMIN_USER:-autonomy}"
ADMIN_PASSWORD="${GITEA_ADMIN_PASSWORD:-AutonomyNode-local-01}"
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@autonomy.local}"
TOKEN_FILE="${GITEA_TOKEN_FILE:-/gitea-shared/token}"
READY_URL="http://localhost:3000/api/v1/version"

log() { echo "[gitea-bootstrap] $*"; }

i=0
until wget -q -O /dev/null "$READY_URL" 2>/dev/null; do
  i=$((i + 1))
  if [ "$i" -gt 120 ]; then
    log "Gitea did not become ready in ~240s; skipping bootstrap."
    exit 0
  fi
  sleep 2
done
log "Gitea is ready."

run_gitea() {
  su-exec git "$GITEA_BIN" --config "$APP_INI" --work-path "$GITEA_WORK_DIR" "$@"
}

run_gitea admin user create \
  --username "$ADMIN_USER" \
  --password "$ADMIN_PASSWORD" \
  --email "$ADMIN_EMAIL" \
  --admin \
  --must-change-password=false 2>&1 | sed 's/^/[gitea-bootstrap] /' || true

mkdir -p "$(dirname "$TOKEN_FILE")"

if [ -s "$TOKEN_FILE" ]; then
  log "Reusing existing API token at $TOKEN_FILE."
  exit 0
fi

TOKEN_NAME="autonomy-node-$(date +%s)"
TOKEN="$(run_gitea admin user generate-access-token \
  --username "$ADMIN_USER" \
  --scopes all \
  --token-name "$TOKEN_NAME" \
  --raw 2>/dev/null | tr -d '[:space:]')"

if [ -z "$TOKEN" ]; then
  log "Failed to generate API token; version control will be disabled."
  exit 0
fi

printf '%s' "$TOKEN" > "$TOKEN_FILE"
chmod 644 "$TOKEN_FILE" 2>/dev/null || true
log "Provisioned API token -> $TOKEN_FILE."
BOOTSTRAP_EOF
  chmod +x "$GITEA_BOOTSTRAP_HOST"

  echo "Starting Gitea: $GITEA_CONTAINER"
  docker run -d \
    --name "$GITEA_CONTAINER" \
    --network "$INTERNAL_NETWORK" \
    --network-alias "$GITEA_ALIAS" \
    --restart unless-stopped \
    -e "GITEA__database__DB_TYPE=sqlite3" \
    -e "GITEA__server__ROOT_URL=http://${GITEA_ALIAS}:3000/" \
    -e "GITEA__server__DOMAIN=${GITEA_ALIAS}" \
    -e "GITEA__service__DISABLE_REGISTRATION=true" \
    -e "GITEA__security__INSTALL_LOCK=true" \
    -e "GITEA_ADMIN_USER=${GITEA_OWNER}" \
    -e "GITEA_ADMIN_PASSWORD=${GITEA_ADMIN_PASSWORD}" \
    -e "GITEA_ADMIN_EMAIL=${GITEA_ADMIN_EMAIL}" \
    -e "GITEA_TOKEN_FILE=/gitea-shared/token" \
    -v "$GITEA_DATA_VOLUME:/data" \
    -v "$GITEA_SHARED_VOLUME:/gitea-shared" \
    -v "$GITEA_BOOTSTRAP_HOST:/usr/local/bin/autonomy-bootstrap.sh:ro" \
    --entrypoint /bin/sh \
    "$GITEA_IMAGE" \
    -c '/usr/local/bin/autonomy-bootstrap.sh & exec /usr/bin/entrypoint' >/dev/null
  echo "[SUCCESS] $GITEA_CONTAINER started"

  # Wait for the bootstrap to write the API token before starting the node —
  # otherwise the node boots with version control disabled until its next
  # restart. Best-effort: we proceed after the timeout regardless.
  echo "Waiting for Gitea to provision its API token (timeout: ${GITEA_TOKEN_TIMEOUT_SECONDS}s)..."
  ELAPSED=0
  TOKEN_READY=false
  while [ "$ELAPSED" -lt "$GITEA_TOKEN_TIMEOUT_SECONDS" ]; do
    if docker exec "$GITEA_CONTAINER" test -s /gitea-shared/token 2>/dev/null; then
      TOKEN_READY=true
      break
    fi
    sleep 3
    ELAPSED=$(( ELAPSED + 3 ))
  done
  if [ "$TOKEN_READY" = true ]; then
    echo "[SUCCESS] Gitea API token provisioned"
  else
    echo -e "${YELLOW}[WARN] Gitea token not ready within ${GITEA_TOKEN_TIMEOUT_SECONDS}s; the node will start with version control disabled until Gitea finishes (restart the node once the token exists).${RESET}"
  fi
else
  echo "[INFO] $GITEA_CONTAINER already present — leaving in place"
fi

### --- START AUTONOMY NODE --- ###
# Joined to the internal bridge so the orchestrator can reach the websocket
# server at ${NODE_CONTAINER}:${WS_PORT}. Only the web UI is published to the
# host (80/443, or --http-port alone when given) — WS_PORT stays internal.

echo "Starting autonomy-node: $NODE_CONTAINER"
docker run -d \
  --name "$NODE_CONTAINER" \
  --network "$INTERNAL_NETWORK" \
  --network-alias "$NODE_CONTAINER" \
  --restart unless-stopped \
  "${NODE_PORT_ARGS[@]}" \
  -e "WS_PORT=${WS_PORT}" \
  -e "GITEA_URL=http://${GITEA_ALIAS}:3000/api/v1" \
  -e "GITEA_INTERNAL_URL=http://${GITEA_ALIAS}:3000" \
  -e "GITEA_OWNER=${GITEA_OWNER}" \
  -e "GITEA_TOKEN_FILE=/gitea-shared/token" \
  -e "VPP_SEED_PATH=/data/vpp-seed" \
  -v "$DATA_VOLUME:/data" \
  -v "$GITEA_SHARED_VOLUME:/gitea-shared:ro" \
  -v "$UPLOADS_VOLUME:/var/orchestrator/uploads" \
  -v "$VPP_SEED_HOST_DIR:/data/vpp-seed:ro" \
  "$NODE_IMAGE" >/dev/null
echo "[SUCCESS] $NODE_CONTAINER started"

### --- WAIT FOR NODE HEALTH --- ###
# The node Dockerfile defines a HEALTHCHECK against port 80. Wait for it to
# report healthy before starting the orchestrator — otherwise the orchestrator
# will retry-connect noisily while the node is still booting.
echo "Waiting for $NODE_CONTAINER to become healthy (timeout: ${HEALTH_TIMEOUT_SECONDS}s)..."
ELAPSED=0
NODE_HEALTHY=false
while [ "$ELAPSED" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
  STATUS=$(docker inspect "$NODE_CONTAINER" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || echo "missing")
  case "$STATUS" in
    healthy)
      NODE_HEALTHY=true
      break
      ;;
    none)
      # Image has no healthcheck — fall back to "is it still running after a few seconds?"
      sleep 5
      if [ "$(docker inspect "$NODE_CONTAINER" --format '{{.State.Status}}' 2>/dev/null)" = "running" ]; then
        NODE_HEALTHY=true
      fi
      break
      ;;
    missing)
      echo -e "${RED}[ERROR] $NODE_CONTAINER disappeared. Inspect with: docker logs $NODE_CONTAINER${RESET}"
      exit 1
      ;;
  esac
  sleep "$HEALTH_POLL_INTERVAL_SECONDS"
  ELAPSED=$(( ELAPSED + HEALTH_POLL_INTERVAL_SECONDS ))
done

if [ "$NODE_HEALTHY" != true ]; then
  echo -e "${RED}[ERROR] $NODE_CONTAINER did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s.${RESET}"
  echo "Inspect with: docker logs $NODE_CONTAINER"
  exit 1
fi
echo "[SUCCESS] $NODE_CONTAINER is healthy"

### --- START ORCHESTRATOR AGENT --- ###
# Local-mode (no mTLS): ORCHESTRATOR_TRANSPORT=insecure switches the scheme to
# http and skips client-cert auth. ORCHESTRATOR_SERVER_HOST repoints the agent
# from the production cloud (api.autonomylogic.com:3001) at the local node
# over the internal bridge.
if [ "$ORCHESTRATOR_EXISTS" = false ]; then
  echo "Starting Device Agent: $ORCHESTRATOR_CONTAINER"
  docker run -d \
    --name "$ORCHESTRATOR_CONTAINER" \
    --network "$INTERNAL_NETWORK" \
    --restart unless-stopped \
    -e "ORCHESTRATOR_TRANSPORT=insecure" \
    -e "ORCHESTRATOR_SERVER_HOST=${NODE_CONTAINER}:${WS_PORT}" \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v "$SHARED_VOLUME:/var/orchestrator" \
    -v "$UPLOADS_VOLUME:/var/orchestrator/uploads" \
    "$ORCHESTRATOR_IMAGE" >/dev/null
  echo "[SUCCESS] $ORCHESTRATOR_CONTAINER started"

  # Give the orchestrator a moment to crash-loop visibly if its env is wrong.
  sleep 4
  if [ "$(docker inspect "$ORCHESTRATOR_CONTAINER" --format '{{.State.Status}}' 2>/dev/null)" != "running" ]; then
    echo -e "${RED}[ERROR] $ORCHESTRATOR_CONTAINER is not running after start.${RESET}"
    echo "Inspect with: docker logs $ORCHESTRATOR_CONTAINER"
    exit 1
  fi
else
  # An agent from before the upload feature has no uploads mount, so it would
  # look for archives somewhere the node cannot write. Recreating it is safe:
  # all of its state lives in the volumes, not the container.
  if ! docker inspect "$ORCHESTRATOR_CONTAINER" \
    --format '{{range .Mounts}}{{.Destination}} {{end}}' 2>/dev/null |
    grep -q '/var/orchestrator/uploads'; then
    echo "[INFO] $ORCHESTRATOR_CONTAINER predates the uploads volume — recreating"
    docker rm -f "$ORCHESTRATOR_CONTAINER" >/dev/null 2>&1 || true
    docker run -d \
      --name "$ORCHESTRATOR_CONTAINER" \
      --network "$INTERNAL_NETWORK" \
      --restart unless-stopped \
      -e "ORCHESTRATOR_TRANSPORT=insecure" \
      -e "ORCHESTRATOR_SERVER_HOST=${NODE_CONTAINER}:${WS_PORT}" \
      -v /var/run/docker.sock:/var/run/docker.sock \
      -v "$SHARED_VOLUME:/var/orchestrator" \
      -v "$UPLOADS_VOLUME:/var/orchestrator/uploads" \
      "$ORCHESTRATOR_IMAGE" >/dev/null
    echo "[SUCCESS] $ORCHESTRATOR_CONTAINER recreated with the uploads volume"
  else
    echo "[INFO] $ORCHESTRATOR_CONTAINER already present — leaving in place"
  fi
fi

### --- INSTALL HOST CLI WRAPPER --- ###
# `autonomy-node <cmd>` forwards a subcommand into the running node container,
# so operators run e.g. `sudo autonomy-node reset-password` instead of a raw
# docker exec. Rewritten on every run so it stays in sync. Best-effort: a
# read-only /usr/local (e.g. u-OS) only costs the shortcut, not the install.
install_cli_wrapper() {
  mkdir -p "$(dirname "$CLI_WRAPPER_HOST")" || return 1
  cat >"$CLI_WRAPPER_HOST" <<WRAPPER_EOF || return 1
#!/usr/bin/env bash
# Autonomy Node host CLI — forwards subcommands into the node container.
set -euo pipefail
CONTAINER="${NODE_CONTAINER}"
if ! command -v docker >/dev/null 2>&1; then
  echo "[ERROR] docker is not installed." >&2
  exit 1
fi
if ! docker ps --format '{{.Names}}' | grep -q "^\${CONTAINER}\$"; then
  echo "[ERROR] The \${CONTAINER} container is not running." >&2
  exit 1
fi
if [ "\$#" -eq 0 ]; then
  echo "Usage: autonomy-node <command>   (e.g. autonomy-node reset-password)" >&2
  exit 2
fi
exec docker exec -it -w /app/apps/backend "\$CONTAINER" node dist/loader.cjs "\$@"
WRAPPER_EOF
  chmod +x "$CLI_WRAPPER_HOST"
}

echo "Installing host CLI wrapper at $CLI_WRAPPER_HOST"
CLI_WRAPPER_INSTALLED=false
if install_cli_wrapper 2>/dev/null; then
  CLI_WRAPPER_INSTALLED=true
  echo "[SUCCESS] CLI wrapper installed — run: sudo autonomy-node reset-password"
else
  echo -e "${YELLOW}[WARN] Could not write $CLI_WRAPPER_HOST (read-only filesystem?); skipping the CLI wrapper. Run subcommands with: sudo docker exec -it -w /app/apps/backend $NODE_CONTAINER node dist/loader.cjs <cmd>${RESET}"
fi

### --- FINAL REPORT --- ###
# `|| true`: BusyBox hostname has no -I, and under pipefail its failure would
# abort the script here, after everything is already installed.
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
HOST_IP="${HOST_IP:-localhost}"

echo
echo -e "${BOLD}${GREEN}=====================================================${RESET}"
if [ "$NODE_EXISTS" = true ]; then
  echo -e "${BOLD}${GREEN}  NODE UPGRADE COMPLETE                               ${RESET}"
else
  echo -e "${BOLD}${GREEN}  INSTALLATION COMPLETE                               ${RESET}"
fi
echo -e "${BOLD}${GREEN}=====================================================${RESET}"
echo
echo -e "  Web UI:        ${CYAN}http://${HOST_IP}${WEB_UI_URL_SUFFIX}/${RESET}"
echo -e "  Internal WS:   ${GRAY}ws://${NODE_CONTAINER}:${WS_PORT}${RESET}  ${GRAY}(docker network only)${RESET}"
echo
echo "  Containers:"
echo -e "    - ${CYAN}${NETMON_CONTAINER}${RESET}        ${GRAY}(host network, network monitor)${RESET}"
echo -e "    - ${CYAN}${GITEA_CONTAINER}${RESET}         ${GRAY}(${INTERNAL_NETWORK}, git server, not published)${RESET}"
echo -e "    - ${CYAN}${NODE_CONTAINER}${RESET}          ${GRAY}(${INTERNAL_NETWORK}, ${NODE_PORTS_LABEL})${RESET}"
echo -e "    - ${CYAN}${ORCHESTRATOR_CONTAINER}${RESET}   ${GRAY}(${INTERNAL_NETWORK}, insecure transport)${RESET}"
echo
echo -e "  Runtime image: ${GRAY}${RUNTIME_IMAGE} (pre-pulled best-effort; the Device Agent pulls others on-demand)${RESET}"
echo
if [ "$CLI_WRAPPER_INSTALLED" = true ]; then
  echo "  Admin CLI:     sudo autonomy-node reset-password   ${GRAY}(recover a lost admin password)${RESET}"
fi
echo "  Logs:          docker logs -f <container>"
echo "  Uninstall:     sudo bash ./uninstall.sh"
echo -e "${GRAY}=====================================================${RESET}"
