#!/usr/bin/env bash
# Clusev installer — idempotent one-command bootstrap (root required).
# Installs Docker on Debian/Ubuntu (official apt repo), creates a dedicated
# `clusev` host user, checks the domain's DNS, generates secrets, brings up the
# prod stack, migrates the schema, creates the first admin with a one-time
# random password, and installs a themed MOTD. Safe to re-run: existing secrets
# and the clusev user are preserved, never regenerated.
#
# The panel installs over IP/HTTP by default — set a domain (auto-TLS) later in the dashboard
# (System page). Power users can still preset one at install with CLUSEV_DOMAIN=.
#   sudo ./install.sh                 # interactive (prompts for HTTP port + admin email)
#   sudo CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh   # preset a domain
set -euo pipefail

cd "$(dirname "$0")"

COMPOSE="docker compose -f docker-compose.prod.yml"
ENV_FILE=".env"

# ── output helpers (German, no emoji) ────────────────────────────────
bold()  { printf '\033[1m%s\033[0m\n' "$*"; }
info()  { printf '  %s\n' "$*"; }
warn()  { printf '\033[33m  ! %s\033[0m\n' "$*"; }
die()   { printf '\033[31m  x %s\033[0m\n' "$*" >&2; exit 1; }
phase() { printf '\n\033[1m[%s] %s\033[0m\n' "$1" "$2"; }

# set_stage: publish the current macro-stage (fetch|build|restart|migrate|done) to a file the
# still-running app container serves to the browser's update-progress page, so it shows REAL
# progress instead of a time guess (run/ is bind-mounted into the container). Strictly best-effort —
# a write failure must NEVER abort the update, hence the guards + `return 0`.
STAGE_FILE="run/update-phase.json"
set_stage() {
  # Symlink-safe write (see docker/restart-sentinel/watch.sh run_write_json): run/ is the
  # container-writable bind mount, so update-phase.json could be pre-planted as a symlink and a plain
  # `> file` would let this root write (install.sh runs as root, incl. via update.sh) follow it out of
  # run/. Build the temp at the repo root (cwd — same fs as run/, not mounted into any container) and
  # rename(2) it over the target: the atomic same-fs rename replaces the link itself, never follows it.
  local tmp
  mkdir -p run 2>/dev/null || true
  tmp="$(mktemp ./.phase.XXXXXX 2>/dev/null)" || return 0
  printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
  chmod 644 "$tmp" 2>/dev/null || true
  mv -f "$tmp" "$STAGE_FILE" 2>/dev/null || rm -f "$tmp" 2>/dev/null
  return 0
}

# ── .env mutators ────────────────────────────────────────────────────
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
force_kv() {
  local key="$1" val esc
  # Strip stray CR/LF (a newline would split .env into two entries) and neutralise sed replacement
  # metacharacters (& | \) so an operator-supplied domain/email/port can neither corrupt .env nor
  # inject a sed command (values reach here unvalidated from CLUSEV_DOMAIN/ADMIN_EMAIL/HTTP_PORT).
  val="$(printf '%s' "$2" | tr -d '\r\n')"
  esc="$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g')"
  if grep -qE "^${key}=" "$ENV_FILE"; then
    sed -i "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE"
  else
    printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
  fi
}
set_kv() {
  local key="$1" val="$2" cur=""
  if grep -qE "^${key}=" "$ENV_FILE"; then
    cur="$(grep -E "^${key}=" "$ENV_FILE" | head -n1 | cut -d= -f2-)"
    case "$cur" in
      ""|null|changeme|change-me-strong|change-me-strong-root) force_kv "$key" "$val" ;;
      *) : ;;  # keep an existing real value — secrets are never regenerated
    esac
  else
    printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
  fi
}
rand_hex() { openssl rand -hex "${1:-32}"; }
# env_get: read a key's current value from .env (empty if the file or key is absent). Lets a
# re-run / update default to the EXISTING domain & ACME e-mail instead of wiping them.
env_get() { [ -f "$ENV_FILE" ] && grep -E "^${1}=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; }

# ── root + OS detection (before everything) ──────────────────────────
[ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./install.sh"

OS_ID=""; OS_LIKE=""; IS_APT=0
if [ -r /etc/os-release ]; then
  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
fi
case " ${OS_ID} ${OS_LIKE} " in
  *debian*|*ubuntu*) IS_APT=1 ;;
esac

# ── [1/9] preflight + Docker ─────────────────────────────────────────
phase 1/9 "Voraussetzungen pruefen"
command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden."

if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
  info "docker + compose vorhanden"
elif [ "$IS_APT" = 1 ]; then
  info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..."
  export DEBIAN_FRONTEND=noninteractive
  apt-get update
  apt-get install -y ca-certificates curl
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc
  chmod a+r /etc/apt/keyrings/docker.asc
  # shellcheck disable=SC1091
  codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
  printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
    "$(dpkg --print-architecture)" "$OS_ID" "$codename" \
    > /etc/apt/sources.list.d/docker.list
  apt-get update
  apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  systemctl enable --now docker
  docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)."
  info "Docker installiert + aktiviert"
else
  die "Docker manuell installieren: https://docs.docker.com/engine/install/ , dann erneut ausfuehren."
fi
info "openssl vorhanden"

# WireGuard gate (clusev wg ...) — present but inert until the operator runs `clusev wg setup`.
if [ "$IS_APT" = 1 ]; then
  if DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools qrencode >/dev/null 2>&1; then
    info "wireguard-tools + qrencode vorhanden"
  else
    warn "wireguard-tools/qrencode nicht installiert — 'clusev wg' erst nach 'apt-get install wireguard-tools qrencode' nutzbar."
  fi
fi

# ── [2/9] dedicated clusev user ──────────────────────────────────────
phase 2/9 "Benutzer clusev"
USER_CREATED=0
CLUSEV_USER_PW=""
if ! id -u clusev >/dev/null 2>&1; then
  useradd -m -s /bin/bash clusev
  CLUSEV_USER_PW="$(openssl rand -base64 18)"
  printf 'clusev:%s\n' "$CLUSEV_USER_PW" | chpasswd
  USER_CREATED=1
  info "Benutzer clusev angelegt"
else
  info "Benutzer clusev vorhanden — unveraendert"
fi
usermod -aG docker clusev
chown -R clusev:clusev .
info "Installationsverzeichnis gehoert clusev"

# ── inputs (interactive at a TTY, else env / bare-IP fallback) ───────
# Default to the EXISTING .env values so a non-interactive re-run (i.e. an update via
# update.sh) preserves the configured domain / ACME e-mail instead of clearing them. On a
# first install .env does not exist yet, so these resolve empty → bare-IP, unchanged.
DOMAIN="${CLUSEV_DOMAIN:-$(env_get APP_DOMAIN)}"
ADMIN_EMAIL="${CLUSEV_ADMIN_EMAIL:-$(env_get ACME_EMAIL)}"
HTTP_PORT="${CLUSEV_HTTP_PORT:-80}"
if [ -t 0 ]; then
  echo; bold "Clusev Installer"; echo "================"
  # The panel domain is NOT asked here — install over IP/HTTP, then set a domain later in the
  # dashboard (System page → automatic TLS). Power users can still pass CLUSEV_DOMAIN=… to preset it.
  if [ -z "$DOMAIN" ]; then
    printf '%s' "HTTP-Port (Standard 80):                   "; read -r in_port || true
    [ -n "${in_port:-}" ] && HTTP_PORT="$in_port"
  fi
  printf '%s' "Admin E-Mail (leer = admin@clusev.local):  "; read -r in_email || true
  [ -n "${in_email:-}" ] && ADMIN_EMAIL="$in_email"
fi

HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HOST_IP="127.0.0.1"
SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
[ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP"

# Reject a non-numeric / out-of-range HTTP port (an operator typo would otherwise reach compose as a
# bad ${APP_PORT} and fail cryptically). Fall back to 80.
case "$HTTP_PORT" in
  ''|*[!0-9]*) warn "HTTP-Port '${HTTP_PORT}' nicht numerisch — nutze 80"; HTTP_PORT=80 ;;
  *) if [ "$HTTP_PORT" -lt 1 ] || [ "$HTTP_PORT" -gt 65535 ]; then warn "HTTP-Port '${HTTP_PORT}' ausserhalb 1-65535 — nutze 80"; HTTP_PORT=80; fi ;;
esac

# ── domain DNS check (only when a domain is set) ─────────────────────
DOMAIN_OK=0
if [ -n "$DOMAIN" ]; then
  RESOLVED="$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
  case " $RESOLVED " in
    *" $SERVER_IP "*) DOMAIN_OK=1 ;;
  esac
  if [ "$DOMAIN_OK" = 1 ]; then
    info "DNS ok: ${DOMAIN} zeigt auf diesen Server (${SERVER_IP})"
  else
    warn "DNS-Pruefung: ${DOMAIN} aufgeloest auf [${RESOLVED:-<keine>}], dieser Server ist ${SERVER_IP}."
    if [ -t 0 ]; then
      echo "  [1] Domain trotzdem uebernehmen (Cert kommt automatisch, sobald DNS hierher zeigt; bis dahin nur HTTP)"
      echo "  [2] Per IP weitermachen (Domain verwerfen -> bare-IP)"
      printf '%s' "  Auswahl [1]: "; read -r dns_choice || true
      case "${dns_choice:-1}" in
        2) DOMAIN=""; warn "Domain verworfen — Installation per IP." ;;
        *) info "Domain uebernommen — TLS folgt automatisch, sobald DNS stimmt." ;;
      esac
    else
      info "Nicht-interaktiv: Domain wird uebernommen (TLS folgt automatisch, sobald DNS stimmt)."
    fi
  fi
fi

# derive every proxy/url var from the single APP_DOMAIN knob
if [ -n "$DOMAIN" ]; then
  [ -n "$ADMIN_EMAIL" ] || die "Bei gesetzter Domain ist eine Admin E-Mail fuer Let's Encrypt Pflicht."
  APP_SCHEME="https"; APP_PORT="$HTTP_PORT"
  SITE_ADDRESS="https://${DOMAIN}"
  APP_URL="https://${DOMAIN}"
  REVERB_HOST="$DOMAIN"; REVERB_PORT="443"; REVERB_SCHEME="https"
  case "$HOST_IP" in
    10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|127.*)
      warn "Host-IP ${HOST_IP} ist privat (RFC1918). Let's Encrypt braucht oeffentlich erreichbare 80/443 — sonst schlaegt das Zertifikat fehl (dann DNS-01-Build noetig)." ;;
  esac
else
  APP_SCHEME="http"; APP_PORT="$HTTP_PORT"
  SITE_ADDRESS=":${HTTP_PORT}"
  if [ "$HTTP_PORT" = "80" ]; then APP_URL="http://${HOST_IP}"; else APP_URL="http://${HOST_IP}:${HTTP_PORT}"; fi
  REVERB_HOST="$HOST_IP"; REVERB_PORT="$HTTP_PORT"; REVERB_SCHEME="http"
  warn "Bare-IP-Modus: Panel inkl. 2FA/Audit laeuft ueber KLARTEXT-HTTP. Fuer Produktion spaeter im Dashboard (System) eine Domain setzen — TLS kommt automatisch."
fi

# ── [3/9] .env + secrets (idempotent) ────────────────────────────────
phase 3/9 "Secrets generieren"
if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt";
else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi
# .env holds EVERY secret: DB/root/redis passwords, APP_KEY (decrypts the whole fleet's SSH-credential
# vault + 2FA secrets), the terminal sidecar secret and the update HMAC key. Never leave it
# world-readable. Own it by the clusev user (the tree owner) and lock it to 0600 BEFORE any secret is
# written; the subsequent sed -i / >> writes preserve this mode.
chown clusev:clusev "$ENV_FILE" 2>/dev/null || true
chmod 600 "$ENV_FILE"

set_kv DB_PASSWORD       "$(rand_hex 24)"
set_kv DB_ROOT_PASSWORD  "$(rand_hex 24)"
set_kv REDIS_PASSWORD    "$(rand_hex 24)"
set_kv REVERB_APP_ID     "$(rand_hex 8)"
set_kv REVERB_APP_KEY    "$(rand_hex 16)"
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
set_kv UPDATE_HMAC_KEY   "$(rand_hex 32)"
set_kv TERMINAL_SIDECAR_SECRET "$(rand_hex 24)"
# Per-install RANDOM honeypot canary values. Seeded into the served fake .env; if any reappears
# in a later request it PROVES an attacker took the bait -> instant ban (DetectHoneytoken).
set_kv CLUSEV_HONEYPOT_CANARY_APP "base64:$(openssl rand -base64 24)"
set_kv CLUSEV_HONEYPOT_CANARY_DB "$(rand_hex 20)"
set_kv CLUSEV_HONEYPOT_CANARY_API "$(rand_hex 24)"

force_kv APP_ENV       production
force_kv APP_DEBUG     false
force_kv APP_URL       "$APP_URL"
force_kv APP_DOMAIN    "$DOMAIN"
force_kv APP_SCHEME    "$APP_SCHEME"
force_kv APP_PORT      "$APP_PORT"
force_kv ACME_EMAIL    "$ADMIN_EMAIL"
force_kv SITE_ADDRESS  "$SITE_ADDRESS"
force_kv REVERB_HOST   "$REVERB_HOST"
force_kv REVERB_PORT   "$REVERB_PORT"
force_kv REVERB_SCHEME "$REVERB_SCHEME"
# Session hardening (control-plane self-security): strict same-site + end on
# browser close everywhere; secure cookie ONLY behind TLS (else the cookie is
# never returned over plain HTTP and login silently breaks).
force_kv SESSION_SAME_SITE       strict
force_kv SESSION_EXPIRE_ON_CLOSE true
if [ -n "$DOMAIN" ]; then force_kv SESSION_SECURE_COOKIE true; else force_kv SESSION_SECURE_COOKIE false; fi
# Containers run as the dedicated clusev user (it owns the install dir + ./run).
force_kv HOST_UID      "$(id -u clusev)"
force_kv HOST_GID      "$(id -g clusev)"
# Bake the deployed commit + branch into .env so the dashboard's Version page can show them in
# production. The prod image ships NO .git (dockerignored), so a runtime .git read is impossible
# there; we capture it here on the HOST (where .git exists) and config:cache freezes it later.
git config --global --add safe.directory "$(pwd)" 2>/dev/null || true
force_kv CLUSEV_BUILD_SHA    "$(git rev-parse --short=7 HEAD 2>/dev/null || true)"
force_kv CLUSEV_BUILD_BRANCH "$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
# Display times in the HOST's timezone (dashboard "geprüft"-Zeit etc.) instead of UTC. DB stays
# UTC. Detect via systemd, then /etc/timezone, then the /etc/localtime symlink; default UTC.
HOST_TZ="$(timedatectl show -p Timezone --value 2>/dev/null || true)"
[ -n "$HOST_TZ" ] || HOST_TZ="$(cat /etc/timezone 2>/dev/null || true)"
[ -n "$HOST_TZ" ] || HOST_TZ="$(readlink -f /etc/localtime 2>/dev/null | sed -n 's#.*/zoneinfo/##p')"
[ -n "$HOST_TZ" ] || HOST_TZ="UTC"
force_kv APP_TIMEZONE "$HOST_TZ"
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet; Zeitzone ${HOST_TZ}"

# Pin the update source to wherever this was cloned from (keeps private URLs out of tracked files).
[ -x "$(pwd)/scripts/set-repository-url.sh" ] && "$(pwd)/scripts/set-repository-url.sh" "$(pwd)" "$(pwd)/.env" || true

# ── [4/9] image ──────────────────────────────────────────────────────
phase 4/9 "Image bauen"
set_stage build
# Resolve the prod image source: a public install pulls the exact promoted digests from
# release-images.lock, the dev/source tree builds, and staging respects the env-provided pin. The
# resolver always leaves a coherent pin (a build never inherits a stale digest); persist it to .env
# with force_kv so `$COMPOSE up/migrate` here AND any later manual/host-triggered compose use it too.
# shellcheck source=scripts/release-images.sh disable=SC1091
. ./scripts/release-images.sh
clusev_resolve_release_images "release-images.lock"
force_kv CLUSEV_IMAGE          "$CLUSEV_IMAGE"
force_kv CLUSEV_TERMINAL_IMAGE "$CLUSEV_TERMINAL_IMAGE"
case "${CLUSEV_RELEASE_MODE:-build}" in
  env)  info "Image aus Umgebung vorgegeben (Pull)" ;;
  pull) info "Release-Images aus release-images.lock (Pull statt Build)" ;;
  *)    info "Lokaler Build" ;;
esac
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi
info "Image bereit"

cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)"
case "$cur_key" in
  ""|base64:)
    NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)"
    [ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden."
    force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;;
  *) info "APP_KEY vorhanden" ;;
esac

# ── [5/9] stack ──────────────────────────────────────────────────────
phase 5/9 "Stack starten"
set_stage restart
$COMPOSE up -d
# Caddyfile changes ship as edits to a bind-mounted file; `up -d` does NOT recreate caddy for a
# content-only change, so force it to re-read the (possibly updated) Caddyfile on every deploy.
$COMPOSE up -d --force-recreate caddy >/dev/null 2>&1 || true
info "Container gestartet"

# ── host watchers (restart + update sentinels) — idempotent, best-effort ────
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and
# "Jetzt aktualisieren" buttons work (they write ./run/{restart,update}.request; the
# host units act on them). The container never gets the Docker socket. Skipped (with a
# hint) when systemd is unavailable — see docker/restart-sentinel/README.md. The restart
# unit runs as the `clusev` user (docker group); the update unit runs as ROOT, because
# update.sh -> install.sh needs root for docker/systemd/apt.
install_host_watchers() {
  local src="docker/restart-sentinel" dst="/etc/systemd/system" proj
  proj="$(pwd)"
  if ! command -v systemctl >/dev/null 2>&1; then
    warn "systemd nicht gefunden — Host-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0
  fi
  # The container's ./run bind mount is owned by clusev; let clusev own it host-side too.
  mkdir -p ./run
  chown -R clusev:clusev ./run
  # Render the units with THIS project's path (the repo units default to /home/nexxo/clusev).
  local tmp_path tmp_svc tmp_upath tmp_usvc
  tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"; tmp_upath="$(mktemp)"; tmp_usvc="$(mktemp)"
  local tmp_gate; tmp_gate="$(mktemp)"
  local tmp_csvc tmp_ctimer; tmp_csvc="$(mktemp)"; tmp_ctimer="$(mktemp)"
  local tmp_rsvc tmp_rpath; tmp_rsvc="$(mktemp)"; tmp_rpath="$(mktemp)"
  # Restart units run as the clusev user.
  sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path"
  sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=clusev/" "${src}/clusev-restart.service" > "$tmp_svc"
  # Update units keep User=root (only the path is rewritten).
  sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.path" > "$tmp_upath"
  sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.service" > "$tmp_usvc"
  # Gate unit keeps User=root; only the path is rewritten.
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate"
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.service" > "$tmp_csvc"
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.timer"   > "$tmp_ctimer"
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.service" > "$tmp_rsvc"
  sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.path"    > "$tmp_rpath"
  if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
     && install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
     && install -m 0644 "$tmp_upath" "${dst}/clusev-update.path" \
     && install -m 0644 "$tmp_usvc" "${dst}/clusev-update.service" \
     && install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \
     && install -m 0644 "$tmp_csvc"   "${dst}/clusev-wg-collect.service" \
     && install -m 0644 "$tmp_ctimer" "${dst}/clusev-wg-collect.timer" \
     && install -m 0644 "$tmp_rsvc"  "${dst}/clusev-wg-request.service" \
     && install -m 0644 "$tmp_rpath" "${dst}/clusev-wg-request.path" \
     && systemctl daemon-reload \
     && systemctl enable --now clusev-restart.path \
     && systemctl enable --now clusev-update.path \
     && systemctl enable clusev-wg-gate.service \
     && systemctl enable --now clusev-wg-collect.timer \
     && systemctl enable --now clusev-wg-request.path; then
    info "Host-Watcher aktiv (clusev-restart.path User=clusev, clusev-update.path User=root)"
  else
    warn "Host-Watcher konnten nicht installiert werden — siehe ${src}/README.md."
  fi
  rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate" "$tmp_csvc" "$tmp_ctimer" "$tmp_rsvc" "$tmp_rpath"

  # Release bridge units — DEV ONLY. Installed only when CLUSEV_RELEASE_CONTROLS=true is set in .env,
  # so customer/staging installs never get release controls or a host git-push watcher.
  if grep -qE '^CLUSEV_RELEASE_CONTROLS=true$' "${proj}/.env" 2>/dev/null; then
    local tmp_relsvc tmp_relpath; tmp_relsvc="$(mktemp)"; tmp_relpath="$(mktemp)"
    sed "s#/home/nexxo/clusev#${proj}#g" "docker/release/clusev-release-request.service" > "$tmp_relsvc"
    sed "s#/home/nexxo/clusev#${proj}#g" "docker/release/clusev-release-request.path"    > "$tmp_relpath"
    # Best-effort like the WG/restart block above — a systemctl failure must not abort the installer.
    if install -m 0644 "$tmp_relsvc"  "${dst}/clusev-release-request.service" \
       && install -m 0644 "$tmp_relpath" "${dst}/clusev-release-request.path" \
       && systemctl daemon-reload \
       && systemctl enable --now clusev-release-request.path; then
      info "Release-Watcher aktiv (clusev-release-request.path, DEV)"
    else
      warn "Release-Watcher konnte nicht installiert werden — siehe docker/release/README.md."
    fi
    rm -f "$tmp_relsvc" "$tmp_relpath"
  fi
}
install_host_watchers

# ── [6/9] wait for the database ──────────────────────────────────────
phase 6/9 "Datenbank bereit"
db_ready=0
for _ in $(seq 1 60); do
  if $COMPOSE exec -T mariadb healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then
    db_ready=1; info "MariaDB healthy"; break
  fi
  sleep 2
done
[ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit."

# ── [7/9] migrate + caches ───────────────────────────────────────────
phase 7/9 "Migrationen"
set_stage migrate
$COMPOSE exec -T -u app app php artisan migrate --force
$COMPOSE exec -T -u app app php artisan config:cache >/dev/null
$COMPOSE exec -T -u app app php artisan route:cache  >/dev/null
info "Schema migriert; Caches gebaut"

# ── [8/9] first admin ────────────────────────────────────────────────
phase 8/9 "Admin anlegen"
email_args=()
[ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL")
INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)"
ADMIN_PW="$(printf '%s\n'   "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)"
ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p'    | head -n1)"
[ -n "$ADMIN_MAIL" ] || ADMIN_MAIL="$ADMIN_EMAIL"

# ── [9/9] Host-CLI + MOTD ────────────────────────────────────────────
phase 9/9 "Host-CLI + MOTD installieren"

# clusev host command: bake the install dir into the template, drop it in PATH. The path is
# printf %q shell-escaped before substitution so a checkout path containing shell syntax cannot
# inject code into the generated CLI (which later runs as root via `sudo clusev update`). Literal
# (not sed) substitution; best-effort with the whole group's stderr muted so a read-only
# /usr/local/bin never fails the installer (no stray "Permission denied").
cli_template="$(cat docker/clusev/clusev)"
cli_dir="$(printf '%q' "$(pwd)")"
cli_rendered="${cli_template//__CLUSEV_DIR__/$cli_dir}"
if { printf '%s\n' "$cli_rendered" > /usr/local/bin/clusev && chmod 0755 /usr/local/bin/clusev; } 2>/dev/null; then
  info "Host-Befehl installiert: clusev (z. B. 'clusev ps', 'sudo clusev update')"
else
  info "Host-Befehl uebersprungen (/usr/local/bin nicht beschreibbar)"
fi
if [ -n "$DOMAIN" ] && [ "$DOMAIN_OK" = 1 ]; then
  ACCESS_URL="https://${DOMAIN}"
elif [ -n "$DOMAIN" ]; then
  # Domain taken but DNS not yet pointing here — until then it's HTTP on the IP.
  ACCESS_URL="https://${DOMAIN}"
else
  if [ "$HTTP_PORT" = "80" ]; then ACCESS_URL="http://${SERVER_IP}"; else ACCESS_URL="http://${SERVER_IP}:${HTTP_PORT}"; fi
fi
# Version (for the MOTD header) and the absolute compose path (the MOTD shows a LIVE
# stack status via `docker compose ps` and runs from any cwd at login, so it needs the
# full path). cwd is the repo root (cd at the top of this script).
MOTD_VERSION="$(grep -oE "'version' => '[^']+'" config/clusev.php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
MOTD_COMPOSE="$(pwd)/docker-compose.prod.yml"
motd_render() {
  sed -e "s|__CLUSEV_URL__|${ACCESS_URL}|" \
      -e "s|__CLUSEV_VERSION__|${MOTD_VERSION}|" \
      -e "s|__CLUSEV_COMPOSE__|${MOTD_COMPOSE}|" docker/motd/00-clusev
}
if [ -d /etc/update-motd.d ]; then
  motd_render > /etc/update-motd.d/00-clusev
  chmod +x /etc/update-motd.d/00-clusev
  info "Dynamisches MOTD installiert (/etc/update-motd.d/00-clusev)"
else
  motd_render | bash > /etc/motd 2>/dev/null || true
  info "Statisches MOTD geschrieben (/etc/motd)"
fi

# Update fully applied — tell the in-browser progress page it may finish (real completion signal).
set_stage "done"

# ── closing banner (passwords shown ONLY here) ───────────────────────
echo
bold "Installation erfolgreich."
echo
info "+----------------------------------------------------------+"
info "  Dashboard:    ${ACCESS_URL}"
info "  Admin-Login:  ${ADMIN_MAIL:-<bestehend>}"
if [ -n "$ADMIN_PW" ]; then
  info "  Admin-Passwort: ${ADMIN_PW}  (generiert — JETZT notieren, wird nur einmal angezeigt!)"
else
  info "  Admin-Passwort: <bereits installiert — kein neuer Admin angelegt>"
fi
if [ "$USER_CREATED" = 1 ]; then
  info "  Host-Benutzer:  clusev"
  info "  Host-Passwort:  ${CLUSEV_USER_PW}"
fi
info "+----------------------------------------------------------+"
if [ -z "$DOMAIN" ]; then
  info "Domain + HTTPS spaeter im Dashboard unter System setzen — TLS kommt automatisch."
fi
info "Diese Passwoerter werden nur jetzt angezeigt."
