#!/usr/bin/env bash
# CoreState installer for Debian / Ubuntu (and other Linux systems with Docker)
#
#   curl -fsSL https://download.corestate.app/install.sh | sudo bash
#
# Options:
#   --port <n>        port CoreState is reachable on (asked during the installation,
#                     suggested: 3000 or the next free port)
#   --dir <path>      installation directory (default: /opt/corestate)
#   --version <x.y.z> install a specific release instead of the latest one
#   --yes             accept the license and all defaults without asking
#   --update          refresh the installation files (used by "corestate update")
set -euo pipefail

IMAGE="${CORESTATE_IMAGE:-ghcr.io/therocraft/corestate}"
INSTALL_URL="${CORESTATE_INSTALL_URL:-https://download.corestate.app/install.sh}"
LICENSE_URL="${CORESTATE_LICENSE_URL:-https://corestate.app/license}"
LOG="/var/log/corestate-install.log"
DIR="/opt/corestate"
PORT=""
VERSION=""
ASSUME_YES=false
UPDATE=false

while [ $# -gt 0 ]; do
  case "$1" in
    --port) PORT="${2:-}"; shift 2 ;;
    --dir) DIR="${2:-}"; shift 2 ;;
    --version) VERSION="${2:-}"; shift 2 ;;
    --yes|-y) ASSUME_YES=true; shift ;;
    --update) UPDATE=true; ASSUME_YES=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

# ------------------------------------------------------------------ output
# Interactive terminals get a spinner per step and a progress bar pinned to the
# bottom line; everything else (logs, CI) gets plain lines. Command output goes to $LOG.
FANCY=false
if [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then FANCY=true; fi
if [ "$FANCY" = true ]; then
  B=$'\e[1m' D=$'\e[2m' G=$'\e[32m' Y=$'\e[33m' R=$'\e[31m' N=$'\e[0m'
  if [[ "${COLORTERM:-}" =~ truecolor|24bit ]]; then C=$'\e[38;2;74;127;255m'; else C=$'\e[38;5;69m'; fi
else
  B='' D='' G='' Y='' R='' N='' C=''
fi

warn() { printf '  %s!%s %s\n' "$Y" "$N" "$*"; }
die() {
  bar_stop
  printf '\n  %s✗ %s%s\n\n' "$R$B" "$*" "$N" >&2
  exit 1
}

repeat() { # repeat <char> <count>
  local s=""
  if [ "$2" -gt 0 ]; then printf -v s '%*s' "$2" ''; s="${s// /$1}"; fi
  printf '%s' "$s"
}

# progress bar on the last terminal line (a scroll region keeps it in place)
BAR=false ROWS=0 COLS=80 STEP=0 TOTAL=1 CURRENT=""
bar_start() {
  [ "$FANCY" = true ] || return 0
  local size
  size="$(stty size </dev/tty 2>/dev/null || true)"
  ROWS="${size% *}" COLS="${size#* }"
  [[ "$ROWS" =~ ^[0-9]+$ ]] && [[ "$COLS" =~ ^[0-9]+$ ]] && [ "$ROWS" -ge 10 ] && [ "$COLS" -ge 50 ] || return 0
  printf '\n\e7\e[1;%dr\e8\e[1A' "$((ROWS - 1))"
  BAR=true
  bar_draw
}
bar_draw() {
  [ "$BAR" = true ] || return 0
  local finished=$STEP
  [ -n "$CURRENT" ] && finished=$((STEP - 1))
  local pct=$((finished * 100 / TOTAL))
  local width=$((COLS - 50))
  [ "$width" -gt 36 ] && width=36
  [ "$width" -lt 10 ] && width=10
  local fill=$((pct * width / 100))
  local label="${CURRENT:-Done}"
  printf '\e7\e[%d;1H\e[2K  %sCoreState%s  %s%s%s%s%s  %s%3d%%%s  %s%s%s\e8' \
    "$ROWS" "$B" "$N" "$C" "$(repeat ━ "$fill")" "$D" "$(repeat ─ $((width - fill)))" "$N" \
    "$B" "$pct" "$N" "$D" "${label:0:$((COLS - width - 26))}" "$N"
}
bar_stop() {
  [ "$BAR" = true ] || return 0
  BAR=false
  printf '\e7\e[r\e[%d;1H\e[2K\e8' "$ROWS"
}
cleanup() {
  [ "$FANCY" = false ] || printf '\e[?25h'
  bar_stop
}
trap cleanup EXIT
trap 'echo; exit 130' INT TERM

FRAMES=('●∙∙∙' '∙●∙∙' '∙∙●∙' '∙∙∙●' '∙∙●∙' '∙●∙∙')
elapsed() { local s="$1"; if [ "$s" -ge 2 ]; then printf '%s%ds%s' "$D" "$s" "$N"; fi; }

# step <label while running> <label when done> <command...>
step() {
  local label="$1" done_label="$2" rc=0 start=$SECONDS i=0 pid mark
  shift 2
  mark="$(wc -l <"$LOG" 2>/dev/null || echo 0)"
  STEP=$((STEP + 1)) CURRENT="$label"
  bar_draw
  printf '\n==> %s\n' "$label" >>"$LOG"
  if [ "$FANCY" = true ]; then
    "$@" >>"$LOG" 2>&1 &
    pid=$!
    printf '\e[?25l'
    while kill -0 "$pid" 2>/dev/null; do
      printf '\r\e[2K  %s%s%s  %s  %s' "$C" "${FRAMES[i % ${#FRAMES[@]}]}" "$N" "$label" "$(elapsed $((SECONDS - start)))"
      i=$((i + 1))
      sleep 0.12
    done
    wait "$pid" || rc=$?
    printf '\r\e[2K\e[?25h'
  else
    printf '  ... %s\n' "$label"
    "$@" >>"$LOG" 2>&1 || rc=$?
  fi
  if [ "$rc" -ne 0 ]; then
    CURRENT="Failed"
    bar_draw
    printf '  %s✗%s %s\n\n%s' "$R" "$N" "$label" "$D"
    tail -n +"$((mark + 1))" "$LOG" | tail -n 15 | cut -c1-"$((COLS - 6))" | sed 's/^/    /'
    printf '%s\n    Full log: %s\n' "$N" "$LOG"
    return "$rc"
  fi
  local took
  took="$(elapsed $((SECONDS - start)))"
  printf '  %s✓%s %s%s\n' "$G" "$N" "$done_label" "${took:+  $took}"
  CURRENT=""
  bar_draw
}

# ------------------------------------------------------------------ checks
[ "$(id -u)" -eq 0 ] || die "Please run as root, e.g.: curl -fsSL https://download.corestate.app/install.sh | sudo bash"
[ "$(uname -s)" = "Linux" ] || die "CoreState can only be installed on Linux"
case "$(uname -m)" in
  x86_64|amd64|aarch64|arm64) ;;
  *) die "Unsupported CPU architecture $(uname -m) (supported: amd64, arm64)" ;;
esac
[ -z "$VERSION" ] || [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "--version must look like 1.2.3"
[[ "$DIR" = /* ]] || die "--dir must be an absolute path"
{ : >>"$LOG"; } 2>/dev/null || LOG="/tmp/corestate-install.log"
printf '\n---- %s\n' "$(date '+%F %T')" >>"$LOG"

interactive() { [ "$ASSUME_YES" = false ] && [ -r /dev/tty ]; }
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }
port_in_use() { # port_in_use <port>
  if command -v ss >/dev/null 2>&1; then
    ss -ltnH "sport = :$1" 2>/dev/null | grep -q .
  else
    (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
  fi
}
free_port_from() { # first free port starting at <port>
  local p="$1"
  while [ "$p" -le 65535 ] && port_in_use "$p"; do p=$((p + 1)); done
  echo "$p"
}

if [ "$UPDATE" = false ]; then
  printf '\n  %s▍%s %sCoreState%s %sInstaller%s\n' "$C" "$N" "$B" "$N" "$D" "$N"
  printf '  %s▍%s %sStatus pages & uptime monitoring · corestate.app%s\n\n' "$C" "$N" "$D" "$N"
fi

# ------------------------------------------------------------------ questions first, then the work
if [ "$UPDATE" = false ] && [ ! -f "$DIR/.env" ]; then
  printf '  CoreState is proprietary software. By installing it you accept the\n'
  printf '  license agreement: %s%s%s\n\n' "$C" "$LICENSE_URL" "$N"
  if [ "$ASSUME_YES" = false ]; then
    [ -r /dev/tty ] || die "Run with --yes to accept the license in non-interactive mode"
    accept=""
    read -r -p "  Accept the license agreement? [y/N] " accept </dev/tty || true
    [[ "$accept" =~ ^[YyJj] ]] || die "Installation cancelled"
    echo
  fi
fi

ENV_FILE="$DIR/.env"
env_get() { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -n1 | cut -d= -f2- || true; }
CURRENT_PORT=""
if [ -f "$ENV_FILE" ]; then
  CURRENT_PORT="$(env_get HOST_PORT)"
  # a re-install keeps the pinned version, "corestate update" moves to the latest release
  [ -n "$VERSION" ] || [ "$UPDATE" = true ] || VERSION="$(env_get CORESTATE_VERSION)"
  # developer builds need the server's own access: the installer moves to the latest release
  [[ "$VERSION" != *-dev.* ]] || VERSION=""
fi
# the port CoreState already uses is not "in use" for this installation
port_taken() { [ "$1" != "$CURRENT_PORT" ] && port_in_use "$1"; }

if [ -n "$PORT" ]; then
  valid_port "$PORT" || die "Invalid port: $PORT (use a number from 1 to 65535)"
  if port_taken "$PORT"; then die "Port $PORT is already in use – choose another one with --port"; fi
elif [ "$UPDATE" = true ] || { [ -n "$CURRENT_PORT" ] && ! interactive; }; then
  PORT="${CURRENT_PORT:-3000}"
else
  SUGGESTED="${CURRENT_PORT:-$(free_port_from 3000)}"
  if interactive; then
    printf '  Which port should the web interface use? Any free port works –\n'
    printf '  %se.g. 8080, or keep the suggestion and add a reverse proxy later.%s\n' "$D" "$N"
    while true; do
      PORT=""
      read -r -p "  Port [$SUGGESTED] " PORT </dev/tty || true
      PORT="${PORT:-$SUGGESTED}"
      if ! valid_port "$PORT"; then
        warn "\"$PORT\" is not a valid port – enter a number from 1 to 65535"
      elif port_taken "$PORT"; then
        warn "Port $PORT is already used by another program – choose a different one"
      else
        break
      fi
    done
    echo
  else
    PORT="$SUGGESTED"
    [ "$PORT" = 3000 ] || warn "Port 3000 is in use – CoreState will use port $PORT (change it later with: corestate port <n>)"
  fi
fi

# ------------------------------------------------------------------ plan
NEED_DOCKER=false NEED_COMPOSE=false
command -v docker >/dev/null 2>&1 || NEED_DOCKER=true
[ "$NEED_DOCKER" = true ] || docker compose version >/dev/null 2>&1 || NEED_COMPOSE=true
TOTAL=5
[ "$NEED_DOCKER" = false ] || TOTAL=$((TOTAL + 1))
[ "$NEED_COMPOSE" = false ] || TOTAL=$((TOTAL + 1))
bar_start

export DEBIAN_FRONTEND=noninteractive NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=a

wait_for_apt() { # other package managers (e.g. unattended upgrades on a fresh server)
  local waited=0
  while pgrep -x 'apt|apt-get|dpkg|unattended-upgr' >/dev/null 2>&1 && [ "$waited" -lt 600 ]; do
    sleep 3
    waited=$((waited + 3))
  done
}
install_docker() {
  wait_for_apt
  command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
  curl -fsSL https://get.docker.com | sh
  systemctl enable --now docker || true
  docker info
}
install_compose() {
  wait_for_apt
  command -v apt-get >/dev/null 2>&1 || { echo "No apt-get – install the Docker Compose plugin manually"; return 1; }
  apt-get update -qq && apt-get install -y -qq docker-compose-plugin
  docker compose version
}

if [ "$NEED_DOCKER" = true ]; then
  step "Installing Docker" "Docker installed" install_docker || die "Docker could not be installed"
fi
if [ "$NEED_COMPOSE" = true ]; then
  step "Installing Docker Compose" "Docker Compose installed" install_compose || die "Docker Compose v2 is required (https://docs.docker.com/compose/install/)"
fi
docker info >/dev/null 2>&1 || die "Docker is not running – start it with: systemctl start docker"

# ------------------------------------------------------------------ configuration
mkdir -p "$DIR"
TZ_NAME="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"

# keep the previous configuration until the new version was downloaded
[ ! -f "$ENV_FILE" ] || cp -p "$ENV_FILE" "$ENV_FILE.bak"
[ ! -f "$DIR/docker-compose.yml" ] || cp -p "$DIR/docker-compose.yml" "$DIR/docker-compose.yml.bak"
restore() {
  [ ! -f "$ENV_FILE.bak" ] || mv -f "$ENV_FILE.bak" "$ENV_FILE"
  [ ! -f "$DIR/docker-compose.yml.bak" ] || mv -f "$DIR/docker-compose.yml.bak" "$DIR/docker-compose.yml"
  return 0
}

write_files() {
cat >"$ENV_FILE" <<EOF
# CoreState configuration – apply changes with: corestate restart
HOST_PORT=$PORT
CORESTATE_VERSION=${VERSION:-latest}
TZ=${TZ_NAME:-UTC}
EOF
chmod 600 "$ENV_FILE"

cat >"$DIR/docker-compose.yml" <<EOF
# Managed by the CoreState installer – changes are overwritten by "corestate update".
name: corestate
services:
  corestate:
    image: \${CORESTATE_DEV_IMAGE:-$IMAGE}:\${CORESTATE_VERSION:-latest}
    container_name: corestate
    restart: unless-stopped
    ports:
      - "\${HOST_PORT:-3000}:3000"
    environment:
      - PORT=3000
      - TZ=\${TZ:-UTC}
      - UPDATE_IMAGE=$IMAGE
    volumes:
      - data:/app/data
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Installs updates started from Settings → About in the admin dashboard.
  updater:
    image: docker:27-cli
    container_name: corestate-updater
    restart: unless-stopped
    entrypoint: ["/bin/sh", "$DIR/updater.sh"]
    working_dir: $DIR
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - $DIR:$DIR
      - data:/data
    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "2"

volumes:
  data:
EOF

cat >"$DIR/updater.sh" <<'EOF'
#!/bin/sh
# CoreState updater: waits for update requests from the admin dashboard
# (<data>/updater/request) and installs the requested version.
set -u
DIR=/data/updater
CDIR="$(pwd)"
mkdir -p "$DIR" && chown 1000:1000 "$DIR" 2>/dev/null
status() {
  printf '{"state":"%s","version":"%s","message":"%s","at":%s000}\n' "$1" "$2" "$3" "$(date +%s)" >"$DIR/status.tmp" && mv "$DIR/status.tmp" "$DIR/status"
}
field() { grep -o "\"$1\":\"[^\"]*\"" "$2" | head -n1 | cut -d'"' -f4; }
env_set() {
  if grep -q "^$1=" "$CDIR/.env"; then sed -i "s|^$1=.*|$1=$2|" "$CDIR/.env"; else echo "$1=$2" >>"$CDIR/.env"; fi
}
env_unset() { sed -i "/^$1=/d" "$CDIR/.env"; }
restore_env() {
  env_set CORESTATE_VERSION "${OLD_V:-latest}"
  if [ -n "$OLD_IMG" ]; then env_set CORESTATE_DEV_IMAGE "$OLD_IMG"; else env_unset CORESTATE_DEV_IMAGE; fi
}
compose() { docker compose --project-directory "$CDIR" "$@" >>"$DIR/update.log" 2>&1; }

while true; do
  date +%s >"$DIR/alive"
  # this updater can switch to developer builds and back (renewed, so an older one is noticed)
  echo channels >"$DIR/features"
  if [ -f "$DIR/request" ]; then
    REQ="$DIR/request.work"
    mv -f "$DIR/request" "$REQ"
    V="$(field version "$REQ")"
    HAS_IMAGE=false
    grep -q '"image":' "$REQ" && HAS_IMAGE=true
    IMG="$(field image "$REQ")"
    REG="$(field registry "$REQ")"
    USR="$(field user "$REQ")"
    PASS="$(field pass "$REQ")"
    rm -f "$REQ"
    OK=true
    echo "$V" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-dev\.[0-9]{12}\.[0-9a-f]{7,40})?$' || OK=false
    for x in "$IMG" "$REG"; do echo "$x" | grep -Eq '^[a-z0-9.:/_-]*$' || OK=false; done
    if [ "$OK" != true ]; then
      status failed "" "Invalid update request"
    else
      OLD_V="$(grep '^CORESTATE_VERSION=' "$CDIR/.env" | cut -d= -f2)"
      OLD_IMG="$(grep '^CORESTATE_DEV_IMAGE=' "$CDIR/.env" | cut -d= -f2)"
      : >"$DIR/update.log"
      status pulling "$V" ""
      # developer builds come from a protected registry: sign in with the short-lived credentials
      if [ -n "$REG" ] && [ -n "$USR" ]; then
        printf '%s' "$PASS" | docker login "$REG" -u "$USR" --password-stdin >>"$DIR/update.log" 2>&1
      fi
      if [ "$HAS_IMAGE" = true ]; then
        if [ -n "$IMG" ]; then env_set CORESTATE_DEV_IMAGE "$IMG"; else env_unset CORESTATE_DEV_IMAGE; fi
      fi
      env_set CORESTATE_VERSION "$V"
      if compose pull corestate; then
        [ -z "$REG" ] || docker logout "$REG" >/dev/null 2>&1
        status restarting "$V" ""
        if compose up -d corestate; then
          status "done" "$V" ""
        else
          restore_env
          compose up -d corestate
          status failed "$V" "The new version could not be started"
        fi
      else
        [ -z "$REG" ] || docker logout "$REG" >/dev/null 2>&1
        restore_env
        status failed "$V" "The new version could not be downloaded"
      fi
    fi
  fi
  sleep 3
done
EOF

# the "corestate" command
{
  printf '#!/usr/bin/env bash\n# CoreState command line helper (written by the installer)\n'
  printf 'DIR=%q\nDEFAULT_INSTALL_URL=%q\n' "$DIR" "$INSTALL_URL"
  cat <<'EOF'
set -euo pipefail
INSTALL_URL="${CORESTATE_INSTALL_URL:-$DEFAULT_INSTALL_URL}"
if [ -t 1 ]; then B=$'\e[1m' D=$'\e[2m' G=$'\e[32m' R=$'\e[31m' Y=$'\e[33m' C=$'\e[38;5;69m' N=$'\e[0m'; else B='' D='' G='' R='' Y='' C='' N=''; fi
ok() { printf '  %s✓%s %s\n' "$G" "$N" "$*"; }
fail() { printf '  %s✗%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
compose() { docker compose --project-directory "$DIR" "$@"; }
need_root() { [ "$(id -u)" -eq 0 ] || fail "This command needs root – run: sudo corestate $*"; }
quiet() { # quiet <label> <command...>: spinner while it runs, output only on errors
  local label="$1" out rc=0 pid i=0 frames=('●∙∙∙' '∙●∙∙' '∙∙●∙' '∙∙∙●' '∙∙●∙' '∙●∙∙')
  shift
  out="$(mktemp)"
  "$@" >"$out" 2>&1 &
  pid=$!
  if [ -t 1 ]; then
    printf '\e[?25l'
    while kill -0 "$pid" 2>/dev/null; do
      printf '\r\e[2K  %s%s%s  %s' "$C" "${frames[i % 6]}" "$N" "$label"
      i=$((i + 1))
      sleep 0.12
    done
    printf '\r\e[2K\e[?25h'
  fi
  wait "$pid" || rc=$?
  LAST_OUT="$(cat "$out")"
  if [ "$rc" -ne 0 ]; then
    printf '  %s✗%s %s\n' "$R" "$N" "$label"
    sed 's/^/    /' "$out" >&2
    rm -f "$out"
    exit "$rc"
  fi
  rm -f "$out"
}
LAST_OUT=""
# backup commands run inside the container (as the user that owns the data)
in_app() { docker exec -u node corestate node server/dist/cli.js "$@"; }
need_backup_support() {
  [ "$(state corestate)" = running ] || fail "CoreState is not running – start it with: sudo corestate start"
  docker exec corestate test -f server/dist/cli.js 2>/dev/null || fail "This CoreState version cannot create backups yet – update first: sudo corestate update"
}
wait_up() {
  local port
  port="$(env_get HOST_PORT)"
  for _ in $(seq 1 90); do
    if curl -fsS "http://127.0.0.1:$port/healthz" >/dev/null 2>&1; then return 0; fi
    sleep 2
  done
  return 1
}
env_get() { grep -E "^$1=" "$DIR/.env" 2>/dev/null | tail -n1 | cut -d= -f2- || true; }
address() { echo "http://$(hostname -I 2>/dev/null | awk '{print $1}'):$(env_get HOST_PORT)"; }
state() { local s; s="$(docker inspect -f '{{.State.Status}}' "$1" 2>/dev/null)" || s=""; echo "${s:-not created}"; }
paint() { if [ "$1" = running ]; then printf '%s●%s %s' "$G" "$N" "$1"; else printf '%s●%s %s' "$Y" "$N" "$1"; fi; }
# without root, Docker and the configuration cannot be read: ask for sudo instead of showing nothing
case "${1:-help}" in
  help | -h | --help) ;;
  *) if [ "$(id -u)" -ne 0 ] && ! docker info >/dev/null 2>&1; then exec sudo "$0" "$@"; fi ;;
esac

case "${1:-help}" in
  status)
    v="$(docker exec corestate printenv CORESTATE_VERSION 2>/dev/null || echo "–")"
    printf '\n  %sCoreState%s %s\n\n' "$B" "$N" "$v"
    printf '  %s%-10s%s %s\n' "$D" "Server" "$N" "$(paint "$(state corestate)")"
    printf '  %s%-10s%s %s\n' "$D" "Updater" "$N" "$(paint "$(state corestate-updater)")"
    printf '  %s%-10s%s %s\n' "$D" "Address" "$N" "$(address)"
    if [ -n "$(env_get CORESTATE_DEV_IMAGE)" ]; then ch="developer builds"; else ch="$(env_get CORESTATE_VERSION)"; fi
    printf '  %s%-10s%s %s\n' "$D" "Channel" "$N" "$ch"
    printf '  %s%-10s%s %s\n\n' "$D" "Directory" "$N" "$DIR"
    ;;
  logs) compose logs -f --tail=200 corestate ;;
  start) need_root start; quiet "Starting CoreState" compose up -d; ok "CoreState started – $(address)" ;;
  stop) need_root stop; quiet "Stopping CoreState" compose stop; ok "CoreState stopped" ;;
  restart) need_root restart; quiet "Restarting CoreState" compose up -d --force-recreate; ok "CoreState restarted – $(address)" ;;
  version) docker exec corestate printenv CORESTATE_VERSION 2>/dev/null || echo "unknown" ;;
  port)
    current="$(env_get HOST_PORT)"
    if [ -z "${2:-}" ]; then echo "$current"; exit 0; fi
    need_root port
    new="$2"
    [[ "$new" =~ ^[0-9]+$ ]] && [ "$new" -ge 1 ] && [ "$new" -le 65535 ] || fail "Invalid port: $new"
    if [ "$new" = "$current" ]; then ok "CoreState already uses port $new"; exit 0; fi
    if { command -v ss >/dev/null 2>&1 && ss -ltnH "sport = :$new" | grep -q .; } || (exec 3<>"/dev/tcp/127.0.0.1/$new") 2>/dev/null; then
      fail "Port $new is already in use"
    fi
    sed -i "s/^HOST_PORT=.*/HOST_PORT=$new/" "$DIR/.env"
    quiet "Moving CoreState to port $new" compose up -d corestate
    ok "CoreState now listens on port $new – $(address)"
    ;;
  activate)
    need_root activate
    [ -n "${2:-}" ] || fail "Usage: sudo corestate activate <code>"
    [ "$(state corestate)" = running ] || fail "CoreState is not running – start it with: sudo corestate start"
    docker exec corestate test -f server/dist/cli.js 2>/dev/null || fail "This CoreState version cannot be activated yet – update first: sudo corestate update"
    printf '\n  %s!%s %sThis connects the server to CoreState ID%s (%s)\n\n' "$Y" "$N" "$B" "$N" "$(docker exec corestate printenv CORESTATE_ID_URL 2>/dev/null || echo https://id.corestate.app)"
    printf '  From now on it sends about every 30 minutes:\n'
    printf '    • an ID of this installation and its public key\n'
    printf '    • the CoreState version it runs\n'
    printf '    • its IP address (with every connection)\n'
    printf '  %sNo monitors, status pages, users or other data. End it with: sudo corestate deactivate%s\n\n' "$D" "$N"
    if [ "${3:-}" != --yes ]; then
      [ -r /dev/tty ] || fail "Confirm with: sudo corestate activate <code> --yes"
      read -r -p "  Continue? [y/N] " answer </dev/tty || true
      case "$answer" in y | Y | yes | YES) ;; *) printf '  Cancelled – nothing was sent.\n\n'; exit 0 ;; esac
    fi
    out="$(in_app activate "$2" 2>&1)" || fail "$out"
    ok "Activated: $(echo "$out" | cut -f1)"
    printf '  %sThe developer build is now offered under Settings → About in the dashboard.%s\n\n' "$D" "$N"
    ;;
  deactivate)
    need_root deactivate
    [ "$(state corestate)" = running ] || fail "CoreState is not running – start it with: sudo corestate start"
    out="$(in_app deactivate 2>&1)" || fail "$out"
    ok "This server no longer contacts CoreState ID"
    if [ -n "$(env_get CORESTATE_DEV_IMAGE)" ]; then printf '  %sIt still runs a developer build – back to the releases: sudo corestate stable%s\n\n' "$D" "$N"; fi
    ;;
  stable)
    need_root stable
    [ -n "$(env_get CORESTATE_DEV_IMAGE)" ] || { ok "CoreState already uses the releases"; exit 0; }
    exec "$0" update latest --stable
    ;;
  update)
    need_root update
    # developer builds are installed by the updater with the server's own access
    if [ -n "$(env_get CORESTATE_DEV_IMAGE)" ] && [ "${3:-}" != --stable ] && [ -z "${2:-}" ]; then
      [ "$(state corestate)" = running ] || fail "CoreState is not running – start it with: sudo corestate start"
      since="$(date +%s)"
      want="$(in_app dev-update 2>&1)" || fail "$want"
      printf '  Installing developer build %s …\n' "$want"
      for _ in $(seq 1 300); do
        sleep 2
        st="$(docker exec corestate-updater cat /data/updater/status 2>/dev/null || true)"
        # only what the updater wrote for this request (an older result may still be there)
        at="$(echo "$st" | sed -n 's/.*"at":\([0-9]*\)000.*/\1/p')"
        [ -n "$at" ] && [ "$at" -ge "$since" ] || continue
        case "$st" in
          *'"state":"done"'*'"version":"'"$want"'"'*) ok "CoreState $want is running – $(address)"; exit 0 ;;
          *'"state":"failed"'*) fail "$(echo "$st" | sed 's/.*"message":"\([^"]*\)".*/\1/')" ;;
        esac
      done
      fail "The update takes unusually long – check: corestate status"
    fi
    target="${2:-latest}"
    args=(--update --dir "$DIR")
    [ "$target" = latest ] || args+=(--version "$target")
    # only run what is really the installer (a proxy or login page may answer with HTML)
    if curl -fsSL "$INSTALL_URL" -o /tmp/corestate-install.sh 2>/dev/null && head -n1 /tmp/corestate-install.sh | grep -q '^#!.*bash'; then
      bash /tmp/corestate-install.sh "${args[@]}"
      rm -f /tmp/corestate-install.sh
    else
      rm -f /tmp/corestate-install.sh
      printf '  %s!%s Could not download the installer – updating the containers directly\n' "$Y" "$N"
      sed -i "s/^CORESTATE_VERSION=.*/CORESTATE_VERSION=$target/" "$DIR/.env"
      sed -i '/^CORESTATE_DEV_IMAGE=/d' "$DIR/.env"
      quiet "Downloading CoreState $target" compose pull corestate
      compose pull updater >/dev/null 2>&1 || true
      quiet "Restarting CoreState" compose up -d --remove-orphans
      ok "CoreState updated – $(address)"
    fi
    ;;
  backup)
    need_root backup
    need_backup_support
    dest="${2:-.}"
    [ -d "$dest" ] || fail "Folder not found: $dest"
    quiet "Creating backup" in_app backup
    name="$(printf '%s\n' "$LAST_OUT" | tail -n1 | cut -f1)"
    quiet "Saving backup" docker cp "corestate:/app/data/backups/$name" "$dest/$name"
    if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER": "$dest/$name" 2>/dev/null || true; fi
    ok "Backup saved: $(cd "$dest" && pwd)/$name ($(du -h "$dest/$name" | cut -f1))"
    printf '    %sIt is also listed in the dashboard under Settings → Backups.%s\n' "$D" "$N"
    ;;
  backups)
    need_root backups
    need_backup_support
    printf '\n'
    in_app list | sed 's/^/  /'
    printf '\n  %sRestore one with: sudo corestate restore <name>%s\n\n' "$D" "$N"
    ;;
  restore)
    need_root restore
    src="${2:-}"
    [ -n "$src" ] || fail "Usage: sudo corestate restore <backup file | backup name>"
    need_backup_support
    if [ -f "$src" ]; then
      quiet "Uploading backup" docker cp "$src" corestate:/tmp/corestate-restore.tar.gz
      docker exec corestate chown node:node /tmp/corestate-restore.tar.gz
      quiet "Checking backup" in_app import /tmp/corestate-restore.tar.gz
      docker exec corestate rm -f /tmp/corestate-restore.tar.gz >/dev/null 2>&1 || true
      name="$(printf '%s\n' "$LAST_OUT" | tail -n1)"
    else
      name="$(basename "$src")"
    fi
    printf '\n  All current data is %sreplaced%s by this backup. A backup of the\n' "$B" "$N"
    printf '  current state is created first.\n'
    c=""
    read -r -p "  Type 'restore' to continue: " c </dev/tty || true
    [ "$c" = restore ] || fail "Cancelled"
    quiet "Preparing restore" in_app restore "$name"
    safety="$(printf '%s\n' "$LAST_OUT" | tail -n1)"
    quiet "Restarting CoreState" compose restart corestate
    quiet "Waiting for CoreState" wait_up
    ok "Backup restored – $(address)"
    printf '    %sThe previous data was saved as %s%s\n' "$D" "$safety" "$N"
    ;;
  uninstall)
    need_root uninstall
    printf '\n  This removes CoreState %sincluding all monitors, status pages and history%s.\n' "$B" "$N"
    c=""
    read -r -p "  Type 'delete' to confirm: " c </dev/tty || true
    [ "$c" = delete ] || fail "Cancelled"
    quiet "Removing CoreState" compose down -v --remove-orphans
    rm -rf "$DIR" /usr/local/bin/corestate
    ok "CoreState has been removed"
    ;;
  help|-h|--help)
    row() { printf '    %s%-16s%s %s\n' "$C" "$1" "$N" "$2"; }
    printf '\n  %sCoreState%s – manage this installation\n\n' "$B" "$N"
    printf '  %sUsage:%s corestate <command>\n\n' "$D" "$N"
    row "status" "version, state and address"
    row "logs" "follow the logs (Ctrl+C to stop)"
    row "start | stop" "start or stop CoreState"
    row "restart" "restart, e.g. after editing $DIR/.env"
    row "update [x.y.z]" "update to the latest (or a specific) release"
    if [ -n "$(env_get CORESTATE_DEV_IMAGE)" ]; then row "stable" "leave the developer builds for the latest release"; fi
    row "activate <code>" "unlock developer builds with a code from the developer"
    row "deactivate" "end the connection to CoreState ID"
    row "port [n]" "show the port or move CoreState to port n"
    row "backup [folder]" "create a backup and save it here (or in folder)"
    row "backups" "list the backups on this server"
    row "restore <file>" "restore a backup file or a backup from the list"
    row "version" "print the installed version"
    row "uninstall" "remove CoreState and all of its data"
    printf '\n  %sCommands that change something need sudo.%s\n\n' "$D" "$N"
    ;;
  *) fail "Unknown command \"$1\" – see: corestate help" ;;
esac
EOF
} >/usr/local/bin/corestate
chmod +x /usr/local/bin/corestate
}

step "Writing configuration" "Configuration written to $DIR" write_files || die "Could not write the installation files"

# ------------------------------------------------------------------ download and start
pull_corestate() { docker compose --project-directory "$DIR" pull corestate; }
pull_updater() { docker compose --project-directory "$DIR" pull updater || docker image inspect docker:27-cli >/dev/null; }
start_corestate() {
  docker compose --project-directory "$DIR" up -d --remove-orphans
  # the updater runs updater.sh – restart it, so a new version of the script is used
  docker compose --project-directory "$DIR" restart updater || true
}
wait_healthy() {
  for _ in $(seq 1 90); do
    if curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then return 0; fi
    sleep 2
  done
  docker compose --project-directory "$DIR" logs --tail=40 corestate
  return 1
}

WHAT="CoreState${VERSION:+ $VERSION}"
[ "$VERSION" != latest ] || WHAT="CoreState"
step "Downloading $WHAT" "$WHAT downloaded" pull_corestate || { restore; die "Could not download $IMAGE:${VERSION:-latest}"; }
rm -f "$ENV_FILE.bak" "$DIR/docker-compose.yml.bak"
step "Downloading the updater" "Updater downloaded" pull_updater || warn "The updater is not available – updates from the dashboard will not work"
step "Starting CoreState" "Containers started" start_corestate || die "CoreState could not be started"
step "Waiting for CoreState to respond" "CoreState responds" wait_healthy || die "CoreState did not start – check the logs with: corestate logs"
bar_stop

RUNNING="$(docker exec corestate printenv CORESTATE_VERSION 2>/dev/null || true)"
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
URL="http://${IP:-localhost}:$PORT"

if [ "$UPDATE" = true ]; then
  printf '\n  %sCoreState %s is running%s – %s\n\n' "$B" "$RUNNING" "$N" "$URL"
  exit 0
fi

# ------------------------------------------------------------------ summary
if [ -n "$CURRENT_PORT" ]; then
  HEAD="CoreState $RUNNING is running" NEXT="Open the dashboard"
else
  HEAD="CoreState $RUNNING is ready" NEXT="Create your admin account"
fi
# plain text (for the width) and styled text per line
PLAIN=("  $HEAD" "" "$NEXT" "$URL" "" "Manage     corestate help" "Directory  $DIR" "Log        $LOG")
STYLED=("$G✓$N $B$HEAD$N" "" "$C$NEXT$N" "$B$URL$N" "" "${D}Manage$N     corestate help" "${D}Directory$N  $DIR" "${D}Log$N        $LOG")
W=0
for p in "${PLAIN[@]}"; do [ "${#p}" -le "$W" ] || W=${#p}; done
W=$((W + 4))
printf '\n  %s╭%s╮%s\n' "$D" "$(repeat ─ "$W")" "$N"
for i in "${!PLAIN[@]}"; do
  printf '  %s│%s  %s%*s%s│%s\n' "$D" "$N" "${STYLED[$i]}" $((W - 2 - ${#PLAIN[$i]})) '' "$D" "$N"
done
printf '  %s╰%s╯%s\n\n' "$D" "$(repeat ─ "$W")" "$N"
