#!/usr/bin/env bash
#
# Beever Atlas · Setup
#
# Usage:
#   ./atlas                    # interactive guided setup
#   ./atlas --non-interactive  # CI / unattended mode
#
# What it does (in order):
#   Prerequisites → .env bootstrap →
#   Step 1/5  Embedding model        (pick provider, then its key)
#   Step 2/5  Agent LLM provider     (pick provider; assign per-agent later in AI Setup)
#   Step 3/5  Graph backend          (neo4j or none)
#   Step 4/5  Optional integrations  (Tavily, MCP server)
#   Step 5/5  Auth tokens            (keep dev defaults or rotate)
#
# For CI/Docker: pass BEEVER_LLM_API_KEY (single-provider shortcut) or
# BEEVER_ENDPOINTS (JSON envelope) + BEEVER_PRESET, or use 'atlas apply'
# with an atlas.yaml — see scripts/atlas_apply.py.
#   Auto-gen secrets → docker compose up -d
#
# Defaults are tuned for local dev: pressing Enter through every prompt
# gives you a working stack with safe defaults. Rotate before any deploy.
#
# See .env.example for the full environment variable reference.

set -euo pipefail

cd "$(dirname "$0")"

# ---------------------------------------------------------------------
# Colors (auto-disabled when stdout isn't a TTY or NO_COLOR is set)
# ---------------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  C_RESET=$'\e[0m'
  C_BOLD=$'\e[1m'
  C_DIM=$'\e[2m'
  C_GREEN=$'\e[32m'
  C_RED=$'\e[31m'
  C_YELLOW=$'\e[33m'
  C_CYAN=$'\e[36m'
else
  C_RESET=""; C_BOLD=""; C_DIM=""; C_GREEN=""; C_RED=""; C_YELLOW=""; C_CYAN=""
fi

ok()      { printf "  ${C_GREEN}✓${C_RESET} %s\n" "$*"; }
warn()    { printf "  ${C_YELLOW}!${C_RESET} %s\n" "$*"; }
err()     { printf "  ${C_RED}✗${C_RESET} %s\n" "$*" >&2; }
ask()     { printf "  ${C_CYAN}?${C_RESET} %s\n" "$*"; }
hint()    { printf "    ${C_DIM}%s${C_RESET}\n" "$*"; }
hinterr() { printf "    ${C_DIM}%s${C_RESET}\n" "$*" >&2; }
section() { printf "\n${C_BOLD}%s${C_RESET}\n" "$*"; }
step()    { printf "\n${C_DIM}──── ${C_RESET}${C_BOLD}Step %s${C_RESET} ${C_DIM}· %s ────────────────${C_RESET}\n" "$1" "$2"; }
rule()    { printf "${C_DIM}%s${C_RESET}\n" "────────────────────────────────────────"; }

# Port preflight helper — returns 0 if port is free (or if we can't check),
# 1 if held by a non-docker process. Requires `lsof`; skips silently without it.
check_port() {
  local port="$1"
  local service="$2"
  if ! command -v lsof >/dev/null 2>&1; then
    return 0
  fi
  local holder
  holder=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1" (pid "$2")"}')
  if [ -z "$holder" ]; then
    return 0
  fi
  # Docker-owned ports are fine — our own stack may already hold them.
  case "$holder" in
    com.docke*|Docker*|docker*|vpnkit*) return 0 ;;
  esac
  warn "port ${port} is in use by: ${holder}  (needed for ${service})"
  return 1
}

# ---------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------
printf "\n"
printf "  ${C_CYAN}═══════════════════════════════════════════════════════════════════════════════════════════════${C_RESET}\n"
printf "\n"
printf "    ${C_BOLD}${C_CYAN}██████╗ ███████╗███████╗██╗   ██╗███████╗██████╗      █████╗ ████████╗██╗      █████╗ ███████╗${C_RESET}\n"
printf "    ${C_BOLD}${C_CYAN}██╔══██╗██╔════╝██╔════╝██║   ██║██╔════╝██╔══██╗    ██╔══██╗╚══██╔══╝██║     ██╔══██╗██╔════╝${C_RESET}\n"
printf "    ${C_BOLD}${C_CYAN}██████╔╝█████╗  █████╗  ██║   ██║█████╗  ██████╔╝    ███████║   ██║   ██║     ███████║███████╗${C_RESET}\n"
printf "    ${C_BOLD}${C_CYAN}██╔══██╗██╔══╝  ██╔══╝  ╚██╗ ██╔╝██╔══╝  ██╔══██╗    ██╔══██║   ██║   ██║     ██╔══██║╚════██║${C_RESET}\n"
printf "    ${C_BOLD}${C_CYAN}██████╔╝███████╗███████╗ ╚████╔╝ ███████╗██║  ██║    ██║  ██║   ██║   ███████╗██║  ██║███████║${C_RESET}\n"
printf "    ${C_BOLD}${C_CYAN}╚═════╝ ╚══════╝╚══════╝  ╚═══╝  ╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝   ╚═╝   ╚══════╝╚═╝  ╚═╝╚══════╝${C_RESET}\n"
printf "\n"
printf "    ${C_BOLD}Self-hosted memory + LLM Wiki for chat-based teams${C_RESET}\n"
printf "    ${C_DIM}Slack · Discord · OpenClaw · Hermes Agents${C_RESET}\n"
printf "\n"
printf "  ${C_CYAN}════════════════════════════════ ${C_RESET}${C_BOLD}Local installer · ~2 minutes${C_RESET} ${C_CYAN}═════════════════════════════════${C_RESET}\n"
printf "  ${C_DIM}Press Enter at any prompt to accept the default — safe for local.${C_RESET}\n"
printf "\n"

# ---------------------------------------------------------------------
# 1. Argument parsing
# ---------------------------------------------------------------------
NON_INTERACTIVE=false
for arg in "$@"; do
  case "$arg" in
    --non-interactive) NON_INTERACTIVE=true ;;
    -h|--help)
      printf "Usage: %s [--non-interactive]\n" "$0"
      exit 0
      ;;
    *)
      err "Unknown flag: $arg"
      hinterr "Usage: $0 [--non-interactive]"
      exit 2
      ;;
  esac
done

# ---------------------------------------------------------------------
# 2. Prerequisites
# ---------------------------------------------------------------------
section "Prerequisites"

if ! command -v docker >/dev/null 2>&1; then
  err "docker is not installed or not on PATH"
  hinterr "Install Docker: https://docs.docker.com/get-docker/"
  exit 1
fi
ok "docker"

COMPOSE_CMD=""
if docker compose version >/dev/null 2>&1; then
  COMPOSE_CMD="docker compose"
  ok "docker compose plugin"
elif command -v docker-compose >/dev/null 2>&1 && docker-compose --version >/dev/null 2>&1; then
  COMPOSE_CMD="docker-compose"
  ok "docker-compose (legacy)"
else
  err "neither 'docker compose' plugin nor 'docker-compose' is available"
  hinterr "Install: https://docs.docker.com/compose/install/"
  exit 1
fi

PY_CMD=""
if command -v python3 >/dev/null 2>&1; then
  PY_CMD="python3"
  ok "python3"
elif command -v python >/dev/null 2>&1; then
  PY_CMD="python"
  ok "python"
else
  warn "python not found — secret regen will be manual"
fi

# ---------------------------------------------------------------------
# 2b. Port preflight (fail fast if a host process owns a needed port)
# ---------------------------------------------------------------------
section "Ports"
stack_running=false
if $COMPOSE_CMD ps --services --filter "status=running" 2>/dev/null | grep -q .; then
  stack_running=true
  ok "compose stack already running — skipping port check"
else
  conflicts=0
  # service:port pairs for every port the stack binds on localhost
  for entry in "27017:MongoDB" "8080:Weaviate" "7687:Neo4j_Bolt" "7474:Neo4j_HTTP" "6380:Redis" "8000:backend" "3000:web" "3001:bot"; do
    p="${entry%%:*}"
    s="${entry#*:}"
    check_port "$p" "$s" || conflicts=$((conflicts + 1))
  done
  if [ "$conflicts" -gt 0 ]; then
    err "${conflicts} port(s) are already in use by non-docker processes"
    hinterr "Free them (stop the listed process) and re-run, or edit docker-compose.yml to use different ports."
    exit 1
  fi
  ok "no port conflicts"
fi

# ---------------------------------------------------------------------
# 3. Environment file
# ---------------------------------------------------------------------
section "Environment"

if [ -f .env ]; then
  ok ".env already present — skipping copy"
else
  cp .env.example .env
  ok "Created .env from .env.example"
  # Restrict permissions: .env holds real secrets (API keys, master key).
  # chmod 600 limits read to the owner. Non-fatal: some FS (Docker Desktop
  # volume mounts) ignore permission bits.
  if chmod 600 .env 2>/dev/null; then
    : # silently succeeded
  else
    warn ".env permissions could not be restricted to 600 — set manually if on a shared machine"
  fi
fi

# Replace KEY=value in .env in place, portable between macOS (BSD sed)
# and Linux (GNU sed). `-i.bak` works on both; we delete the backup.
replace_env_value() {
  local key="$1"
  local value="$2"
  # Escape sed replacement-string metacharacters: | (delimiter), & (match
  # backref), \ (escape). Protects against .env corruption when values
  # contain these chars (e.g. tokens with pipes or ampersands).
  local escaped
  escaped=$(printf '%s' "$value" | sed 's/[|&\\]/\\&/g')
  sed -i.bak "s|^${key}=.*|${key}=${escaped}|" .env
  rm -f .env.bak
}

# Generate a random hex token of N bytes (2N hex chars). Echoes the
# token on stdout. Issue #41 — falls back to POSIX `od /dev/urandom`
# when Python is missing, so minimal hosts no longer leave the public
# .env.example placeholder in place. Returns non-zero only when both
# Python and /dev/urandom are unavailable.
gen_hex() {
  local bytes="${1:-24}"
  if [ -n "$PY_CMD" ]; then
    "$PY_CMD" -c "import secrets; print(secrets.token_hex(${bytes}))"
    return $?
  fi
  if [ -r /dev/urandom ]; then
    # POSIX `od` (BSD + GNU + busybox); strip spaces and newlines from
    # the output. Produces 2N hex chars matching `secrets.token_hex(N)`.
    od -An -tx1 -N"${bytes}" /dev/urandom | tr -d ' \n'
    echo
    return 0
  fi
  error "gen_hex: neither Python nor /dev/urandom available — cannot generate secret"
  return 1
}

# Confirm prompt. Args: default(Y|N), prompt text. Returns 0 for yes, 1 for no.
confirm() {
  local default="$1"
  local question="$2"
  local suffix
  if [ "$default" = "Y" ]; then suffix="(Y/n)"; else suffix="(y/N)"; fi
  printf "  ${C_CYAN}?${C_RESET} %s ${C_DIM}%s${C_RESET} " "$question" "$suffix"
  local reply=""
  read -r reply || true
  reply="${reply:-$default}"
  case "$reply" in
    y|Y|yes|YES|Yes) return 0 ;;
    n|N|no|NO|No)    return 1 ;;
    *) [ "$default" = "Y" ] && return 0 || return 1 ;;
  esac
}

# Arrow-key menu picker. Returns the chosen 1-based index via global $REPLY.
#
# Args:  default_idx_1based  item1  item2  ...
#
# Bindings: ↑/↓/k/j move · Home/End jump · Enter selects · Esc/q keeps default
#
# Falls back to a typed-number prompt (preserving prior UX) when:
#   * stdin or stdout is not a TTY (CI, piped input, redirected output)
#   * ``tput`` is unavailable (no ncurses)
#   * ``ATLAS_NO_TUI=1`` is set (operator escape hatch)
#
# Each item is printed verbatim, so callers can embed colour escapes inline.
# The picker hides the cursor while interactive and restores it on every exit
# path (including SIGINT/SIGTERM) via a trap.
pick_menu() {
  local _pm_default="$1"; shift
  local _pm_items=("$@")
  local _pm_n=${#_pm_items[@]}

  # Fallback: behaves like the original numbered prompt.
  if [ ! -t 0 ] || [ ! -t 1 ] || [ "${ATLAS_NO_TUI:-}" = "1" ] || ! command -v tput >/dev/null 2>&1; then
    local _pm_i=1
    local _pm_item
    for _pm_item in "${_pm_items[@]}"; do
      printf "    ${C_BOLD}%d)${C_RESET} %s\n" "$_pm_i" "$_pm_item"
      _pm_i=$((_pm_i + 1))
    done
    printf "\n    ${C_DIM}Choice [%s]:${C_RESET} " "$_pm_default"
    local _pm_reply=""
    read -r _pm_reply || true
    REPLY="${_pm_reply:-$_pm_default}"
    return 0
  fi

  local _pm_idx=$((_pm_default - 1))
  [ "$_pm_idx" -lt 0 ] && _pm_idx=0
  [ "$_pm_idx" -ge "$_pm_n" ] && _pm_idx=$((_pm_n - 1))

  # Cursor restoration is critical — the picker hides the cursor while drawing.
  # If the user hits Ctrl+C or the script crashes mid-loop without this trap
  # the terminal is left without a visible cursor until ``tput cnorm`` is run
  # manually (or the shell is reset).
  trap 'tput cnorm 2>/dev/null' EXIT INT TERM
  tput civis

  local _pm_first=true
  local _pm_item _pm_i

  _pm_draw() {
    if [ "$_pm_first" = "false" ]; then
      tput cuu "$_pm_n"
    fi
    _pm_first=false
    _pm_i=0
    for _pm_item in "${_pm_items[@]}"; do
      tput el
      if [ "$_pm_i" -eq "$_pm_idx" ]; then
        printf "  ${C_CYAN}▶${C_RESET} ${C_BOLD}%s${C_RESET}\n" "$_pm_item"
      else
        printf "    %s\n" "$_pm_item"
      fi
      _pm_i=$((_pm_i + 1))
    done
  }
  _pm_draw

  local _pm_key _pm_ext _pm_result=""
  while [ -z "$_pm_result" ]; do
    _pm_key=""
    IFS= read -rsn1 _pm_key || true
    case "$_pm_key" in
      $'\e')  # ESC — could be a plain Esc or the start of an arrow sequence.
        # Byte-by-byte reads: more robust than ``read -rsn2`` because some
        # TTY emulations (expect, mosh, slow ssh) can drop the second byte
        # if both are demanded in a single read call. ``-t 1`` (integer,
        # not 0.5 — bash 3.2 on macOS rejects fractional timeouts with
        # "invalid timeout specification" so the read fails open and the
        # arrow looks like a plain Esc). 1-second cap on plain-Esc
        # detection is below human reaction time, so harmless.
        _pm_b1=""; _pm_b2=""
        IFS= read -rsn1 -t 1 _pm_b1 2>/dev/null || _pm_b1=""
        case "$_pm_b1" in
          '['|'O')
            IFS= read -rsn1 -t 1 _pm_b2 2>/dev/null || _pm_b2=""
            case "$_pm_b2" in
              A) [ "$_pm_idx" -gt 0 ] && _pm_idx=$((_pm_idx - 1)) ;;        # up
              B) [ "$_pm_idx" -lt $((_pm_n - 1)) ] && _pm_idx=$((_pm_idx + 1)) ;; # down
              H) _pm_idx=0 ;;                                                # home
              F) _pm_idx=$((_pm_n - 1)) ;;                                   # end
            esac
            ;;
          '') _pm_result="$_pm_default" ;;  # plain Esc
        esac
        ;;
      ''|$'\n'|$'\r') _pm_result=$((_pm_idx + 1)) ;;
      k|K)            [ "$_pm_idx" -gt 0 ] && _pm_idx=$((_pm_idx - 1)) ;;
      j|J)            [ "$_pm_idx" -lt $((_pm_n - 1)) ] && _pm_idx=$((_pm_idx + 1)) ;;
      q|Q)            _pm_result="$_pm_default" ;;
    esac
    [ -z "$_pm_result" ] && _pm_draw
  done

  tput cnorm
  trap - EXIT INT TERM
  REPLY="$_pm_result"
  return 0
}

# External-provider key prompt: user pastes or presses Enter to skip.
# Falls back to shell env var if the user presses Enter and the env var is set.
prompt_provider_model() {
  # Optional model-override prompt. Shows the curated catalog for the chosen
  # provider and lets the operator either accept the recommended default
  # (Enter) or type any model name — including one not in the catalog
  # (e.g. a private fine-tune, a preview model not yet in known_models.py).
  # Writes ``<prefix>/<chosen>`` to ``$5`` (defaults to LLM_FAST_MODEL).
  local label="$1"
  local prefix="$2"
  local default_model="$3"
  local available="$4"
  local env_var="${5:-LLM_FAST_MODEL}"

  ask "${C_BOLD}${label}${C_RESET} default chat model"
  hint "Available: ${C_DIM}${available}${C_RESET}"
  hint "Enter to keep ${C_GREEN}${default_model}${C_RESET}, or type a custom model name"
  printf "    ${C_DIM}${env_var}${C_RESET} ${prefix}/"
  local user_input=""
  IFS= read -r user_input || true
  user_input=$(printf "%s" "$user_input" | tr -d '[:space:]')
  if [ -n "$user_input" ]; then
    # Strip an accidental ``${prefix}/`` if the operator typed the full form
    user_input="${user_input#${prefix}/}"
    replace_env_value "$env_var" "${prefix}/${user_input}"
    ok "${env_var}=${prefix}/${user_input}"
  fi
}

prompt_external_key() {
  local key="$1"
  local label="$2"
  local url="$3"

  local current
  current=$(grep -E "^${key}=" .env | head -n 1 | cut -d'=' -f2-)

  ask "${C_BOLD}${label}${C_RESET} ${C_DIM}· ${url}${C_RESET}"
  if [ -n "$current" ]; then
    # Issue #52 — show only the last 4 chars (not the first 8). The
    # leading bytes of provider keys are structured (`AIzaSy…` for
    # Google, `jina_…` for Jina) and revealing them aids targeted
    # brute-force if the terminal is logged. The trailing 4 are
    # enough for the operator to recognize their own key, and we
    # fall back to length-only for keys too short to mask safely.
    local len=${#current}
    if [ "$len" -ge 12 ]; then
      hint "Current: …${current: -4} (Enter = keep)"
    else
      hint "Current: ${len}-char value set (Enter = keep)"
    fi
  else
    hint "Paste the key, or press Enter to skip"
  fi
  printf "    ${C_DIM}${key}${C_RESET} "
  local value=""
  read -r value || true
  if [ -z "$value" ]; then
    # No explicit input — try shell environment as fallback
    local env_val="${!key:-}" 2>/dev/null || env_val=""
    if [ -n "$env_val" ]; then
      replace_env_value "$key" "$env_val"
      ok "pre-seeded from shell environment (saved)"
    elif [ -n "$current" ]; then
      ok "skipped — using existing value"
    else
      ok "skipped"
    fi
  else
    replace_env_value "$key" "$value"
    ok "saved"
  fi
}

# Internal-secret prompt: user pastes OR presses Enter to auto-generate.
prompt_internal_secret() {
  local key="$1"
  local label="$2"
  local bytes="${3:-24}"

  ask "${C_BOLD}${label}${C_RESET}"
  hint "Paste your own value, or press Enter to auto-generate"
  printf "    ${C_DIM}${key}${C_RESET} "
  local value=""
  read -r value || true
  if [ -z "$value" ]; then
    # Issue #41 — gen_hex now falls back to /dev/urandom if Python is
    # missing. Only warn-and-skip when both paths fail.
    value=$(gen_hex "$bytes")
    if [ -n "$value" ]; then
      replace_env_value "$key" "$value"
      ok "auto-generated"
    else
      warn "Cannot auto-generate ${key} (no Python and no /dev/urandom) — set manually later"
    fi
  else
    replace_env_value "$key" "$value"
    ok "saved"
  fi
}

# ---------------------------------------------------------------------
# Non-interactive shortcut: skip all prompts, only do idempotent work.
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" = "true" ]; then
  section "Non-interactive mode"
  ok "skipping prompts — all user-supplied values left as-is"
fi

# ---------------------------------------------------------------------
# Per-step recap collected here, printed in a "Configuration summary"
# block right before launch so operators can verify the .env they just
# wrote without scrolling back through the prompts.
# ---------------------------------------------------------------------
_atlas_summary=()

# ---------------------------------------------------------------------
# Step 1 · Embedding model — pick the provider FIRST, then prompt only
# its API key. Avoids the old "paste two keys then choose" trap where
# users entered a Jina key only to switch to OpenAI on the next prompt.
#
# Uses pick_menu (arrow-key picker) when the terminal supports it;
# falls back to the typed-number prompt automatically otherwise.
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" != "true" ]; then
  step "1/5" "Embedding model"
  hint "Each chat message becomes a vector — this powers hybrid search."
  hint "Pick the provider you want; you can switch any time in Settings → Embedding."
  hint "${C_DIM}↑/↓ to move · Enter to select · Esc/q to keep default${C_RESET}"
  printf "\n"

  # Detect the currently-saved provider so the picker cursor lands on it
  # and we can mark that row with a "● current" badge. Re-runs of the
  # installer feel idempotent — Enter accepts what's already in .env.
  emb_default=1
  cur_emb_prov=$(grep -E '^EMBEDDING_PROVIDER=' .env 2>/dev/null | head -n1 | cut -d'=' -f2- || true)
  cur_emb_model=$(grep -E '^EMBEDDING_MODEL=' .env 2>/dev/null | head -n1 | cut -d'=' -f2- || true)
  case "${cur_emb_prov}/${cur_emb_model}" in
    jina_ai/*)                       emb_default=1 ;;
    openai/text-embedding-3-large)   emb_default=2 ;;
    openai/text-embedding-3-small)   emb_default=3 ;;
    voyage/*)                        emb_default=4 ;;
    cohere/*)                        emb_default=5 ;;
    gemini/*)                        emb_default=6 ;;
    mistral/*)                       emb_default=7 ;;
    ollama/*)                        emb_default=8 ;;
  esac

  emb_items=(
    "Jina v4              ${C_GREEN}multilingual${C_RESET}    ~\$0.18/1M tok    ${C_DIM}strong recall${C_RESET}"
    "OpenAI 3-large       ${C_GREEN}multilingual${C_RESET}    ~\$0.13/1M tok    ${C_DIM}widest cloud option${C_RESET}"
    "OpenAI 3-small       ${C_GREEN}multilingual${C_RESET}    ~\$0.02/1M tok    ${C_DIM}cheapest cloud${C_RESET}"
    "Voyage 3-large       ${C_GREEN}multilingual${C_RESET}    ~\$0.18/1M tok    ${C_DIM}strong reranker pair${C_RESET}"
    "Cohere multi-v3      ${C_GREEN}multilingual${C_RESET}    ~\$0.10/1M tok"
    "Gemini emb-001       ${C_GREEN}multilingual${C_RESET}    ~\$0.025/1M tok   ${C_DIM}reuses Step 2 GOOGLE_API_KEY${C_RESET}"
    "Mistral embed        ${C_GREEN}multilingual${C_RESET}    ~\$0.10/1M tok"
    "Ollama nomic         ${C_YELLOW}English-leaning${C_RESET} ${C_GREEN}FREE (local)${C_RESET}     ${C_DIM}no key required${C_RESET}"
  )
  if [ "$emb_default" -ge 1 ] && [ "$emb_default" -le ${#emb_items[@]} ]; then
    emb_items[$((emb_default - 1))]="${emb_items[$((emb_default - 1))]}  ${C_GREEN}● current${C_RESET}"
  fi

  pick_menu "$emb_default" "${emb_items[@]}"
  emb_choice="$REPLY"

  case "$emb_choice" in
    1|jina|jina_ai)
      replace_env_value "EMBEDDING_PROVIDER"   "jina_ai"
      replace_env_value "EMBEDDING_MODEL"      "jina-embeddings-v4"
      replace_env_value "EMBEDDING_DIMENSIONS" "2048"
      ok "EMBEDDING_PROVIDER=jina_ai · jina-embeddings-v4 @ 2048d (default)"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Jina v4 · jina_ai/jina-embeddings-v4 @ 2048d")
      prompt_external_key "JINA_API_KEY" "Jina v4 embeddings" "https://jina.ai/api-dashboard/"
      ;;
    2|openai-large|openai_large)
      replace_env_value "EMBEDDING_PROVIDER"   "openai"
      replace_env_value "EMBEDDING_MODEL"      "text-embedding-3-large"
      replace_env_value "EMBEDDING_DIMENSIONS" "3072"
      ok "EMBEDDING_PROVIDER=openai · text-embedding-3-large @ 3072d"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   OpenAI 3-large · text-embedding-3-large @ 3072d")
      prompt_external_key "OPENAI_API_KEY" "OpenAI" "https://platform.openai.com/api-keys"
      ;;
    3|openai-small|openai_small)
      replace_env_value "EMBEDDING_PROVIDER"   "openai"
      replace_env_value "EMBEDDING_MODEL"      "text-embedding-3-small"
      replace_env_value "EMBEDDING_DIMENSIONS" "1536"
      ok "EMBEDDING_PROVIDER=openai · text-embedding-3-small @ 1536d (cheapest cloud)"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   OpenAI 3-small · text-embedding-3-small @ 1536d")
      prompt_external_key "OPENAI_API_KEY" "OpenAI" "https://platform.openai.com/api-keys"
      ;;
    4|voyage)
      replace_env_value "EMBEDDING_PROVIDER"   "voyage"
      replace_env_value "EMBEDDING_MODEL"      "voyage-3-large"
      replace_env_value "EMBEDDING_DIMENSIONS" "1024"
      ok "EMBEDDING_PROVIDER=voyage · voyage-3-large @ 1024d"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Voyage 3-large · voyage-3-large @ 1024d")
      prompt_external_key "VOYAGE_API_KEY" "Voyage AI" "https://dash.voyageai.com/"
      ;;
    5|cohere)
      replace_env_value "EMBEDDING_PROVIDER"   "cohere"
      replace_env_value "EMBEDDING_MODEL"      "embed-multilingual-v3.0"
      replace_env_value "EMBEDDING_DIMENSIONS" "1024"
      ok "EMBEDDING_PROVIDER=cohere · embed-multilingual-v3.0 @ 1024d"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Cohere multi-v3 · embed-multilingual-v3.0 @ 1024d")
      prompt_external_key "COHERE_API_KEY" "Cohere" "https://dashboard.cohere.com/api-keys"
      ;;
    6|gemini)
      replace_env_value "EMBEDDING_PROVIDER"   "gemini"
      replace_env_value "EMBEDDING_MODEL"      "gemini-embedding-001"
      replace_env_value "EMBEDDING_DIMENSIONS" "3072"
      ok "EMBEDDING_PROVIDER=gemini · gemini-embedding-001 @ 3072d"
      hint "Gemini embedding reuses GOOGLE_API_KEY from Step 2 — no separate key needed."
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Gemini emb-001 · gemini-embedding-001 @ 3072d (uses GOOGLE_API_KEY)")
      ;;
    7|mistral)
      replace_env_value "EMBEDDING_PROVIDER"   "mistral"
      replace_env_value "EMBEDDING_MODEL"      "mistral-embed"
      replace_env_value "EMBEDDING_DIMENSIONS" "1024"
      ok "EMBEDDING_PROVIDER=mistral · mistral-embed @ 1024d"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Mistral embed · mistral-embed @ 1024d")
      prompt_external_key "MISTRAL_API_KEY" "Mistral" "https://console.mistral.ai/api-keys"
      ;;
    8|ollama)
      replace_env_value "EMBEDDING_PROVIDER"   "ollama"
      replace_env_value "EMBEDDING_MODEL"      "nomic-embed-text"
      replace_env_value "EMBEDDING_DIMENSIONS" "768"
      replace_env_value "EMBEDDING_API_BASE"   "http://localhost:11434"
      ok "EMBEDDING_PROVIDER=ollama · nomic-embed-text @ 768d (local, no key)"
      warn "Heads-up: Ollama nomic-embed-text is English-leaning. Switch to a multilingual model in Settings if you have non-English channels."
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Ollama nomic · nomic-embed-text @ 768d (local)")
      ;;
    *)
      warn "Unknown choice '${emb_choice}' — kept default Jina v4. Change later in Settings → Embedding."
      replace_env_value "EMBEDDING_PROVIDER"   "jina_ai"
      replace_env_value "EMBEDDING_MODEL"      "jina-embeddings-v4"
      replace_env_value "EMBEDDING_DIMENSIONS" "2048"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Embedding   Jina v4 (fallback)")
      prompt_external_key "JINA_API_KEY" "Jina v4 embeddings" "https://jina.ai/api-dashboard/"
      ;;
  esac
fi

# ---------------------------------------------------------------------
# Step 2 · Agent LLM provider
# Powers fact extraction, hybrid search ranking, and answer composition
# (the 16 ADK agents). agent-llm-provider-pluggable: pick any provider;
# you can mix providers per-agent later via Settings → AI Setup.
# Writes LLM_FAST_MODEL / LLM_QUALITY_MODEL + the chosen provider's key.
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" != "true" ]; then
  step "2/5" "Agent LLM provider"
  hint "Pick your default agent provider. You can assign different providers"
  hint "per-agent later in Settings → AI Setup, or via 'atlas apply' with a YAML."
  printf "\n"

  # Detect the currently-saved primary from LLM_FAST_MODEL.
  agent_default=1
  cur_fast=$(grep -E '^LLM_FAST_MODEL=' .env 2>/dev/null | head -n1 | cut -d'=' -f2- || true)
  case "$cur_fast" in
    openai/*|gpt-*)      agent_default=2 ;;
    anthropic/*|claude-*) agent_default=3 ;;
    mistral/*)           agent_default=4 ;;
    deepseek/*)          agent_default=5 ;;
    groq/*)              agent_default=6 ;;
    minimax/*)           agent_default=7 ;;
    ollama_chat/*|ollama/*) agent_default=8 ;;
    *)                   agent_default=1 ;;
  esac

  agent_items=(
    "Google Gemini       ${C_GREEN}free tier${C_RESET}     multimodal + native batch   ${C_DIM}AIzaSy… key${C_RESET}"
    "OpenAI              gpt-4o-mini / gpt-4.1 / o4-mini   ${C_DIM}sk-… key${C_RESET}"
    "Anthropic Claude    haiku-4.5 / sonnet-4.6            ${C_DIM}sk-ant-… key${C_RESET}"
    "Mistral             small / large"
    "DeepSeek            chat / reasoner"
    "Groq                llama-3.3-70b                     ${C_DIM}fast inference${C_RESET}"
    "MiniMax             abab6.5s-chat"
    "Ollama (local)      gemma3 / qwen2.5 / llama3.3       ${C_GREEN}FREE (local)${C_RESET}  ${C_DIM}no key${C_RESET}"
    "Custom / configure in UI later"
  )
  if [ "$agent_default" -ge 1 ] && [ "$agent_default" -le ${#agent_items[@]} ]; then
    agent_items[$((agent_default - 1))]="${agent_items[$((agent_default - 1))]}  ${C_GREEN}● current${C_RESET}"
  fi

  pick_menu "$agent_default" "${agent_items[@]}"
  agent_choice="$REPLY"

  case "$agent_choice" in
    1|gemini|google)
      replace_env_value "LLM_FAST_MODEL"    "gemini/gemini-2.5-flash"
      replace_env_value "LLM_QUALITY_MODEL" "gemini/gemini-2.5-pro"
      ok "LLM_FAST_MODEL=gemini/gemini-2.5-flash · LLM_QUALITY_MODEL=gemini/gemini-2.5-pro"
      prompt_external_key "GOOGLE_API_KEY" "Google Gemini" "https://aistudio.google.com/apikey"
      prompt_provider_model "Google Gemini" "gemini" "gemini-2.5-flash" \
        "gemini-2.5-flash, gemini-2.5-flash-lite, gemini-2.5-pro"
      if grep -qE '^GOOGLE_API_KEY=.+' .env 2>/dev/null; then
        _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   Google Gemini · key set")
      else
        _atlas_summary+=("${C_YELLOW}!${C_RESET} Agent LLM   Google Gemini · key NOT set (paste later in .env)")
      fi
      ;;
    2|openai)
      replace_env_value "LLM_FAST_MODEL"    "openai/gpt-4o-mini"
      replace_env_value "LLM_QUALITY_MODEL" "openai/gpt-4.1"
      ok "LLM_FAST_MODEL=openai/gpt-4o-mini · LLM_QUALITY_MODEL=openai/gpt-4.1"
      prompt_external_key "OPENAI_API_KEY" "OpenAI" "https://platform.openai.com/api-keys"
      prompt_provider_model "OpenAI" "openai" "gpt-4o-mini" \
        "gpt-4o-mini, gpt-4.1, o4-mini, gpt-4.1-mini"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   OpenAI")
      ;;
    3|anthropic|claude)
      replace_env_value "LLM_FAST_MODEL"    "anthropic/claude-haiku-4-5"
      replace_env_value "LLM_QUALITY_MODEL" "anthropic/claude-sonnet-4-6"
      ok "LLM_FAST_MODEL=anthropic/claude-haiku-4-5 · LLM_QUALITY_MODEL=anthropic/claude-sonnet-4-6"
      prompt_external_key "ANTHROPIC_API_KEY" "Anthropic" "https://console.anthropic.com/settings/keys"
      prompt_provider_model "Anthropic" "anthropic" "claude-haiku-4-5" \
        "claude-haiku-4-5, claude-sonnet-4-6, claude-opus-4-7"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   Anthropic")
      ;;
    4|mistral)
      replace_env_value "LLM_FAST_MODEL"    "mistral/mistral-small-latest"
      replace_env_value "LLM_QUALITY_MODEL" "mistral/mistral-large-latest"
      ok "LLM_FAST_MODEL=mistral/mistral-small-latest · LLM_QUALITY_MODEL=mistral/mistral-large-latest"
      prompt_external_key "MISTRAL_API_KEY" "Mistral" "https://console.mistral.ai/api-keys"
      prompt_provider_model "Mistral" "mistral" "mistral-small-latest" \
        "mistral-small-latest, mistral-large-latest, codestral-latest"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   Mistral")
      ;;
    5|deepseek)
      replace_env_value "LLM_FAST_MODEL"    "deepseek/deepseek-chat"
      replace_env_value "LLM_QUALITY_MODEL" "deepseek/deepseek-chat"
      ok "LLM_FAST_MODEL=deepseek/deepseek-chat · LLM_QUALITY_MODEL=deepseek/deepseek-chat"
      prompt_external_key "DEEPSEEK_API_KEY" "DeepSeek" "https://platform.deepseek.com/api_keys"
      prompt_provider_model "DeepSeek" "deepseek" "deepseek-chat" \
        "deepseek-chat, deepseek-reasoner"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   DeepSeek")
      ;;
    6|groq)
      replace_env_value "LLM_FAST_MODEL"    "groq/llama-3.3-70b-versatile"
      replace_env_value "LLM_QUALITY_MODEL" "groq/llama-3.3-70b-versatile"
      ok "LLM_FAST_MODEL=groq/llama-3.3-70b-versatile · LLM_QUALITY_MODEL=groq/llama-3.3-70b-versatile"
      prompt_external_key "GROQ_API_KEY" "Groq" "https://console.groq.com/keys"
      prompt_provider_model "Groq" "groq" "llama-3.3-70b-versatile" \
        "llama-3.3-70b-versatile, llama-3.1-8b-instant, mixtral-8x7b-32768"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   Groq")
      ;;
    7|minimax)
      replace_env_value "LLM_FAST_MODEL"    "minimax/abab6.5s-chat"
      replace_env_value "LLM_QUALITY_MODEL" "minimax/abab6.5s-chat"
      ok "LLM_FAST_MODEL=minimax/abab6.5s-chat · LLM_QUALITY_MODEL=minimax/abab6.5s-chat"
      prompt_external_key "MINIMAX_API_KEY" "MiniMax" "https://platform.minimaxi.com/document/Models"
      prompt_provider_model "MiniMax" "minimax" "abab6.5s-chat" \
        "abab6.5s-chat, abab6.5-chat"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   MiniMax")
      ;;
    8|ollama|local)
      replace_env_value "LLM_FAST_MODEL"    "ollama_chat/gemma3:e4b"
      replace_env_value "LLM_QUALITY_MODEL" "ollama_chat/qwen2.5:14b"
      replace_env_value "OLLAMA_ENABLED"    "true"
      replace_env_value "OLLAMA_API_BASE"   "http://localhost:11434"
      ok "LLM_FAST_MODEL=ollama_chat/gemma3:e4b · OLLAMA_ENABLED=true (no key needed)"
      prompt_provider_model "Ollama (local)" "ollama_chat" "gemma3:e4b" \
        "gemma3:e4b, qwen2.5:14b, llama3.3, qwen2.5-coder, phi4 (any model you have pulled locally)"
      hint "Make sure Ollama is running locally: 'ollama serve' + 'ollama pull <model>'"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Agent LLM   Ollama (local, no key)")
      ;;
    *)
      hint "Skipped — set LLM_FAST_MODEL / LLM_QUALITY_MODEL + the provider key in .env,"
      hint "or use 'atlas apply' with an atlas.yaml, or Settings → AI Setup once running."
      _atlas_summary+=("${C_DIM}—${C_RESET} Agent LLM   not configured (configure later)")
      ;;
  esac

  # Optional second-provider prompt for hybrid setups.
  if confirm "N" "Configure a second provider for hybrid setups (e.g. Claude quality + Gemini fast)?"; then
    printf "\n"
    pick_menu 1 "${agent_items[@]}"
    case "$REPLY" in
      1|gemini) prompt_external_key "GOOGLE_API_KEY"   "Google Gemini" "https://aistudio.google.com/apikey" ;;
      2|openai) prompt_external_key "OPENAI_API_KEY"   "OpenAI"        "https://platform.openai.com/api-keys" ;;
      3|claude) prompt_external_key "ANTHROPIC_API_KEY" "Anthropic"    "https://console.anthropic.com/settings/keys" ;;
      4|mistral) prompt_external_key "MISTRAL_API_KEY" "Mistral"       "https://console.mistral.ai/api-keys" ;;
      5|deepseek) prompt_external_key "DEEPSEEK_API_KEY" "DeepSeek"    "https://platform.deepseek.com/api_keys" ;;
      6|groq) prompt_external_key "GROQ_API_KEY"        "Groq"         "https://console.groq.com/keys" ;;
      7|minimax) prompt_external_key "MINIMAX_API_KEY"  "MiniMax"      "https://platform.minimaxi.com/document/Models" ;;
      *) : ;;
    esac
    hint "${C_DIM}↪ Once running, Settings → AI Setup lets you assign agents to the secondary provider.${C_RESET}"
  fi

  printf "${C_DIM}↪ Settings → AI Setup lets you assign different providers per agent, run Test Connection, and discover models.${C_RESET}\n"
fi

# ---------------------------------------------------------------------
# Step 3 · Graph backend (architectural decision — keep before optional
# integrations so users see core stack choices first).
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" != "true" ]; then
  step "3/5" "Graph backend"
  hint "Stores entity → entity relationships for graph queries."
  hint "${C_DIM}↑/↓ to move · Enter to select · Esc/q to keep default${C_RESET}"
  printf "\n"

  gb_default=1
  cur_gb=$(grep -E '^GRAPH_BACKEND=' .env 2>/dev/null | head -n1 | cut -d'=' -f2- || true)
  case "$cur_gb" in
    none) gb_default=2 ;;
    *)    gb_default=1 ;;
  esac

  gb_items=(
    "neo4j  ${C_DIM}recommended · powers entity graph queries${C_RESET}"
    "none   ${C_DIM}semantic memory only · skips Neo4j entirely${C_RESET}"
  )
  gb_items[$((gb_default - 1))]="${gb_items[$((gb_default - 1))]}  ${C_GREEN}● current${C_RESET}"

  pick_menu "$gb_default" "${gb_items[@]}"
  case "$REPLY" in
    2|none|n|None|NONE)
      replace_env_value "GRAPH_BACKEND" "none"
      ok "GRAPH_BACKEND=none"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Graph       none (semantic memory only)")
      ;;
    *)
      replace_env_value "GRAPH_BACKEND" "neo4j"
      ok "GRAPH_BACKEND=neo4j"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Graph       neo4j")
      ;;
  esac
fi

# ---------------------------------------------------------------------
# Step 4 · Optional integrations
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" != "true" ]; then
  step "4/5" "Optional integrations"
  hint "External web search (Tavily) and MCP for Claude Code / Cursor."

  opt_parts=()
  if confirm "N" "Configure optional integrations now?"; then
    # Tavily
    prompt_external_key "TAVILY_API_KEY" "Tavily (external web search in QA)" "https://tavily.com/"
    if grep -qE '^TAVILY_API_KEY=.+' .env 2>/dev/null; then
      opt_parts+=("Tavily ✓")
    else
      opt_parts+=("Tavily skipped")
    fi

    # Ollama is now a first-class agent-provider choice in Step 2 — no
    # separate prompt here. (If you picked Ollama in Step 2 it's already
    # enabled; otherwise pick it there or set OLLAMA_ENABLED=true in .env.)

    # MCP server
    if confirm "N" "Enable the MCP server for Claude Code / Cursor?"; then
      replace_env_value "BEEVER_MCP_ENABLED" "true"
      # Issue #41 — gen_hex now falls back to /dev/urandom; only warn-and-skip
      # when both paths fail.
      mcp_random=$(gen_hex 24)
      if [ -n "$mcp_random" ]; then
        replace_env_value "BEEVER_MCP_API_KEYS" "mcp-${mcp_random}"
        ok "BEEVER_MCP_ENABLED=true · auto-generated BEEVER_MCP_API_KEYS"
        opt_parts+=("MCP ✓")
      else
        warn "BEEVER_MCP_ENABLED=true but cannot auto-generate (no Python and no /dev/urandom) — set BEEVER_MCP_API_KEYS manually"
        opt_parts+=("MCP partial")
      fi
    else
      ok "skipped MCP server"
      opt_parts+=("MCP skipped")
    fi
    _atlas_summary+=("${C_GREEN}✓${C_RESET} Optional    ${opt_parts[*]}")
  else
    ok "skipped optional integrations"
    _atlas_summary+=("${C_DIM}—${C_RESET} Optional    skipped (Tavily / MCP)")
  fi
fi

# ---------------------------------------------------------------------
# Step 5 · Auth tokens (dev defaults vs rotate)
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" != "true" ]; then
  step "5/5" "Auth tokens"
  hint "BEEVER_API_KEYS, BEEVER_ADMIN_TOKEN, BRIDGE_API_KEY."
  hint "Dev defaults (e.g. 'dev-key-change-me') work for local testing."
  hint "Rotate them to random secrets for any deploy."

  if confirm "N" "Rotate auth tokens to fresh random secrets?"; then
    # Issue #41 — gen_hex now falls back to /dev/urandom; only skip
    # rotation when BOTH Python and /dev/urandom are unavailable.
    new_api=$(gen_hex 24)
    new_admin=$(gen_hex 24)
    new_bridge=$(gen_hex 24)
    if [ -z "$new_api" ] || [ -z "$new_admin" ] || [ -z "$new_bridge" ]; then
      warn "Cannot auto-generate (no Python and no /dev/urandom) — skipping rotation"
      _atlas_summary+=("${C_YELLOW}!${C_RESET} Auth        rotation FAILED (no Python and no /dev/urandom)")
    else
      replace_env_value "BEEVER_API_KEYS"        "$new_api"
      replace_env_value "BEEVER_ADMIN_TOKEN"     "$new_admin"
      replace_env_value "BRIDGE_API_KEY"         "$new_bridge"
      replace_env_value "VITE_BEEVER_API_KEY"    "$new_api"
      replace_env_value "VITE_BEEVER_ADMIN_TOKEN" "$new_admin"
      ok "Rotated BEEVER_API_KEYS"
      ok "Rotated BEEVER_ADMIN_TOKEN"
      ok "Rotated BRIDGE_API_KEY"
      ok "Mirrored VITE_* tokens to match"
      _atlas_summary+=("${C_GREEN}✓${C_RESET} Auth        rotated to fresh random secrets")
    fi
  else
    ok "kept dev-default auth tokens (fine for local)"
    _atlas_summary+=("${C_DIM}—${C_RESET} Auth        kept dev defaults (rotate before any deploy)")
  fi
fi

# ---------------------------------------------------------------------
# Non-interactive env pre-seeding: apply any keys set in the calling shell.
# pydantic-settings / docker compose read .env, NOT the process env, so we
# must write values to .env — not just export them. This runs before
# "Finalizing secrets" so auto-gen logic sees the correct filled state.
# ---------------------------------------------------------------------
if [ "$NON_INTERACTIVE" = "true" ]; then
  for _preseed_key in GOOGLE_API_KEY JINA_API_KEY TAVILY_API_KEY \
                       EMBEDDING_PROVIDER EMBEDDING_MODEL EMBEDDING_DIMENSIONS \
                       EMBEDDING_API_KEY OPENAI_API_KEY COHERE_API_KEY \
                       VOYAGE_API_KEY MISTRAL_API_KEY \
                       ANTHROPIC_API_KEY DEEPSEEK_API_KEY GROQ_API_KEY \
                       XAI_API_KEY TOGETHER_API_KEY MINIMAX_API_KEY \
                       LLM_FAST_MODEL LLM_QUALITY_MODEL OLLAMA_ENABLED OLLAMA_API_BASE \
                       BEEVER_ENDPOINTS BEEVER_PRESET BEEVER_LLM_API_KEY; do
    _preseed_val="${!_preseed_key:-}" 2>/dev/null || _preseed_val=""
    if [ -n "$_preseed_val" ]; then
      replace_env_value "$_preseed_key" "$_preseed_val"
      ok "${_preseed_key} pre-seeded from shell environment (saved)"
    fi
  done
fi

# ---------------------------------------------------------------------
# Auto-generated secrets (always, if placeholder/blank)
# ---------------------------------------------------------------------
section "Finalizing secrets"
secrets_changed=false
MASTER_KEY_PLACEHOLDER="00000000000000000000000000000000000000000000000000000000deadbeef"

if grep -qF "CREDENTIAL_MASTER_KEY=${MASTER_KEY_PLACEHOLDER}" .env; then
  # Issue #41 — `gen_hex` now falls back to /dev/urandom if Python is
  # missing. Exit fail-loud if BOTH paths fail rather than ship the
  # public placeholder, which would leave AES-256-GCM encryption
  # effectively plaintext.
  new_master_key="$(gen_hex 32)"
  if [ -z "$new_master_key" ]; then
    error "Cannot generate CREDENTIAL_MASTER_KEY — neither Python nor /dev/urandom available."
    error "Set it manually in .env (64 hex chars) and re-run atlas."
    exit 1
  fi
  replace_env_value "CREDENTIAL_MASTER_KEY" "$new_master_key"
  ok "Generated a fresh CREDENTIAL_MASTER_KEY"
  secrets_changed=true
fi

if grep -qE "^WEAVIATE_API_KEY=$" .env; then
  new_weaviate_key="$(gen_hex 16)"
  if [ -z "$new_weaviate_key" ]; then
    error "Cannot generate WEAVIATE_API_KEY — neither Python nor /dev/urandom available."
    exit 1
  fi
  replace_env_value "WEAVIATE_API_KEY" "$new_weaviate_key"
  ok "Generated a fresh WEAVIATE_API_KEY"
  secrets_changed=true
fi

# LOADER_TOKEN_SECRET signs the short-lived ?loader_token= URLs the web UI
# uses for <img> / <a href> file-proxy access. Empty is supported in dev
# (the server falls back to raw ?access_token= matching), but the fallback
# path emits 503s on the mint endpoint that the frontend retries against
# every image load — noisy in logs. Generate a value so the signed-token
# path activates cleanly. Production REQUIRES it (server fails fast).
if grep -qE "^LOADER_TOKEN_SECRET=$" .env; then
  new_loader_secret="$(gen_hex 32)"
  if [ -z "$new_loader_secret" ]; then
    error "Cannot generate LOADER_TOKEN_SECRET — neither Python nor /dev/urandom available."
    exit 1
  fi
  replace_env_value "LOADER_TOKEN_SECRET" "$new_loader_secret"
  ok "Generated a fresh LOADER_TOKEN_SECRET"
  secrets_changed=true
fi

if ! $secrets_changed; then
  ok "Master + Weaviate + Loader-token secrets already set — nothing to regenerate"
fi

# ---------------------------------------------------------------------
# Configuration summary — recap of every step's outcome so the operator
# can verify .env one final time before docker compose builds + boots.
# Skipped silently when the array is empty (e.g. --non-interactive).
# ---------------------------------------------------------------------
if [ "${#_atlas_summary[@]}" -gt 0 ]; then
  printf "\n  ${C_BOLD}${C_CYAN}─────── Configuration summary ─────────────────────────────────${C_RESET}\n\n"
  for _atlas_line in "${_atlas_summary[@]}"; do
    printf "    %s\n" "$_atlas_line"
  done
  printf "\n"
fi

# ---------------------------------------------------------------------
# Launch
# ---------------------------------------------------------------------
section "Launch"
# --build:           rebuild the backend image if source or Dockerfile changed.
# --force-recreate:  ensure containers pick up any .env changes this run.
# --remove-orphans:  prune containers from old/removed services (e.g. retired
#                    Nebula profile) so users don't see noisy orphan warnings.
# All three flags are idempotent: no cost on a fresh clone.
printf "    ${C_DIM}Building images + starting services…${C_RESET}\n"
if $COMPOSE_CMD up -d --build --force-recreate --remove-orphans; then
  ok "Services are running"

  # ------------------------------------------------------------------
  # Resolve ACTUAL host ports from compose. `docker compose port <svc>
  # <container-port>` returns lines like `0.0.0.0:3000` or `[::]:3000`.
  # Take the last colon-separated field to get the host port. Fall back
  # to documented defaults if the lookup returns nothing (test stubs,
  # custom override files, etc.).
  # ------------------------------------------------------------------
  web_port=$($COMPOSE_CMD port web 80 2>/dev/null | awk -F: '{print $NF}' | head -n1)
  backend_port=$($COMPOSE_CMD port beever-atlas 8000 2>/dev/null | awk -F: '{print $NF}' | head -n1)
  web_port="${web_port:-3000}"
  backend_port="${backend_port:-8000}"

  # ------------------------------------------------------------------
  # Health poll: wait up to ATLAS_HEALTH_POLL_TIMEOUT seconds (default 15)
  # for the backend to serve HTTP 200 on /api/health. Skipped when curl
  # isn't available or when the timeout is set to 0.
  # The poll is advisory — a slow backend MUST NOT fail the installer.
  # ------------------------------------------------------------------
  _poll_timeout="${ATLAS_HEALTH_POLL_TIMEOUT:-15}"
  if [ "$_poll_timeout" -gt 0 ] 2>/dev/null && command -v curl >/dev/null 2>&1; then
    _poll_elapsed=0
    _poll_ok=false
    while [ "$_poll_elapsed" -lt "$_poll_timeout" ]; do
      if curl -sf --max-time 1 "http://localhost:${backend_port}/api/health" >/dev/null 2>&1; then
        _poll_ok=true
        break
      fi
      sleep 1
      _poll_elapsed=$((_poll_elapsed + 1))
    done
    if $_poll_ok; then
      ok "backend responding on :${backend_port}"
    else
      warn "backend not yet responding — it may still be starting; check \`${COMPOSE_CMD} logs -f beever-atlas\`"
    fi
  fi

  printf "\n"
  printf "  ${C_GREEN}═════════════════════════════════ ${C_RESET}${C_BOLD}${C_GREEN}✓ Beever Atlas is ready${C_RESET} ${C_GREEN}════════════════════════════════════${C_RESET}\n"
  printf "\n"
  printf "    ${C_BOLD}Web UI${C_RESET}      ${C_CYAN}http://localhost:${web_port}${C_RESET}\n"
  printf "    ${C_BOLD}Backend${C_RESET}     ${C_CYAN}http://localhost:${backend_port}${C_RESET}\n"
  printf "\n"
  printf "    ${C_DIM}Tail logs   ${COMPOSE_CMD} logs -f${C_RESET}\n"
  printf "    ${C_DIM}Stop stack  ${COMPOSE_CMD} down${C_RESET}\n"
  printf "    ${C_DIM}Edit env    \$EDITOR .env${C_RESET}\n"
  printf "\n"
  printf "  ${C_GREEN}═══════════════════════════════════════════════════════════════════════════════════════════════${C_RESET}\n"
  printf "\n"
else
  status=$?
  printf "\n"
  err "Services failed to start"
  hinterr "Fix the error above and re-run: ./atlas  (or: ${COMPOSE_CMD} up -d --build --force-recreate --remove-orphans)"
  exit "$status"
fi
