#!/bin/sh
# VAC installer — https://get.vac.vojir.io
#
#   curl -sSL get.vac.vojir.io | sudo sh
#
# Run it in a terminal and it walks you through a short setup (domain, managed
# services, sudo-free access) and shows a summary before touching the host. Pipe
# it without a terminal (CI, provisioning) and it runs unattended with safe
# defaults. Re-running upgrades the images and preserves your secrets/config.
#
# Override any default — and skip the matching question — with env vars:
#   VAC_VERSION=v0.5.0 VAC_DOMAIN=vac.example.com sh install.sh
#
# Flags:
#   --yes, -y         Accept defaults; never prompt (even in a terminal).
#   --no-grant        Keep VAC root-only (don't grant the sudo user access).
#   --grant           Force the grant on (the default).
#   -h, --help        Show help and exit.
set -eu

# ─────────────────────────────────────────────────────────────────────────────
# Config (overridable via env)
# ─────────────────────────────────────────────────────────────────────────────

# Which knobs did the caller pre-set via env? A pre-set knob skips its wizard
# question — this is what keeps `curl | sh` fully unattended for automation.
# Captured before defaults are applied (defaults would mask the distinction).
[ -n "${VAC_DOMAIN+x}" ]           && DOMAIN_PRESET=1   || DOMAIN_PRESET=0
[ -n "${VAC_MANAGED_SERVICES+x}" ] && MANAGED_PRESET=1  || MANAGED_PRESET=0
[ -n "${VAC_ENABLE_SHELL+x}" ]     && SHELL_PRESET=1    || SHELL_PRESET=0
[ -n "${VAC_SECURITY_AGENT+x}" ]   && SECAGENT_PRESET=1 || SECAGENT_PRESET=0
[ -n "${VAC_IDLE_SUSPEND+x}" ]     && IDLE_PRESET=1    || IDLE_PRESET=0
[ -n "${VAC_GRANT_ACCESS+x}" ]     && GRANT_PRESET=1    || GRANT_PRESET=0

VAC_VERSION="${VAC_VERSION:-latest}"
VAC_INSTALL_DIR="${VAC_INSTALL_DIR:-/opt/vac}"
VAC_REGISTRY="${VAC_REGISTRY:-ghcr.io/vojir-mikulas}"
VAC_ASSET_BASE="${VAC_ASSET_BASE:-https://get.vac.vojir.io}"
VAC_HOST_PORT="${VAC_HOST_PORT:-9393}"
VAC_DOMAIN="${VAC_DOMAIN:-}"
# Managed services (backups, managed databases, the add-on catalog). Off by
# default — it starts background workers and uses a little more RAM. Toggle any
# time with `vac managed-services on|off`.
VAC_MANAGED_SERVICES="${VAC_MANAGED_SERVICES:-}"
# Interactive container shell (P3.4). Off by default — when on, the dashboard can
# open a root-capable shell into a user app container (confirm-gated + audited).
# Highest blast-radius feature, so it's opt-in: `vac container-shell on|off`.
VAC_ENABLE_SHELL="${VAC_ENABLE_SHELL:-}"
# Host security agent (read-only fail2ban/firewall collector). Off by default —
# it installs a host-level systemd timer/cron outside the compose stack, so it's
# opt-in. Toggle any time with `vac security-agent on|off`.
VAC_SECURITY_AGENT="${VAC_SECURITY_AGENT:-}"
# Scale-to-zero / idle-suspend. Off by default — when on, apps that opt in and go
# idle are stopped and woken on the next request, saving RAM. Toggle any time with
# `vac idle-suspend on|off`.
VAC_IDLE_SUSPEND="${VAC_IDLE_SUSPEND:-}"
# When installed via sudo, also let the invoking user run `vac` without sudo:
# add them to the `docker` group and give them the install dir (which holds the
# root-only .env). Opt out with --no-grant or VAC_GRANT_ACCESS=0.
# Note: docker-group membership is root-equivalent on this host.
VAC_GRANT_ACCESS="${VAC_GRANT_ACCESS:-1}"

ASSUME_YES=0          # --yes / -y: never prompt
INTERACTIVE=0         # resolved at wizard time (a readable /dev/tty + not --yes)
FRESH=0               # 1 on a first-time install (no existing .env)
RELOGIN=0             # 1 when the user must re-login for docker-group membership
IP=""                 # detected public/host IP, filled lazily by detect_ip()

COMPOSE_FILE="$VAC_INSTALL_DIR/compose.prod.yaml"
ENV_FILE="$VAC_INSTALL_DIR/.env"

# ─────────────────────────────────────────────────────────────────────────────
# Output & prompt helpers
# ─────────────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
  B="$(printf '\033[1m')"; D="$(printf '\033[2m')"; N="$(printf '\033[0m')"
  R="$(printf '\033[31m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"; C="$(printf '\033[36m')"
else B=; D=; N=; R=; G=; Y=; C=; fi
info() { printf '%s==>%s %s\n' "$G" "$N" "$1"; }
warn() { printf '%s!  %s%s\n' "$Y" "$1" "$N"; }
die()  { printf '%serror:%s %s\n' "$R" "$N" "$1" >&2; exit 1; }

# Prompts read from /dev/tty, never stdin — under `curl | sh`, stdin is the
# pipe carrying this script, so only the controlling terminal can answer.
say() { printf '%s\n' "$1" > /dev/tty; }            # a line of wizard prose

ask() {
  # ask <question> [default] -> echoes the answer (default if blank)
  _def="${2:-}"
  if [ -n "$_def" ]; then printf '%s [%s] ' "$1" "$_def" > /dev/tty
  else printf '%s ' "$1" > /dev/tty; fi
  IFS= read -r _ans < /dev/tty || _ans=
  [ -n "$_ans" ] || _ans="$_def"
  printf '%s' "$_ans"
}

confirm() {
  # confirm <question> <default:y|n> -> 0 for yes, 1 for no
  _hint='[y/N]'; [ "${2:-n}" = y ] && _hint='[Y/n]'
  printf '%s %s ' "$1" "$_hint" > /dev/tty
  IFS= read -r _ans < /dev/tty || _ans=
  [ -n "$_ans" ] || _ans="${2:-n}"
  case "$_ans" in [Yy]*) return 0 ;; *) return 1 ;; esac
}

normalize_bool() {
  # Map a loose truthy/falsy string to the literal true|false the API expects.
  case "$1" in true|TRUE|1|yes|YES|on|y|Y) printf 'true' ;; *) printf 'false' ;; esac
}

usage() {
  cat <<USAGE
VAC installer

  curl -sSL get.vac.vojir.io | sudo sh
  curl -sSL get.vac.vojir.io/install.sh | sudo sh -s -- [flags]

Flags:
  --yes, -y    Accept defaults and never prompt (even in a terminal).
  --no-grant   Don't add the invoking user to the docker group or chown the
               install dir — leave VAC root-only.
  --grant      Force the grant on (this is the default).
  -h, --help   Show this help and exit.

Env overrides (a pre-set value skips its question): VAC_VERSION, VAC_DOMAIN,
VAC_MANAGED_SERVICES (true/false), VAC_ENABLE_SHELL (true/false),
VAC_IDLE_SUSPEND (true/false), VAC_INSTALL_DIR, VAC_HOST_PORT, VAC_REGISTRY,
VAC_GRANT_ACCESS (1/0).
USAGE
}

show_banner() {
  # Retro VAC wordmark shown once at the top of the run. Printed only when
  # stdout is a terminal — under `curl | sh` that's true, but a redirect to a
  # file or CI log is not, so machine output stays clean.
  [ -t 1 ] || return 0
  # Wipe the screen only for a fresh interactive run where a human is watching;
  # CI / --yes / piped runs keep their scrollback. Remove this `if` block to
  # never clear the screen.
  if [ "$ASSUME_YES" != 1 ] && [ -r /dev/tty ]; then
    clear 2>/dev/null || printf '\033[H\033[2J'
  fi
  printf '%s' "$C"
  cat <<'ART'
    ██╗   ██╗ █████╗  ██████╗
    ██║   ██║██╔══██╗██╔════╝
    ██║   ██║███████║██║
    ╚██╗ ██╔╝██╔══██║██║
     ╚████╔╝ ██║  ██║╚██████╗
      ╚═══╝  ╚═╝  ╚═╝ ╚═════╝
ART
  printf '%s' "$N"
  printf '    %sself-hosted PaaS for a single box%s  ·  %s%s%s\n\n' \
    "$D" "$N" "$B" "$VAC_VERSION" "$N"
}

# ─────────────────────────────────────────────────────────────────────────────
# Small utilities
# ─────────────────────────────────────────────────────────────────────────────
fetch() {
  # fetch <url> <dest>
  if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
  elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
  else die "Neither curl nor wget is available."; fi
}

rand_hex() {
  # rand_hex <bytes>
  if command -v openssl >/dev/null 2>&1; then openssl rand -hex "$1"
  else od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n'; fi
}

detect_ip() {
  # Cache the best-guess public IP for the dashboard URL / DNS hints.
  [ -n "$IP" ] && { printf '%s' "$IP"; return; }
  IP="$(curl -fsS https://api.ipify.org 2>/dev/null || true)"
  [ -n "$IP" ] || IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
  [ -n "$IP" ] || IP="<server-ip>"
  printf '%s' "$IP"
}

# ─────────────────────────────────────────────────────────────────────────────
# Pre-flight & privilege
# ─────────────────────────────────────────────────────────────────────────────
handle_help() {
  # Answer --help before any preflight/elevation so it never needs Linux or
  # root. Peek without consuming "$@" so args still forward through elevation.
  for _a in "$@"; do
    case "$_a" in -h|--help) usage; exit 0 ;; esac
  done
}

preflight() {
  [ "$(uname -s)" = "Linux" ] || die "VAC installs on Linux hosts only (found $(uname -s))."
  case "$(uname -m)" in
    x86_64|amd64|aarch64|arm64) ;;
    *) die "Unsupported architecture: $(uname -m). VAC ships amd64 and arm64." ;;
  esac
}

elevate_if_needed() {
  # Re-run under sudo if we're not root. Forward "$@" so flags survive the
  # elevation: with `sh -c BODY name arg…`, name becomes $0 and the rest $1….
  [ "$(id -u)" -ne 0 ] || return 0
  command -v sudo >/dev/null 2>&1 || die "Please run as root (or install sudo)."
  info "Re-running with sudo…"
  exec sudo -E sh -c "$(cat "$0" 2>/dev/null || true)" sh "$@" 2>/dev/null \
    || die "Please run as root: curl -sSL get.vac.vojir.io | sudo sh"
}

parse_args() {
  # Parsed after self-elevation (which forwards "$@") so flags reliably cross
  # the sudo boundary. Flags win over the env defaults set above.
  while [ $# -gt 0 ]; do
    case "$1" in
      --yes|-y)   ASSUME_YES=1 ;;
      --no-grant) VAC_GRANT_ACCESS=0; GRANT_PRESET=1 ;;
      --grant)    VAC_GRANT_ACCESS=1; GRANT_PRESET=1 ;;
      -h|--help)  usage; exit 0 ;;
      *) die "unknown option: $1 (try --help)" ;;
    esac
    shift
  done
}

detect_fresh() {
  if [ -f "$ENV_FILE" ]; then FRESH=0; else FRESH=1; fi
}

# ─────────────────────────────────────────────────────────────────────────────
# Guided setup
# ─────────────────────────────────────────────────────────────────────────────
run_wizard() {
  # Upgrades keep their config untouched — nothing to ask.
  if [ "$FRESH" != 1 ]; then
    info "Existing install found at ${B}${VAC_INSTALL_DIR}${N} — keeping secrets, upgrading images."
    return 0
  fi

  # Interactive only when there's a terminal to answer and the user didn't opt
  # out with --yes. Otherwise: announce the defaults and proceed unattended.
  if [ "$ASSUME_YES" != 1 ] && [ -r /dev/tty ]; then INTERACTIVE=1; fi
  if [ "$INTERACTIVE" != 1 ]; then
    VAC_MANAGED_SERVICES="$(normalize_bool "${VAC_MANAGED_SERVICES:-false}")"
    VAC_ENABLE_SHELL="$(normalize_bool "${VAC_ENABLE_SHELL:-false}")"
    VAC_SECURITY_AGENT="$(normalize_bool "${VAC_SECURITY_AGENT:-false}")"
    VAC_IDLE_SUSPEND="$(normalize_bool "${VAC_IDLE_SUSPEND:-false}")"
    info "Running non-interactively — using defaults (domain: ${VAC_DOMAIN:-none}, managed services: ${VAC_MANAGED_SERVICES}, container shell: ${VAC_ENABLE_SHELL}, security agent: ${VAC_SECURITY_AGENT}, idle suspend: ${VAC_IDLE_SUSPEND})."
    return 0
  fi

  wizard_welcome
  wizard_system_summary
  wizard_ask_domain
  wizard_ask_managed_services
  wizard_ask_container_shell
  wizard_ask_security_agent
  wizard_ask_idle_suspend
  wizard_ask_grant
  wizard_confirm
}

wizard_welcome() {
  say ""
  say "${B}${C}Welcome to VAC${N} — a self-hosted PaaS for a single box."
  say "This will:"
  say "  • install Docker if it's missing"
  say "  • lay down the stack in ${B}${VAC_INSTALL_DIR}${N} and start it"
  say "  • install the ${B}vac${N} management command"
  say ""
  say "Nothing changes on this host until you confirm. Ctrl-C any time to abort."
}

wizard_system_summary() {
  if command -v docker >/dev/null 2>&1; then _dk="installed"; else _dk="${Y}will be installed${N}"; fi
  say ""
  say "${B}${C}System${N}"
  say "  host:    $(uname -s) $(uname -m)"
  say "  docker:  ${_dk}"
  say "  install: ${VAC_INSTALL_DIR}"
  say "  address: $(detect_ip)"
}

wizard_ask_domain() {
  [ "$DOMAIN_PRESET" = 1 ] && return 0
  say ""
  say "${B}${C}Domain${N}  (optional — you can also set this later with 'vac set-domain')"
  say "  Give VAC a domain to serve the dashboard over HTTPS and enable automatic"
  say "  per-app subdomains. Leave blank to reach it by IP for now."
  VAC_DOMAIN="$(ask 'Domain (blank = use IP):' '')"
  if [ -n "$VAC_DOMAIN" ]; then
    say "  DNS to create later:  A vac.${VAC_DOMAIN} → $(detect_ip)   and   A *.${VAC_DOMAIN} → $(detect_ip)"
  fi
}

wizard_ask_managed_services() {
  if [ "$MANAGED_PRESET" = 1 ]; then
    VAC_MANAGED_SERVICES="$(normalize_bool "$VAC_MANAGED_SERVICES")"
    return 0
  fi
  say ""
  say "${B}${C}Managed services${N}  (automatic backups, managed databases, add-on catalog)"
  say "  Off by default — it starts background workers and uses a little more RAM."
  say "  You can turn it on or off any time with: ${B}vac managed-services on|off${N}"
  if confirm 'Enable managed services now?' n; then
    VAC_MANAGED_SERVICES=true
  else
    VAC_MANAGED_SERVICES=false
  fi
}

wizard_ask_container_shell() {
  if [ "$SHELL_PRESET" = 1 ]; then
    VAC_ENABLE_SHELL="$(normalize_bool "$VAC_ENABLE_SHELL")"
    return 0
  fi
  say ""
  say "${B}${C}Container shell${N}  (open a terminal into an app's container from the dashboard)"
  say "  Off by default. When on, a ${B}Shell${N} action on each running service opens a"
  say "  ${B}root-capable${N} shell inside that container — handy for debugging, but a powerful"
  say "  one: it's confirm-gated and every session is recorded in the audit log."
  say "  You can turn it on or off any time with: ${B}vac container-shell on|off${N}"
  if confirm 'Enable the container shell now?' n; then
    VAC_ENABLE_SHELL=true
  else
    VAC_ENABLE_SHELL=false
  fi
}

wizard_ask_security_agent() {
  if [ "$SECAGENT_PRESET" = 1 ]; then
    VAC_SECURITY_AGENT="$(normalize_bool "$VAC_SECURITY_AGENT")"
    return 0
  fi
  say ""
  say "${B}${C}Security monitoring${N}  (read-only fail2ban + firewall status on the Security tab)"
  say "  VAC's control plane is sandboxed and can't read host firewall/fail2ban"
  say "  state on its own. Opting in installs a tiny ${B}read-only${N} host collector"
  say "  (a systemd timer that runs 'ufw status' / 'fail2ban-client status' every"
  say "  ~60s) so the dashboard can show your firewall/ban status and warn if no"
  say "  firewall is active. It never changes host state."
  say "  You can turn it on or off any time with: ${B}vac security-agent on|off${N}"
  if confirm 'Enable host security monitoring now?' n; then
    VAC_SECURITY_AGENT=true
  else
    VAC_SECURITY_AGENT=false
  fi
}

wizard_ask_idle_suspend() {
  if [ "$IDLE_PRESET" = 1 ]; then
    VAC_IDLE_SUSPEND="$(normalize_bool "$VAC_IDLE_SUSPEND")"
    return 0
  fi
  say ""
  say "${B}${C}Scale to zero${N}  (stop idle apps, wake them on the next request)"
  say "  Off by default. When on, an app you opt in from the dashboard is stopped"
  say "  after a period with no traffic and started again automatically when the"
  say "  next request arrives — saving RAM on apps that sit idle."
  say "  You can turn it on or off any time with: ${B}vac idle-suspend on|off${N}"
  if confirm 'Enable scale to zero now?' n; then
    VAC_IDLE_SUSPEND=true
  else
    VAC_IDLE_SUSPEND=false
  fi
}

wizard_ask_grant() {
  # Only meaningful when invoked via sudo from a real user account.
  [ "$GRANT_PRESET" = 1 ] && return 0
  _u="${SUDO_USER:-}"
  { [ -n "$_u" ] && [ "$_u" != root ]; } || return 0
  say ""
  say "${B}${C}Access${N}"
  say "  Let ${B}${_u}${N} run 'vac' without sudo? This adds them to the 'docker'"
  say "  group (root-equivalent on this host) and hands them the install dir."
  if confirm "Grant ${_u} sudo-free access?" y; then
    VAC_GRANT_ACCESS=1
  else
    VAC_GRANT_ACCESS=0
  fi
}

wizard_confirm() {
  [ "$VAC_GRANT_ACCESS" = 1 ] && _grant="yes" || _grant="no"
  say ""
  say "${B}${C}Ready to install${N}"
  say "  version:           ${VAC_VERSION}"
  say "  install dir:       ${VAC_INSTALL_DIR}"
  say "  domain:            ${VAC_DOMAIN:-none (reach by IP)}"
  say "  managed services:  ${VAC_MANAGED_SERVICES}"
  say "  container shell:    ${VAC_ENABLE_SHELL}"
  say "  security agent:    ${VAC_SECURITY_AGENT}"
  say "  scale to zero:     ${VAC_IDLE_SUSPEND}"
  say "  sudo-free access:  ${_grant}"
  say ""
  confirm 'Proceed?' y || die "Aborted — nothing was changed."
}

# ─────────────────────────────────────────────────────────────────────────────
# Docker
# ─────────────────────────────────────────────────────────────────────────────
ensure_docker() {
  if ! command -v docker >/dev/null 2>&1; then
    info "Docker not found — installing via get.docker.com…"
    command -v curl >/dev/null 2>&1 || die "curl is required to install Docker."
    curl -fsSL https://get.docker.com | sh
  fi

  # Ensure the daemon is up (systemd or sysvinit).
  if command -v systemctl >/dev/null 2>&1; then
    systemctl enable --now docker >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker start >/dev/null 2>&1 || true
  fi

  docker info >/dev/null 2>&1 || die "Docker is installed but the daemon isn't reachable."
  docker compose version >/dev/null 2>&1 || die "The Docker Compose v2 plugin is required (docker compose)."
}

# ─────────────────────────────────────────────────────────────────────────────
# Install directory & assets
# ─────────────────────────────────────────────────────────────────────────────
lay_down_files() {
  info "Installing into ${B}${VAC_INSTALL_DIR}${N}"
  mkdir -p "$VAC_INSTALL_DIR"

  info "Fetching compose.prod.yaml…"
  fetch "$VAC_ASSET_BASE/compose.prod.yaml" "$COMPOSE_FILE"

  # Stash uninstall.sh next to the compose file so `vac uninstall` works offline
  # and is where an operator would look. Non-fatal — the wrapper also fetches it.
  info "Fetching uninstall.sh…"
  if fetch "$VAC_ASSET_BASE/uninstall.sh" "$VAC_INSTALL_DIR/uninstall.sh"; then
    chmod +x "$VAC_INSTALL_DIR/uninstall.sh"
  else
    warn "Could not fetch uninstall.sh; 'vac uninstall' will fall back to the network."
  fi

  # Same idea for migrate.sh — stash it next to the compose file so 'vac migrate'
  # works offline. Non-fatal: the wrapper fetches it on demand if it's missing.
  info "Fetching migrate.sh…"
  if fetch "$VAC_ASSET_BASE/migrate.sh" "$VAC_INSTALL_DIR/migrate.sh"; then
    chmod +x "$VAC_INSTALL_DIR/migrate.sh"
  else
    warn "Could not fetch migrate.sh; 'vac migrate' will fall back to the network."
  fi
}

generate_env() {
  # Only on first install — secrets are preserved across re-runs.
  if [ "$FRESH" != 1 ]; then
    info "Existing config found — keeping secrets."
  else
    info "Generating secrets…"
    MASTER_KEY="$(rand_hex 32)"
    DB_PASSWORD="$(rand_hex 24)"          # hex → URL-safe inside the Postgres DSN

    DOCKER_GID="$(getent group docker 2>/dev/null | cut -d: -f3)"
    [ -n "${DOCKER_GID:-}" ] || DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo 999)"

    umask 077
    cat > "$ENV_FILE" <<EOF
# Generated by the VAC installer on $(date -u +%Y-%m-%dT%H:%M:%SZ). Keep this safe.
VAC_VERSION=$VAC_VERSION
VAC_REGISTRY=$VAC_REGISTRY
VAC_MASTER_KEY=$MASTER_KEY
VAC_DB_PASSWORD=$DB_PASSWORD
DOCKER_GID=$DOCKER_GID
VAC_HOST_PORT=$VAC_HOST_PORT
VAC_BASE_DOMAIN=$VAC_DOMAIN
VAC_MANAGED_SERVICES=$(normalize_bool "${VAC_MANAGED_SERVICES:-false}")
VAC_ENABLE_SHELL=$(normalize_bool "${VAC_ENABLE_SHELL:-false}")
VAC_SECURITY_AGENT=$(normalize_bool "${VAC_SECURITY_AGENT:-false}")
VAC_IDLE_SUSPEND=$(normalize_bool "${VAC_IDLE_SUSPEND:-false}")
EOF
    chmod 600 "$ENV_FILE"
  fi

  # Pin the requested version for this run even on upgrades.
  if grep -q '^VAC_VERSION=' "$ENV_FILE"; then
    sed -i "s|^VAC_VERSION=.*|VAC_VERSION=$VAC_VERSION|" "$ENV_FILE"
  else
    printf 'VAC_VERSION=%s\n' "$VAC_VERSION" >> "$ENV_FILE"
  fi
}

start_stack() {
  info "Pulling images (${VAC_REGISTRY}/vac-* : ${VAC_VERSION})…"
  docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" pull

  info "Starting VAC…"
  docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d
}

# ─────────────────────────────────────────────────────────────────────────────
# The `vac` management CLI
# ─────────────────────────────────────────────────────────────────────────────
install_cli() {
  info "Installing the 'vac' command…"
  write_vac_cli
}

write_vac_cli() {
  # Embedded management CLI → /usr/local/bin/vac. __VAC_DIR__ and
  # __VAC_ASSET_BASE__ are substituted after the (unexpanded) heredoc is
  # written, so the script body itself stays free of shell expansion.
  cat > /usr/local/bin/vac <<'VACEOF'
#!/bin/sh
set -eu
DIR="__VAC_DIR__"
ASSET_BASE="__VAC_ASSET_BASE__"
COMPOSE="$DIR/compose.prod.yaml"
ENVF="$DIR/.env"
dc() { docker compose -f "$COMPOSE" --env-file "$ENVF" "$@"; }
set_env() {
  if grep -q "^$1=" "$ENVF" 2>/dev/null; then sed -i "s|^$1=.*|$1=$2|" "$ENVF"
  else printf '%s=%s\n' "$1" "$2" >> "$ENVF"; fi
}
cmd="${1:-help}"; [ $# -gt 0 ] && shift || true
case "$cmd" in
  up)        dc up -d "$@" ;;
  down)      dc down "$@" ;;
  restart)   dc restart "$@" ;;
  status|ps) dc ps "$@" ;;
  logs)      dc logs -f --tail=100 "$@" ;;
  pull)      dc pull "$@" ;;
  upgrade)   [ $# -gt 0 ] && set_env VAC_VERSION "$1" || true; dc pull && dc up -d ;;
  set-domain)
    [ $# -ge 1 ] || { echo "usage: vac set-domain <domain>" >&2; exit 1; }
    set_env VAC_BASE_DOMAIN "$1"; dc up -d vac-api
    printf 'Base domain set to %s.\n' "$1"
    printf 'Dashboard will be reachable at:  https://vac.%s\n' "$1"
    printf 'DNS records to create:\n'
    printf '  A   vac.%s   → this host\n' "$1"
    printf '  A   *.%s     → this host   (for deployed apps)\n' "$1"
    printf 'TLS certificates are issued automatically by Let'"'"'s Encrypt once DNS points here.\n' ;;
  unset-domain) set_env VAC_BASE_DOMAIN ""; dc up -d vac-api; echo "Automatic subdomains disabled." ;;
  managed-services)
    case "${1:-}" in
      on|true|1)
        set_env VAC_MANAGED_SERVICES true; dc up -d vac-api
        echo "Managed services enabled (backups, databases, add-ons)." ;;
      off|false|0|"")
        set_env VAC_MANAGED_SERVICES false; dc up -d vac-api
        echo "Managed services disabled." ;;
      *) echo "usage: vac managed-services on|off" >&2; exit 1 ;;
    esac ;;
  container-shell)
    case "${1:-}" in
      on|true|1)
        set_env VAC_ENABLE_SHELL true; dc up -d vac-api
        echo "Container shell enabled — a 'Shell' action appears on running services."
        echo "It opens a root-capable shell into the container (confirm-gated + audited)." ;;
      off|false|0|"")
        set_env VAC_ENABLE_SHELL false; dc up -d vac-api
        echo "Container shell disabled." ;;
      *) echo "usage: vac container-shell on|off" >&2; exit 1 ;;
    esac ;;
  idle-suspend)
    case "${1:-}" in
      on|true|1)
        set_env VAC_IDLE_SUSPEND true; dc up -d vac-api
        echo "Scale to zero enabled — opt apps in from the dashboard (Settings → Idle suspend)."
        echo "Opted-in apps stop when idle and wake automatically on the next request." ;;
      off|false|0|"")
        set_env VAC_IDLE_SUSPEND false; dc up -d vac-api
        echo "Scale to zero disabled — apps are no longer auto-suspended." ;;
      *) echo "usage: vac idle-suspend on|off" >&2; exit 1 ;;
    esac ;;
  security-check)
    # Opt in/out of the firewall / fail2ban posture warnings. A box with no
    # firewall is dangerous, so both warn by default; turn one off if you
    # deliberately don't run it and don't want the Security tab to flag it.
    _what="${1:-}"; _state="${2:-}"
    case "$_what" in
      firewall) _var=VAC_SECURITY_EXPECT_FIREWALL ;;
      fail2ban) _var=VAC_SECURITY_EXPECT_FAIL2BAN ;;
      *) echo "usage: vac security-check firewall|fail2ban on|off" >&2; exit 1 ;;
    esac
    case "$_state" in
      on|true|1)  set_env "$_var" true;  dc up -d vac-api; echo "$_what posture warnings enabled." ;;
      off|false|0) set_env "$_var" false; dc up -d vac-api; echo "$_what posture warnings disabled." ;;
      *) echo "usage: vac security-check firewall|fail2ban on|off" >&2; exit 1 ;;
    esac ;;
  security-agent)
    # Install/remove the read-only host security collector. It runs ON THE HOST
    # (the sandboxed control plane can't read host firewall/fail2ban state), so
    # it's a host-level artifact (systemd timer / cron) — opt-in, and needs root.
    case "${1:-}" in
      on|true|1)
        [ "$(id -u)" -eq 0 ] || { echo "vac security-agent on must run as root (sudo)" >&2; exit 1; }
        mkdir -p /var/lib/vac/security /usr/local/lib/vac
        # Manual-ban queue: vac-api (container uid 1000) drops validated ban
        # requests here; this root agent drains them. Owned by 1000 so the
        # non-root control plane can write, while the snapshot stays root-owned
        # and read-only to the container.
        mkdir -p /var/lib/vac/security/commands
        chown 1000:1000 /var/lib/vac/security/commands
        cat > /usr/local/lib/vac/vac-security-agent.sh <<'AGENTEOF'
#!/bin/sh
# vac-security-agent — host-side security collector for VAC. Writes
# $VAC_SECURITY_DIR/host.snapshot (fail2ban + firewall) for the sandboxed vac-api,
# which can't read host state directly. Read-only except for draining the
# manual-ban queue ($DIR/commands), whose fields it re-validates before use.
set -eu
DIR="${VAC_SECURITY_DIR:-/var/lib/vac/security}"
OUT="$DIR/host.snapshot"
PATH="/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
mkdir -p "$DIR"
# Drain operator-triggered bans the control plane queued (re-validate each).
CMDDIR="$DIR/commands"
if [ -d "$CMDDIR" ] && command -v fail2ban-client >/dev/null 2>&1; then
  for req in "$CMDDIR"/*.cmd; do
    [ -e "$req" ] || continue
    read -r action jail ip _ < "$req" || true
    if [ "${action:-}" = "ban" ] \
      && printf '%s' "$jail" | grep -qE '^[A-Za-z0-9._-]{1,64}$' \
      && printf '%s' "$ip" | grep -qE '^[0-9a-fA-F:.]{2,45}$'; then
      fail2ban-client set "$jail" banip "$ip" >/dev/null 2>&1 || true
    fi
    rm -f "$req"
  done
fi
TMP="$(mktemp "$DIR/.host.snapshot.XXXXXX")"
trap 'rm -f "$TMP"' EXIT
printf 'generated_at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$TMP"
if command -v fail2ban-client >/dev/null 2>&1; then
  if status="$(fail2ban-client status 2>/dev/null)"; then
    printf '@@@ fail2ban-status\n%s\n' "$status" >> "$TMP"
    jails="$(printf '%s\n' "$status" | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' ')"
    for jail in $jails; do
      [ -n "$jail" ] || continue
      if jout="$(fail2ban-client status "$jail" 2>/dev/null)"; then
        printf '@@@ fail2ban-jail %s\n%s\n' "$jail" "$jout" >> "$TMP"
      fi
    done
  fi
fi
if command -v ufw >/dev/null 2>&1 && fw="$(ufw status verbose 2>/dev/null)"; then
  printf '@@@ firewall ufw\n%s\n' "$fw" >> "$TMP"
elif command -v nft >/dev/null 2>&1 && fw="$(nft list ruleset 2>/dev/null)"; then
  printf '@@@ firewall nftables\n%s\n' "$fw" >> "$TMP"
else
  printf '@@@ firewall none\n' >> "$TMP"
fi
chmod 0644 "$TMP"
mv -f "$TMP" "$OUT"
trap - EXIT
AGENTEOF
        chmod 0755 /usr/local/lib/vac/vac-security-agent.sh
        if command -v systemctl >/dev/null 2>&1; then
          cat > /etc/systemd/system/vac-security-agent.service <<'UNITEOF'
[Unit]
Description=VAC host security collector (read-only fail2ban/firewall snapshot)
[Service]
Type=oneshot
ExecStart=/usr/local/lib/vac/vac-security-agent.sh
UNITEOF
          cat > /etc/systemd/system/vac-security-agent.timer <<'UNITEOF'
[Unit]
Description=Run the VAC host security collector periodically
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=10s
[Install]
WantedBy=timers.target
UNITEOF
          systemctl daemon-reload >/dev/null 2>&1 || true
          systemctl enable --now vac-security-agent.timer >/dev/null 2>&1 \
            || echo "warning: could not enable the vac-security-agent timer" >&2
        else
          printf '%s\n' '* * * * * root /usr/local/lib/vac/vac-security-agent.sh >/dev/null 2>&1' \
            > /etc/cron.d/vac-security-agent
        fi
        /usr/local/lib/vac/vac-security-agent.sh >/dev/null 2>&1 || true
        set_env VAC_SECURITY_AGENT true; dc up -d vac-api
        echo "Host security agent enabled — read-only fail2ban/firewall snapshots (~60s)." ;;
      off|false|0|"")
        [ "$(id -u)" -eq 0 ] || { echo "vac security-agent off must run as root (sudo)" >&2; exit 1; }
        if command -v systemctl >/dev/null 2>&1; then
          systemctl disable --now vac-security-agent.timer >/dev/null 2>&1 || true
          rm -f /etc/systemd/system/vac-security-agent.timer \
                /etc/systemd/system/vac-security-agent.service
          systemctl daemon-reload >/dev/null 2>&1 || true
        fi
        rm -f /etc/cron.d/vac-security-agent /usr/local/lib/vac/vac-security-agent.sh
        set_env VAC_SECURITY_AGENT false; dc up -d vac-api
        echo "Host security agent disabled and removed." ;;
      *) echo "usage: vac security-agent on|off" >&2; exit 1 ;;
    esac ;;
  config)    cat "$ENVF" ;;
  version|--version|-v)
    pinned="$(grep '^VAC_VERSION=' "$ENVF" 2>/dev/null | cut -d= -f2-)"
    printf 'pinned: %s\n' "${pinned:-unset}"
    if dc ps --status=running --services 2>/dev/null | grep -q '^vac-api$'; then
      dc exec -T vac-api vac-api version 2>/dev/null || echo 'running: vac-api unreachable'
    else
      echo 'running: vac-api not up'
    fi ;;
  reset-password)
    [ $# -ge 1 ] || { echo "usage: vac reset-password <username>" >&2; exit 1; }
    dc ps --status=running --services 2>/dev/null | grep -q '^vac-api$' \
      || { echo "vac-api is not running; start it with 'vac up' first." >&2; exit 1; }
    dc exec vac-api vac-api reset-password "$@" ;;
  migrate)
    # Whole-box export/import for VPS-to-VPS migration. Prefer the on-disk copy
    # (offline hosts); fall back to the published asset. Runs in $DIR so a bare
    # `vac migrate export` lands the bundle in the install dir by default.
    if [ -x "$DIR/migrate.sh" ]; then
      exec "$DIR/migrate.sh" "$@"
    elif command -v curl >/dev/null 2>&1; then
      curl -fsSL "$ASSET_BASE/migrate.sh" | sh -s -- "$@"
    elif command -v wget >/dev/null 2>&1; then
      wget -qO- "$ASSET_BASE/migrate.sh" | sh -s -- "$@"
    else
      echo "neither curl nor wget available, and no $DIR/migrate.sh on disk" >&2
      exit 1
    fi ;;
  uninstall)
    # Prefer an on-disk copy so air-gapped hosts work; fall back to fetching
    # the published asset. uninstall.sh exits 0 if the user declines.
    [ "$(id -u)" -eq 0 ] || { echo "vac uninstall must run as root" >&2; exit 1; }
    if [ -x "$DIR/uninstall.sh" ]; then
      exec "$DIR/uninstall.sh" "$@"
    elif command -v curl >/dev/null 2>&1; then
      curl -fsSL "$ASSET_BASE/uninstall.sh" | sh -s -- "$@"
    elif command -v wget >/dev/null 2>&1; then
      wget -qO- "$ASSET_BASE/uninstall.sh" | sh -s -- "$@"
    else
      echo "neither curl nor wget available, and no $DIR/uninstall.sh on disk" >&2
      exit 1
    fi ;;
  *)
    cat <<USAGE
vac — manage this VAC install ($DIR)

  vac status                       show running services
  vac version                      show the running and pinned versions
  vac logs [service]               tail logs
  vac upgrade [version]            pull + recreate (optionally pin a version)
  vac set-domain <domain>          serve dashboard on HTTPS + enable app subdomains
  vac unset-domain                 disable HTTPS dashboard and app subdomains
  vac managed-services on|off      toggle backups, databases & the add-on catalog
  vac container-shell on|off       toggle the in-dashboard container shell (privileged)
  vac idle-suspend on|off          toggle scale to zero (stop idle apps, wake on request)
  vac security-agent on|off        install/remove the read-only host security
                                   collector (fail2ban/firewall; root required)
  vac security-check firewall|fail2ban on|off
                                   toggle the firewall/fail2ban posture warnings
  vac reset-password <username>    set a new password and revoke sessions
  vac up | down | restart [service]
  vac config                       print the .env
  vac migrate export [DIR]         bundle this whole install (DB, key, certs, app data)
  vac migrate import <BUNDLE>      restore a bundle onto this host (move from another VPS)
  vac uninstall [--purge] [--apps] [--backup DIR] [--yes]
                                   remove VAC; see --help for full options
USAGE
    ;;
esac
VACEOF
  sed -i "s#__VAC_DIR__#$VAC_INSTALL_DIR#g" /usr/local/bin/vac
  sed -i "s#__VAC_ASSET_BASE__#$VAC_ASSET_BASE#g" /usr/local/bin/vac
  # Explicit 0755 (not `chmod +x`): on a fresh install we run under `umask 077`
  # (set when writing the .env), and a who-less `+x` is masked down to owner-only
  # — leaving the wrapper root-unreadable so the granted user can't run `vac`.
  chmod 0755 /usr/local/bin/vac
}

grant_user_access() {
  # Optionally let the user who invoked `sudo` manage VAC without sudo: add
  # them to the `docker` group (so docker/compose calls don't need root) and
  # hand them the install dir (so the `vac` CLI can read the root-only .env).
  # No-op when run as a real root login (no $SUDO_USER) or when disabled.
  [ "$VAC_GRANT_ACCESS" = "1" ] || return 0
  TARGET_USER="${SUDO_USER:-}"
  [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != "root" ] || return 0
  command -v usermod >/dev/null 2>&1 || return 0

  if getent group docker >/dev/null 2>&1; then
    if id -nG "$TARGET_USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
      : # already a member
    else
      info "Adding ${B}${TARGET_USER}${N} to the 'docker' group (root-equivalent; VAC_GRANT_ACCESS=0 to skip)…"
      if usermod -aG docker "$TARGET_USER"; then
        RELOGIN=1
      else
        warn "  could not add $TARGET_USER to the docker group"
      fi
    fi
  fi

  info "Giving ${B}${TARGET_USER}${N} ownership of ${VAC_INSTALL_DIR} (so 'vac' reads .env without sudo)…"
  chown -R "$TARGET_USER" "$VAC_INSTALL_DIR" || warn "  could not chown $VAC_INSTALL_DIR"
}

# ─────────────────────────────────────────────────────────────────────────────
# Host security agent (read-only fail2ban/firewall collector)
# ─────────────────────────────────────────────────────────────────────────────
ensure_security_agent() {
  # The host security agent is opt-in (it installs a host-level systemd timer /
  # cron outside the compose stack). Install/refresh it only when the effective
  # config says so — on a fresh install that's the wizard choice (written to
  # .env by generate_env), on an upgrade it's whatever the operator set. The
  # actual install lives in the `vac` CLI (`vac security-agent on`), so it's the
  # single source of truth and toggleable any time.
  _sa="$(grep '^VAC_SECURITY_AGENT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-)"
  [ "$(normalize_bool "${_sa:-false}")" = "true" ] || return 0
  info "Installing the host security agent (read-only fail2ban/firewall snapshot)…"
  /usr/local/bin/vac security-agent on >/dev/null 2>&1 \
    || warn "  could not install the host security agent (run 'sudo vac security-agent on' to retry)"
}

# ─────────────────────────────────────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────────────────────────────────────
print_summary() {
  detect_ip >/dev/null

  # On a fresh install, vac-api writes a one-time setup token into its work dir.
  # Read it back through the container so we can hand the operator a ready-to-
  # click link with the token baked in — no log-digging. The token lives on a
  # named volume, so the host can't read the file directly. Upgrades have no
  # token (the admin already exists), so we only bother when FRESH=1.
  SETUP_TOKEN=""
  if [ "$FRESH" = "1" ]; then
    info "Waiting for VAC to come up…"
    i=0
    while [ "$i" -lt 60 ]; do
      SETUP_TOKEN="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" \
        exec -T vac-api cat /var/lib/vac/repos/setup.token 2>/dev/null | tr -d '\r\n' || true)"
      [ -n "$SETUP_TOKEN" ] && break
      i=$((i + 1)); sleep 1
    done
  fi

  printf '\n%s VAC is up.%s\n\n' "$B$G" "$N"
  if [ -n "$VAC_DOMAIN" ]; then
    printf '  Dashboard:  %shttps://vac.%s%s   (once DNS + TLS settle)\n' "$B" "$VAC_DOMAIN" "$N"
    printf '  Direct:     http://%s:%s   (recovery / pre-DNS fallback)\n' "$IP" "$VAC_HOST_PORT"
    printf '\n  DNS: point %sA vac.%s%s and %sA *.%s%s at this host (%s).\n' "$B" "$VAC_DOMAIN" "$N" "$B" "$VAC_DOMAIN" "$N" "$IP"
  else
    printf '  Dashboard:  %shttp://%s:%s%s\n' "$B" "$IP" "$VAC_HOST_PORT" "$N"
    printf '\n  Add a domain later to put the dashboard on HTTPS and enable\n'
    printf '  automatic app subdomains:\n'
    printf '    %svac set-domain example.com%s\n' "$B" "$N"
  fi
  if [ -n "$SETUP_TOKEN" ]; then
    printf '\n  %sCreate your admin account — open this link (token included):%s\n' "$B" "$N"
    if [ -n "$VAC_DOMAIN" ]; then
      printf '    %s%shttps://vac.%s/setup?token=%s%s   %s(once DNS + TLS settle)%s\n' \
        "$B" "$G" "$VAC_DOMAIN" "$SETUP_TOKEN" "$N" "$D" "$N"
      printf '    %s%shttp://%s:%s/setup?token=%s%s   %s(direct — works right now)%s\n' \
        "$B" "$G" "$IP" "$VAC_HOST_PORT" "$SETUP_TOKEN" "$N" "$D" "$N"
    else
      printf '    %s%shttp://%s:%s/setup?token=%s%s\n' "$B" "$G" "$IP" "$VAC_HOST_PORT" "$SETUP_TOKEN" "$N"
    fi
    printf '\n  This one-time token is consumed once the account is created.\n'
  else
    printf '\n  Open the dashboard to create your admin account.\n'
  fi
  if [ "$(normalize_bool "${VAC_MANAGED_SERVICES:-false}")" = "true" ]; then
    printf '  Managed services are %son%s — backups, databases & add-ons are available.\n' "$B" "$N"
  fi
  if [ "$(normalize_bool "${VAC_ENABLE_SHELL:-false}")" = "true" ]; then
    printf '  Container shell is %son%s — a privileged, audited shell into app containers.\n' "$B" "$N"
  fi
  if [ "$(normalize_bool "${VAC_SECURITY_AGENT:-false}")" = "true" ]; then
    printf '  Security monitoring is %son%s — read-only fail2ban/firewall status on the Security tab.\n' "$B" "$N"
  fi
  printf '  Manage:  %svac status | vac logs | vac upgrade | vac down%s\n' "$B" "$N"
  if [ -n "$VAC_DOMAIN" ]; then
    printf '\n  %sSecurity:%s the direct port %s:%s serves the dashboard over plain\n' "$B" "$N" "$IP" "$VAC_HOST_PORT"
    printf '  HTTP (no TLS) — itʼs the pre-DNS recovery path. Once %shttps://vac.%s%s\n' "$B" "$VAC_DOMAIN" "$N"
    printf '  works, restrict that port to your admin IP (or close it and use an SSH\n'
    printf '  tunnel) so logins never traverse plain HTTP, e.g.:\n'
    printf '    %sufw allow from <your-ip> to any port %s proto tcp%s\n' "$B" "$VAC_HOST_PORT" "$N"
    printf '    %sufw deny %s/tcp%s\n' "$B" "$VAC_HOST_PORT" "$N"
  fi
  if [ "${RELOGIN:-0}" = "1" ]; then
    printf '\n  %sLog out and back in%s (or run %snewgrp docker%s) so %s%s%s can run\n' "$B" "$N" "$B" "$N" "$B" "${SUDO_USER:-your user}" "$N"
    printf '  %svac%s commands without sudo.\n' "$B" "$N"
  fi
  printf '\n'
}

# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
main() {
  handle_help "$@"
  preflight
  elevate_if_needed "$@"   # re-execs as root; everything below runs privileged
  parse_args "$@"
  detect_fresh

  show_banner              # retro header (after elevation so it prints once)
  run_wizard               # gather + confirm choices before any host mutation
  ensure_docker
  lay_down_files
  generate_env
  start_stack
  install_cli
  ensure_security_agent
  grant_user_access
  print_summary
}

main "$@"
