#!/bin/bash
# --- ProxmoxVEx auto-header start ---
# --------------------------------------------------------------------
# File:        install.sh
# Project:     ProxmoxVEx
# Version:     1.2.662
# Build:       2026.10.21
# Description: Install SH source
# Usage:       bash install.sh
# Docs:        https://proxmoxvex.com/docs
# Generated:   2026-10-21
# --------------------------------------------------------------------
# --- ProxmoxVEx auto-header end ---
set -euo pipefail

# ============================================================================
# Configuration — overridable via environment or flags
# ============================================================================
INSTALL_DIR="${PROXMOXVEX_INSTALL_DIR:-/opt/ProxmoxVEx}"
SERVICE_USER="ProxmoxVEx"
ENV_DIR="/etc/ProxmoxVEx"
ENV_FILE="$ENV_DIR/ProxmoxVEx.env"
ACCESS_PORT="${PROXMOXVEX_PORT:-5000}"
ARCHIVE_BASE="https://proxmoxvex.com/downloads"
RELEASE_ARCHIVE="${PROXMOXVEX_ARCHIVE:-$ARCHIVE_BASE/ProxmoxVEx-latest.tar.gz}"
DB_NAME="proxmoxvex"
DB_USER="proxmoxvex"
# DB_MODE: local installs + initializes PostgreSQL on this host; external uses
# the supplied DSN and skips the database prerequisite entirely. The TUI asks
# this up front; PROXMOXVEX_DATABASE_URL pre-answers it.
DB_DSN="${PROXMOXVEX_DATABASE_URL:-}"
DB_MODE="local"
[ -n "$DB_DSN" ] && DB_MODE="external"
INTERACTIVE=true
DOWNLOAD_OFFLINE=true
CONFIGURE_FIREWALL=true
BIND_ALL=true   # management UI: bind all interfaces by default; env file documents it
# NO_TUI (--no-tui or PROXMOXVEX_NO_TUI=1) forces classic line output even on
# terminals where the dialog UI would normally run.
NO_TUI="${PROXMOXVEX_NO_TUI:-false}"
# PYTHON_BIN: interpreter used for the version check and the venv. The TUI
# dependency resolver may point this at a user-supplied path; PROXMOXVEX_PYTHON
# does the same for scripted installs.
PYTHON_BIN="${PROXMOXVEX_PYTHON:-python3}"
# SYSTEM_UPDATE: whether to offer pending OS updates before installing.
# ask (default) prompts on interactive runs; 1/yes applies them unattended;
# 0/no never offers. Non-interactive runs skip unless explicitly opted in —
# a piped one-liner shouldn't surprise-upgrade the whole box.
SYSTEM_UPDATE="${PROXMOXVEX_SYSTEM_UPDATE:-ask}"
# PORT_VIA_ARG: --port or PROXMOXVEX_PORT already answered the port question.
PORT_VIA_ARG=false
[ -n "${PROXMOXVEX_PORT:-}" ] && PORT_VIA_ARG=true

for arg in "$@"; do
    case "$arg" in
        --port=*)            ACCESS_PORT="${arg#*=}"; PORT_VIA_ARG=true ;;
        --no-interactive)    INTERACTIVE=false ;;
        --no-tui)            NO_TUI=true ;;
        --no-offline)        DOWNLOAD_OFFLINE=false ;;
        --no-firewall)       CONFIGURE_FIREWALL=false ;;
        --loopback-only)     BIND_ALL=false ;;
        --help|-h)
            cat <<'HELP'
ProxmoxVEx one-command installer

Usage: sudo bash install.sh [options]
       curl -fsSL https://proxmoxvex.com/downloads/install.sh | sudo bash -s -- [options]

One-liner including the curl bootstrap (Debian/Ubuntu — use dnf/pacman
equivalents elsewhere):
       apt update && apt install -y curl && \
       curl -fsSL https://proxmoxvex.com/downloads/install.sh | sudo bash

Options:
  --port=PORT        Web UI port (default 5000; use 443 for clean HTTPS)
  --no-interactive   Never prompt; accept all defaults
  --no-tui           Use classic line output instead of the full-screen UI
  --no-offline       Skip downloading static assets for offline mode
  --no-firewall      Do not touch ufw/firewalld rules
  --loopback-only    Bind to 127.0.0.1 only (behind a reverse proxy)

Environment:
  PROXMOXVEX_DATABASE_URL   Use an existing PostgreSQL instead of installing one
  PROXMOXVEX_ARCHIVE        Override the release archive URL
  PROXMOXVEX_INSTALL_DIR    Install location (default /opt/ProxmoxVEx)
  PROXMOXVEX_PORT           Web UI port (same as --port)
  PROXMOXVEX_PYTHON         Interpreter used to build the venv
  PROXMOXVEX_NO_TUI=1       Force classic line output
  PROXMOXVEX_SYSTEM_UPDATE  ask (default) | 1 = apply pending OS updates
                            unattended | 0 = never offer them
HELP
            exit 0
            ;;
    esac
done

# ============================================================================
# Output helpers
# ============================================================================
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
step()  { echo -e "\n${BLUE}════════════════════════════════════════════════════════════${NC}\n${BOLD}$1${NC}\n${BLUE}════════════════════════════════════════════════════════════${NC}\n"; }
ok()    { echo -e "${GREEN}✓${NC} $1"; }
info()  { echo -e "${CYAN}ℹ${NC} $1"; }
warn()  { echo -e "${YELLOW}⚠${NC} $1"; }
die()   { echo -e "${RED}✗ $1${NC}" >&2; exit 1; }

# ============================================================================
# run_step — long quiet commands with a live heartbeat
# Sep 2026 — several install steps used to run fully silenced (apt-get,
# initdb, pip, --download-static). During the PostgreSQL package install the
# debconf config script prints a harmless "pg_lsclusters: not found" notice
# (it runs before postgresql-common is unpacked), after which apt sits silent
# for a minute while the cluster initialises — the install looked frozen.
# Now each long step shows a counting elapsed-time heartbeat, captures output
# to a temp log (shown verbatim only on failure), and gets /dev/null on stdin
# so a `curl | bash` run can't have a child process eat the script stream.
# ============================================================================
run_step() {
    local desc="$1"; shift
    local log pid secs=0 rc=0 noted=""
    log=$(mktemp)
    "$@" >"$log" 2>&1 </dev/null &
    pid=$!
    while kill -0 "$pid" 2>/dev/null; do
        if [ -t 1 ]; then
            printf "\r  ${CYAN}…${NC} %s ${BOLD}(%ds)${NC}   " "$desc" "$secs"
        elif [ -z "$noted" ]; then
            # Non-tty output (log capture): emit one plain line, not a spinner.
            printf "  … %s\n" "$desc"; noted=1
        fi
        if [ "$secs" -eq 90 ] && [ -z "$noted" ]; then
            printf "\n  ${YELLOW}still running — a few minutes is normal for database init and asset downloads${NC}\n"
            noted=1
        fi
        sleep 1; secs=$((secs+1))
    done
    wait "$pid" || rc=$?
    # `|| true`: on non-tty output [ -t 1 ] fails — must not trip `set -e`.
    [ -t 1 ] && printf "\r  \033[K" || true
    if [ "$rc" -ne 0 ]; then
        cat "$log" >&2
    fi
    rm -f "$log"
    return "$rc"
}

# ============================================================================
# SSH Terminal UI (dialog)
# Sep 2026 — on a real terminal (SSH session or console) the installer runs
# through a full-screen `dialog` UI: welcome screen, all questions collected
# up front, a live scrolling output window during the install, and a closing
# summary. Works under `curl | sudo bash` too — widgets read keystrokes from
# /dev/tty, not stdin. Non-interactive runs, missing/tiny terminals, and
# hosts where `dialog` can't be installed fall back to the classic output.
# ============================================================================
TUI=false
TUI_LOG="/var/log/proxmoxvex-install.log"
TUI_BACKTITLE="ProxmoxVEx Installer — https://proxmoxvex.com"
TUI_PROG_H=20
TUI_PROG_W=76

# dlg: invoke a dialog widget with the shared backtitle. stdin is forced to
# /dev/tty so interactive widgets receive keystrokes even when the script
# itself was piped in via curl. The 3>&1 1>&2 2>&3 swap is the standard dialog
# capture idiom: the curses UI is drawn on stdout (→ stderr → terminal) while
# the widget's answer is emitted on stderr (→ stdout → $(...) captures it).
dlg() {
    dialog --backtitle "$TUI_BACKTITLE" "$@" 3>&1 1>&2 2>&3 </dev/tty
}

# _pkg_update / _pkg_install: package-manager abstraction — the dep scan, the
# guided installs and step 1 all go through these so nothing hard-codes apt.
_pkg_update() {
    case "$PKG_MGR" in
        apt)    env DEBIAN_FRONTEND=noninteractive apt-get update -qq ;;
        dnf)    : ;;
        pacman) pacman -Sy --noconfirm ;;
    esac
}

_pkg_install() {
    case "$PKG_MGR" in
        apt)    env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" ;;
        dnf)    dnf install -y -q "$@" ;;
        pacman) pacman -S --noconfirm --needed "$@" ;;
    esac
}

# setup_tui: decide whether the dialog UI can run, installing the small
# `dialog` package via the detected package manager if needed. Called after
# the root + distro + connectivity checks so a package manager exists.
setup_tui() {
    TUI=false
    [ "$INTERACTIVE" = true ] || return 0
    case "$NO_TUI" in 1|true|yes|on) return 0 ;; esac
    case "${TERM:-}" in ""|dumb|unknown) return 0 ;; esac

    # A controlling terminal is required for the widgets. This also succeeds
    # under `curl | sudo bash`: stdin is the pipe there, but /dev/tty still
    # belongs to the SSH session.
    (exec </dev/tty) 2>/dev/null || return 0

    if ! command -v dialog >/dev/null 2>&1; then
        info "Installing 'dialog' for the installer UI..."
        _pkg_update >/dev/null 2>&1 || true
        _pkg_install dialog >/dev/null 2>&1 || true
    fi
    if ! command -v dialog >/dev/null 2>&1; then
        warn "'dialog' not available — using classic output"
        return 0
    fi

    # dialog needs a sane window to draw in; anything smaller just errors out.
    local rows cols
    read -r rows cols < <(stty size </dev/tty 2>/dev/null || echo "0 0")
    if [ "${cols:-0}" -lt 70 ] || [ "${rows:-0}" -lt 20 ]; then
        warn "Terminal too small for the installer UI — using classic output"
        return 0
    fi

    TUI=true
}

# ============================================================================
# Dependency detection & guided setup
# Before touching the system, scan every prerequisite (package manager is
# already known-good by this point): interpreter + version, venv/pip modules,
# system tools, PostgreSQL (unless an external DSN was chosen) and disk space.
# Missing pieces are installed step by step; the only prompt is where an
# answer genuinely can't be derived — e.g. which Python interpreter to use.
# Details collected here (interpreter, DSN, port, options) feed straight into
# the application configuration — nothing is asked twice.
# ============================================================================
DEP_MISSING_PKGS=()
DEP_MISSING_LABELS=()
DEP_PY_STATE=""        # ok | missing | old | new | broken
DEP_PY_VER=""
DEP_PY_VENV_BAD=false  # interpreter present but lacks venv/ensurepip
DEP_DISK_FREE_MB=0
DEP_DISK_OK=true
DEP_DISK_MIN_MB=400

_dep_need() {
    DEP_MISSING_LABELS+=("$1")
    DEP_MISSING_PKGS+=("$2")
}

# _dep_pkg_for: command name → package name for the detected manager.
_dep_pkg_for() {
    case "$PKG_MGR:$1" in
        pacman:python3) echo python ;;
        apt:ssh)        echo openssh-client ;;
        dnf:ssh)        echo openssh-clients ;;
        pacman:ssh)     echo openssh ;;
        *)              echo "$1" ;;
    esac
}

# dep_collect_missing: populate DEP_MISSING_PKGS/LABELS with the packages
# still needed. Pure detection — no side effects — so the report, the guided
# install and the post-install re-scan share one source of truth.
dep_collect_missing() {
    DEP_MISSING_PKGS=()
    DEP_MISSING_LABELS=()
    DEP_PY_VENV_BAD=false

    if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
        # A missing distro interpreter maps to packages; a missing custom
        # path can't be fixed by the package manager — the resolver handles it.
        if [ "$PYTHON_BIN" = "python3" ]; then
            _dep_need "Python 3 interpreter" "$(_dep_pkg_for python3)"
            [ "$PKG_MGR" = apt ] && { _dep_need "Python pip module" python3-pip; _dep_need "Python venv module" python3-venv; }
            [ "$PKG_MGR" = dnf ] && _dep_need "Python pip module" python3-pip
            [ "$PKG_MGR" = pacman ] && _dep_need "Python pip module" python-pip
        fi
    elif [ "$PYTHON_BIN" = "python3" ]; then
        "$PYTHON_BIN" -m pip --version >/dev/null 2>&1 || _dep_need "Python pip module" "$([ "$PKG_MGR" = pacman ] && echo python-pip || echo python3-pip)"
        if [ "$PKG_MGR" = apt ]; then
            "$PYTHON_BIN" -c 'import venv, ensurepip' >/dev/null 2>&1 || _dep_need "Python venv module" python3-venv
        fi
    else
        # Custom interpreter: packages can't supply its modules — flag instead.
        "$PYTHON_BIN" -c 'import venv, ensurepip' >/dev/null 2>&1 || DEP_PY_VENV_BAD=true
    fi

    local cmd
    for cmd in curl wget openssl ssh sudo; do
        command -v "$cmd" >/dev/null 2>&1 || _dep_need "$cmd" "$(_dep_pkg_for "$cmd")"
    done
    # sshpass needs EPEL on dnf — kept as a best-effort step in run_install
    # rather than a hard prerequisite here.
    if [ "$PKG_MGR" != dnf ]; then
        command -v sshpass >/dev/null 2>&1 || _dep_need "sshpass" sshpass
    fi
    [ -d /etc/ssl/certs ] || _dep_need "CA certificates" ca-certificates

    # Local PostgreSQL is a prerequisite only in local mode — an external DSN
    # means the database already exists elsewhere.
    if [ "$DB_MODE" = local ] && ! command -v psql >/dev/null 2>&1; then
        case "$PKG_MGR" in
            dnf) _dep_need "PostgreSQL server" postgresql-server
                 _dep_need "PostgreSQL client" postgresql ;;
            *)   _dep_need "PostgreSQL server" postgresql ;;
        esac
    fi
}

# dep_python_state: classify $PYTHON_BIN into DEP_PY_STATE + DEP_PY_VER.
dep_python_state() {
    DEP_PY_STATE="broken"
    DEP_PY_VER=""
    command -v "$PYTHON_BIN" >/dev/null 2>&1 || { DEP_PY_STATE="missing"; return 0; }
    DEP_PY_VER=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
    [ -n "$DEP_PY_VER" ] || return 0
    local maj=${DEP_PY_VER%%.*} min=${DEP_PY_VER#*.}
    case "$min" in ''|*[!0-9]*) return 0 ;; esac
    if [ "$maj" != "3" ] || [ "$min" -lt 10 ]; then
        DEP_PY_STATE="old"
    elif [ "$min" -ge 14 ]; then
        DEP_PY_STATE="new"
    else
        DEP_PY_STATE="ok"
    fi
}

# dep_disk_check: venv + source + offline assets need a few hundred MB.
dep_disk_check() {
    DEP_DISK_FREE_MB=$(df -Pm /opt 2>/dev/null | awk 'NR==2{print $4}')
    case "$DEP_DISK_FREE_MB" in ''|*[!0-9]*)
        DEP_DISK_FREE_MB=$(df -Pm / 2>/dev/null | awk 'NR==2{print $4}')
        ;;
    esac
    case "$DEP_DISK_FREE_MB" in ''|*[!0-9]*) DEP_DISK_FREE_MB=0 ;; esac
    [ "$DEP_DISK_FREE_MB" -ge "$DEP_DISK_MIN_MB" ] && DEP_DISK_OK=true || DEP_DISK_OK=false
}

# check_connectivity: probe curl first — ICMP echo is filtered on plenty of
# networks, and minimal images often ship curl but not iputils-ping.
# Returns 0=online, 1=offline, 2=cannot determine (no probe tool installed).
check_connectivity() {
    local curl_ok=false ping_ok=false
    command -v curl >/dev/null 2>&1 && curl_ok=true
    command -v ping >/dev/null 2>&1 && ping_ok=true
    if [ "$curl_ok" = true ]; then
        curl -fsS -m 8 -o /dev/null https://proxmoxvex.com 2>/dev/null && return 0
    fi
    if [ "$ping_ok" = true ]; then
        ping -c 1 -W 3 proxmoxvex.com >/dev/null 2>&1 && return 0
    fi
    [ "$curl_ok" = false ] && [ "$ping_ok" = false ] && return 2
    return 1
}

# dep_updates_pending: count pending OS updates for the detected package
# manager. A quiet metadata refresh runs first so a fresh box that has never
# run `apt-get update`/`pacman -Sy` still reports a real number — the refresh
# is harmless and the installer does it anyway before package installs.
dep_updates_pending() {
    local n=0
    case "$PKG_MGR" in
        apt)
            env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
            n=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst ' || true)
            ;;
        dnf)
            n=$(dnf -q check-update 2>/dev/null | grep -c '^\S' || true)
            ;;
        pacman)
            pacman -Sy --noconfirm >/dev/null 2>&1 || true
            n=$(pacman -Qu 2>/dev/null | grep -c . || true)
            ;;
    esac
    echo "${n:-0}"
}

# _sys_upgrade: apply pending OS updates. apt uses plain `upgrade` (never
# removes packages — safest non-interactive choice); pacman's -Su relies on
# the -Sy refresh already done by dep_updates_pending.
_sys_upgrade() {
    case "$PKG_MGR" in
        apt)    env DEBIAN_FRONTEND=noninteractive apt-get upgrade -y ;;
        dnf)    dnf upgrade -y ;;
        pacman) pacman -Su --noconfirm ;;
    esac
}

# system_update_wanted: resolve SYSTEM_UPDATE (ask/1/0) + pending count into
# a yes/no decision. Prints the pending count on stdout for the caller's
# message; return 0 = offer/apply, 1 = skip.
system_update_check() {
    case "$SYSTEM_UPDATE" in
        0|no|false|off|never) UPDATES_PENDING=0; return 1 ;;
    esac
    UPDATES_PENDING=$(dep_updates_pending)
    [ "${UPDATES_PENDING:-0}" -gt 0 ] 2>/dev/null
}

# dep_step: run one dependency step inside a titled live-output window so the
# operator watches each prerequisite being resolved instead of a text wall.
dep_step() {
    local title="$1" desc="$2"
    shift 2
    "$@" 2>&1 | tee -a "$TUI_LOG" | \
        dialog --backtitle "$TUI_BACKTITLE" --title "Dependencies — $title" \
            --progressbox "$desc" 14 "$TUI_PROG_W"
    return "${PIPESTATUS[0]}"
}

# tui_cancel: clean exit when the operator backs out of a pre-install screen.
tui_cancel() {
    clear
    echo "Installation cancelled."
    exit 0
}

# tui_hard_fail: unrecoverable prerequisite failure.
tui_hard_fail() {
    clear
    echo -e "${RED}✗ Installation aborted — see $TUI_LOG for details${NC}"
    exit 1
}

# tui_welcome: splash screen shown before the first question.
tui_welcome() {
    dlg --title "Welcome" --msgbox \
        "Welcome to the ProxmoxVEx installer.\n\nThis wizard checks prerequisites, installs missing\ndependencies, and sets up the ProxmoxVEx cluster\nmanagement platform as a systemd service.\n\nPress ENTER to continue, or ESC to cancel." \
        14 64 || tui_cancel
}

# tui_ask_db: local PostgreSQL vs an external DSN. Asked before the dependency
# report because the answer decides whether postgresql is a prerequisite.
tui_ask_db() {
    [ -n "${PROXMOXVEX_DATABASE_URL:-}" ] && return 0  # pre-answered via env
    local choice dsn
    while true; do
        choice=$(dlg --title "Database" --menu \
            "ProxmoxVEx stores its data in PostgreSQL.\n\nWhere should the database live?" \
            14 66 2 \
            "local"    "Install PostgreSQL on this host (recommended)" \
            "external" "Use an existing PostgreSQL (provide a DSN)") || tui_cancel
        if [ "$choice" = local ]; then
            DB_MODE="local"; DB_DSN=""
            return 0
        fi
        dsn=$(dlg --title "External Database" --inputbox \
            "PostgreSQL connection URL:\n\npostgresql://user:password@host:5432/dbname" \
            12 66) || continue
        case "$dsn" in
            postgres://*|postgresql://*)
                DB_MODE="external"; DB_DSN="$dsn"
                return 0
                ;;
            *)
                dlg --msgbox "That doesn't look like a PostgreSQL DSN.\nExpected: postgresql://user:password@host:5432/dbname" \
                    9 66 || true
                ;;
        esac
    done
}

# tui_deps_report: status table — satisfied / will install / needs a decision.
tui_deps_report() {
    local txt="Dependency check results:\n" i
    txt+="\n  ✓ Package manager ($PKG_MGR)"
    txt+="\n  ✓ Internet connectivity"
    case "$DB_MODE" in
        external) txt+="\n  ✓ Database: external DSN provided" ;;
        *) if command -v psql >/dev/null 2>&1; then
               txt+="\n  ✓ PostgreSQL already installed"
           else
               txt+="\n  → PostgreSQL — will be installed locally"
           fi ;;
    esac
    case "$DEP_PY_STATE" in
        ok)      txt+="\n  ✓ Python $DEP_PY_VER ($PYTHON_BIN)" ;;
        missing) txt+="\n  → Python 3 — will be installed" ;;
        old)     txt+="\n  ✗ Python $DEP_PY_VER — too old (need 3.10–3.13)" ;;
        new)     txt+="\n  ⚠ Python $DEP_PY_VER — newer than tested (3.10–3.13)" ;;
        *)       txt+="\n  ✗ $PYTHON_BIN — unusable interpreter" ;;
    esac
    [ "$DEP_PY_VENV_BAD" = true ] && txt+="\n  ✗ $PYTHON_BIN lacks venv/ensurepip"
    for i in "${!DEP_MISSING_LABELS[@]}"; do
        # State lines above already cover the interpreter and database rows.
        case "${DEP_MISSING_LABELS[$i]}" in
            "Python 3 interpreter"|"PostgreSQL server") continue ;;
        esac
        txt+="\n  → ${DEP_MISSING_LABELS[$i]} — will be installed"
    done
    if [ "$DEP_DISK_OK" = true ]; then
        txt+="\n  ✓ Disk space (${DEP_DISK_FREE_MB} MB free)"
    else
        txt+="\n  ⚠ Disk space low (${DEP_DISK_FREE_MB} MB free, ${DEP_DISK_MIN_MB}+ recommended)"
    fi

    local prompt="All prerequisites satisfied. Continue?"
    [ ${#DEP_MISSING_PKGS[@]} -gt 0 ] && \
        prompt="Missing items will be installed step by step. Continue?"
    dlg --title "Dependency Check" --yesno "$txt\n\n$prompt" 21 68 || tui_cancel
}

# tui_deps_install: guided step-by-step install of the missing packages,
# split into logical steps so each progress window says what is happening.
tui_deps_install() {
    # Accepted system updates run first — before this early return, so the
    # offer still applies on hosts whose app dependencies are already met.
    # A failed upgrade only warns: updates are recommended, not required.
    if [ "${SYS_DO_UPDATE:-false}" = true ]; then
        dep_step "System updates" \
            "Applying ${UPDATES_PENDING:-pending} update(s) — this can take several minutes" \
            _sys_upgrade \
            || { dlg --msgbox "System updates failed — see $TUI_LOG for details.\n\nThe application install will continue." 10 64 || true; }
    fi
    [ ${#DEP_MISSING_PKGS[@]} -eq 0 ] && return 0

    local p py=() db=() tools=()
    for p in "${DEP_MISSING_PKGS[@]}"; do
        case "$p" in
            python*|python3*) py+=("$p") ;;
            postgresql*)      db+=("$p") ;;
            *)                tools+=("$p") ;;
        esac
    done

    dep_step "Package lists" "Refreshing package lists..." _pkg_update || true
    if [ ${#py[@]} -gt 0 ]; then
        dep_step "Python runtime" "Installing: ${py[*]}" _pkg_install "${py[@]}" \
            || { dlg --msgbox "Failed to install: ${py[*]}\n\nSee $TUI_LOG for details." 10 64 || true; tui_hard_fail; }
    fi
    if [ ${#db[@]} -gt 0 ]; then
        dep_step "PostgreSQL" "Installing: ${db[*]} (cluster init takes a moment)" \
            _pkg_install "${db[@]}" \
            || { dlg --msgbox "Failed to install: ${db[*]}\n\nSee $TUI_LOG for details." 10 64 || true; tui_hard_fail; }
    fi
    if [ ${#tools[@]} -gt 0 ]; then
        dep_step "System tools" "Installing: ${tools[*]}" _pkg_install "${tools[@]}" \
            || { dlg --msgbox "Failed to install: ${tools[*]}\n\nSee $TUI_LOG for details." 10 64 || true; tui_hard_fail; }
    fi

    # Re-scan — everything queued above should now show as satisfied.
    dep_collect_missing
    dep_python_state
    if [ ${#DEP_MISSING_PKGS[@]} -gt 0 ]; then
        dlg --title "Dependencies" --msgbox \
            "Some prerequisites could not be installed:\n\n  ${DEP_MISSING_LABELS[*]}\n\nSee $TUI_LOG for details." 14 66 || true
        tui_hard_fail
    fi
}

# _py_candidate_ok: validate a user-supplied interpreter path. Sets
# PYTHON_CANDIDATE on success, or _PY_CANDIDATE_ERR with a readable reason.
_py_candidate_ok() {
    _PY_CANDIDATE_ERR=""
    PYTHON_CANDIDATE=""
    local p="$1" v maj min
    [ -n "$p" ] || { _PY_CANDIDATE_ERR="No path given."; return 1; }
    [ -x "$p" ] || p=$(command -v "$p" 2>/dev/null) \
        || { _PY_CANDIDATE_ERR="'$1' is not executable and not on PATH."; return 1; }
    v=$("$p" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) \
        || { _PY_CANDIDATE_ERR="'$p' did not run — not a Python interpreter?"; return 1; }
    maj=${v%%.*}; min=${v#*.}
    case "$min" in ''|*[!0-9]*) min=0 ;; esac
    if [ "$maj" != "3" ] || [ "$min" -lt 10 ] || [ "$min" -ge 14 ]; then
        _PY_CANDIDATE_ERR="'$p' is Python $v — ProxmoxVEx needs 3.10–3.13."
        return 1
    fi
    "$p" -c 'import venv, ensurepip' >/dev/null 2>&1 \
        || { _PY_CANDIDATE_ERR="'$p' lacks venv/ensurepip — the app cannot build its virtualenv."; return 1; }
    PYTHON_CANDIDATE=$p
    return 0
}

# _py_install_pkgs: the interpreter + support packages for this distro.
_py_install_pkgs() {
    case "$PKG_MGR" in
        apt)    echo "python3 python3-pip python3-venv" ;;
        dnf)    echo "python3 python3-pip" ;;
        pacman) echo "python python-pip" ;;
    esac
}

# tui_python_resolve: the one prerequisite that can need a real answer —
# which interpreter to build the app on. Auto-resolves via the package
# manager where possible and only asks when a decision is genuinely required.
# The chosen interpreter builds the venv, so the answer configures the app.
tui_python_resolve() {
    local choice path msg mh
    local -a items
    while true; do
        dep_python_state
        [ "$DEP_PY_STATE" = ok ] && [ "$DEP_PY_VENV_BAD" = false ] && return 0

        if [ "$DEP_PY_STATE" = new ]; then
            if ! dlg --title "Unsupported Python Version" --yesno \
                "Python $DEP_PY_VER is newer than what ProxmoxVEx is tested on (3.10–3.13).\n\nKnown issue on 3.14: SSH/VNC WebSocket subprocesses may\nfail to bind cleanly (issue #388).\n\nContinue anyway?" \
                13 64; then
                tui_cancel
            fi
            DEPLOY_FORCE_PY=1
            return 0
        fi

        case "$DEP_PY_STATE" in
            old)     msg="Python $DEP_PY_VER is too old — ProxmoxVEx needs 3.10–3.13." ;;
            missing) msg="No Python 3 interpreter was found." ;;
            *)       msg="$PYTHON_BIN is not a usable interpreter." ;;
        esac
        [ "$DEP_PY_VENV_BAD" = true ] && msg="$PYTHON_BIN has no venv/ensurepip support."

        items=("auto" "Install via $PKG_MGR (recommended)"
               "path" "Use a different interpreter (custom path)"
               "abort" "Cancel the installation")
        mh=$(( ${#items[@]} / 2 ))
        choice=$(dlg --title "Python Interpreter" --menu \
            "$msg\n\nHow should Python be provided?" \
            $(( 9 + mh )) 68 "$mh" "${items[@]}") || tui_cancel

        case "$choice" in
            auto)
                dep_step "Python runtime" "Installing Python 3 via $PKG_MGR..." \
                    _pkg_install $(_py_install_pkgs) || true
                if ! command -v "$PYTHON_BIN" >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
                    PYTHON_BIN=python3
                fi
                dep_python_state
                dep_collect_missing
                if [ "$DEP_PY_STATE" != ok ] && [ "$DEP_PY_STATE" != new ]; then
                    dlg --msgbox \
                        "The package manager did not provide a suitable interpreter (found: ${DEP_PY_VER:-none}).\n\nTry the custom-path option, or install Python 3.10–3.13 manually." \
                        11 66 || true
                fi
                ;;
            path)
                path=$(dlg --title "Python Interpreter" --inputbox \
                    "Full path to a Python 3.10–3.13 binary:" 9 62) || continue
                if _py_candidate_ok "$path"; then
                    PYTHON_BIN=$PYTHON_CANDIDATE
                    dep_collect_missing
                    dep_python_state
                else
                    dlg --msgbox "$_PY_CANDIDATE_ERR" 10 64 || true
                fi
                ;;
            abort)
                tui_cancel
                ;;
        esac
    done
}

# tui_ask_port: menu-based port picker. Skipped when --port/PROXMOXVEX_PORT
# already answered the question.
tui_ask_port() {
    [ "$PORT_VIA_ARG" = true ] && return 0
    local choice
    while true; do
        choice=$(dlg --title "Web Interface Port" --menu \
            "Choose which port the web UI should listen on:" \
            13 64 3 \
            "5000"   "Standard port (recommended)" \
            "443"    "HTTPS on the standard port" \
            "custom" "Enter a custom port (1-65535)") || tui_cancel
        case "$choice" in
            5000|443)
                ACCESS_PORT=$choice
                break
                ;;
            custom)
                choice=$(dlg --title "Custom Port" --inputbox \
                    "Enter the port for the web UI (1-65535):" \
                    9 60 "$ACCESS_PORT") || continue
                if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 65535 ]; then
                    ACCESS_PORT=$choice
                    break
                fi
                dlg --title "Invalid Port" --msgbox \
                    "'$choice' is not a valid port. Enter a number between 1 and 65535." \
                    8 62 || true
                ;;
        esac
    done
}

# tui_ask_options: optional-component checklist. Defaults follow the flags so
# --no-offline / --no-firewall / --loopback-only still win.
tui_ask_options() {
    local off_state="ON" fw_state="ON" bind_state="ON" opts
    [ "$DOWNLOAD_OFFLINE" = true ] || off_state="OFF"
    [ "$CONFIGURE_FIREWALL" = true ] || fw_state="OFF"
    [ "$BIND_ALL" = true ] || bind_state="OFF"
    opts=$(dlg --title "Install Options" --checklist \
        "Toggle install options (SPACE to select):" \
        14 68 3 \
        "offline"  "Download web assets for offline use" "$off_state" \
        "firewall" "Open firewall ports (ufw/firewalld)" "$fw_state" \
        "bindall"  "Bind web UI to all interfaces" "$bind_state") || tui_cancel
    case "$opts" in
        *offline*) ;;  *) DOWNLOAD_OFFLINE=false ;;
    esac
    case "$opts" in
        *firewall*) ;; *) CONFIGURE_FIREWALL=false ;;
    esac
    case "$opts" in
        *bindall*) ;;  *) BIND_ALL=false ;;
    esac
}

# tui_confirm: last gate before any system change — recap the answers.
tui_confirm() {
    local offline="no" fw="no" bind="loopback only" db
    [ "$DOWNLOAD_OFFLINE" = true ] && offline="yes"
    [ "$CONFIGURE_FIREWALL" = true ] && fw="yes"
    [ "$BIND_ALL" = true ] && bind="all interfaces"
    if [ "$DB_MODE" = external ]; then
        db="external DSN"
    else
        db="local PostgreSQL"
    fi
    if ! dlg --title "Ready to Install" --yesno \
        "Review the installation settings:\n\n  Install dir    : $INSTALL_DIR\n  Database       : $db\n  Python         : ${DEP_PY_VER:-unknown}  ($PYTHON_BIN)\n  Web port       : $ACCESS_PORT  (VNC $((ACCESS_PORT+1)), SSH $((ACCESS_PORT+2)))\n  Offline assets : $offline\n  Firewall rules : $fw\n  Bind           : $bind\n\nProceed with the installation?" \
        17 66; then
        tui_cancel
    fi
}

# tui_success / tui_failure: closing screens. Both drop a plain-text summary
# afterwards so the URL survives in SSH scrollback.
tui_success() {
    local ip url
    ip="$(hostname -I 2>/dev/null | awk '{print $1}')"; ip="${ip:-<server-ip>}"
    [ "$ACCESS_PORT" = 443 ] && url="https://$ip" || url="https://$ip:$ACCESS_PORT"
    dlg --title "Installation Complete" --msgbox \
        "ProxmoxVEx is installed and running.\n\nWeb UI     : $url\nConsole WS : $((ACCESS_PORT+1)) (VNC), $((ACCESS_PORT+2)) (SSH)\n\nFirst login: create the admin account in the UI.\nConfig     : $ENV_FILE\nLog file   : $TUI_LOG" \
        16 68 || true
    clear
    print_summary
    echo -e "${CYAN}Install log: $TUI_LOG${NC}"
}

tui_failure() {
    dlg --title "Installation Failed" --msgbox \
        "The installation did not complete cleanly.\n\nThe install log is shown next for diagnosis.\nPress ESC or q to close it." \
        11 64 || true
    dlg --textbox "$TUI_LOG" "$TUI_PROG_H" "$TUI_PROG_W" || true
    clear
    echo -e "${RED}✗ Installation failed — see $TUI_LOG${NC}" >&2
    exit 1
}

# tui_main: the TUI flow — collect every answer up front, then run the
# installer with its output streamed into a live --progressbox window and
# mirrored to $TUI_LOG for the failure viewer. Answers land in the same
# variables the classic prompts write, and INTERACTIVE is cleared so no
# mid-install prompt can fight dialog for the terminal; run_install also gets
# /dev/null on stdin as a second guard.
tui_main() {
    tui_welcome

    # Fall back to a temp file if /var/log isn't writable for some reason.
    # Created up front so the dependency steps can log into it too.
    : > "$TUI_LOG" 2>/dev/null || TUI_LOG=$(mktemp /tmp/proxmoxvex-install.XXXXXX.log)

    # Database choice first: it decides whether PostgreSQL is a prerequisite.
    tui_ask_db
    dep_collect_missing

    tui_deps_report

    # Offer pending OS updates before app dependencies: refreshing the base
    # system first means the packages installed next land on a patched base.
    # --infobox draws immediately and returns, so it stays on screen while the
    # metadata refresh + count runs behind it.
    SYS_DO_UPDATE=false
    dlg --infobox "Checking for pending system updates..." 5 48 || true
    if system_update_check; then
        case "$SYSTEM_UPDATE" in
            ask)
                if dlg --title "System Updates" --yesno \
                    "$UPDATES_PENDING system update(s) are pending for this host.\n\nApply them before installing ProxmoxVEx? (recommended)\n\nDepending on how far behind the system is, this can take\nseveral minutes." \
                    13 68; then
                    SYS_DO_UPDATE=true
                fi ;;
            *)
                # Explicit opt-in via PROXMOXVEX_SYSTEM_UPDATE=1
                SYS_DO_UPDATE=true ;;
        esac
    fi

    tui_deps_install
    tui_python_resolve

    tui_ask_port
    tui_ask_options
    tui_confirm

    INTERACTIVE=false

    run_install </dev/null 2>&1 | tee -a "$TUI_LOG" | \
        dialog --backtitle "$TUI_BACKTITLE" \
            --title "Installing ProxmoxVEx" \
            --progressbox "Live installer output — do not close this terminal" \
            "$TUI_PROG_H" "$TUI_PROG_W"
    local rc=${PIPESTATUS[0]}

    # The pipeline's exit status is dialog's; the installer's own result lives
    # in PIPESTATUS[0]. Double-check the service too — run_install only warns
    # (does not fail) when the unit fails to come up.
    if [ "$rc" -eq 0 ] && systemctl is-active --quiet ProxmoxVEx; then
        tui_success
    else
        tui_failure
    fi
}

# dep_classic_report: the classic-mode equivalent of the dependency screen —
# same scan data, printed as status lines.
dep_classic_report() {
    if [ ${#DEP_MISSING_PKGS[@]} -gt 0 ]; then
        info "Missing dependencies (will be installed): ${DEP_MISSING_LABELS[*]}"
    else
        info "All system dependencies already satisfied"
    fi
    case "$DEP_PY_STATE" in
        old)
            echo -e "${RED}✗ Python $DEP_PY_VER is too old — ProxmoxVEx needs 3.10–3.13.${NC}" >&2
            if [ "$INTERACTIVE" = true ] && [ -t 0 ]; then
                local _pyp
                read -r -p "Path to a Python 3.10–3.13 interpreter (Enter to abort): " _pyp < /dev/tty
                if [ -n "$_pyp" ] && _py_candidate_ok "$_pyp"; then
                    PYTHON_BIN=$PYTHON_CANDIDATE
                    ok "Using interpreter $PYTHON_BIN"
                    dep_collect_missing
                    dep_python_state
                else
                    [ -n "$_pyp" ] && echo -e "${RED}✗ $_PY_CANDIDATE_ERR${NC}" >&2
                    exit 1
                fi
            else
                exit 1
            fi
            ;;
        broken)
            die "$PYTHON_BIN is not a usable interpreter"
            ;;
        missing)
            info "python3 will be installed via $PKG_MGR"
            ;;
    esac
    if [ "$DEP_PY_VENV_BAD" = true ]; then
        die "$PYTHON_BIN lacks venv/ensurepip — cannot build the virtualenv"
    fi
    [ "$DEP_DISK_OK" = false ] && \
        warn "Low disk space: ${DEP_DISK_FREE_MB} MB free (${DEP_DISK_MIN_MB}+ MB recommended)"
    return 0
}

# ============================================================================
# Main install routine
# Runs either directly (classic output) or inside the TUI's live-output window
# (tui_main). Root/arch/distro/connectivity checks and the dependency scan all
# happen in the entry-point section below before this is invoked.
# ============================================================================
run_install() {

# ============================================================================
# Step 1: OS packages — install only what the scan still finds missing
# (the TUI's guided dependency pass normally resolved everything already)
# ============================================================================
step "Step 1/7: System dependencies"

dep_collect_missing
if [ ${#DEP_MISSING_PKGS[@]} -gt 0 ]; then
    run_step "Refreshing package lists" _pkg_update
    run_step "Installing missing packages: ${DEP_MISSING_PKGS[*]}" \
        _pkg_install "${DEP_MISSING_PKGS[@]}"
else
    info "All required packages already present"
fi

# sshpass lives in EPEL on RHEL-family — kept best-effort since password-based
# SSH to nodes degrades gracefully without it.
if [ "$PKG_MGR" = dnf ] && ! rpm -q sshpass >/dev/null 2>&1; then
    run_step "Installing sshpass" dnf install -y -q sshpass \
        || { dnf install -y -q epel-release >/dev/null 2>&1 || true
             run_step "Installing sshpass from EPEL" dnf install -y -q sshpass; } \
        || warn "sshpass unavailable — password-based SSH to nodes will not work"
fi

# DEPLOY_FORCE_PY=1 means the operator already acknowledged a too-new
# interpreter at the TUI warning screen — don't hard-fail on it twice.
if [ "${DEPLOY_FORCE_PY:-0}" = 1 ]; then
    warn "Proceeding on untested Python $("$PYTHON_BIN" -V 2>&1 || echo 'unknown')"
else
    "$PYTHON_BIN" -c 'import sys; v=sys.version_info; sys.exit(0 if (v.major==3 and 10<=v.minor<14) else 1)' \
        || die "Python 3.10–3.13 required ($PYTHON_BIN is $("$PYTHON_BIN" -V 2>&1 || echo 'not runnable'))"
fi
ok "System dependencies ready"

# ============================================================================
# Step 2: PostgreSQL — local install unless an external DSN was provided
# ============================================================================
step "Step 2/7: PostgreSQL database"

DB_DSN="${DB_DSN:-${PROXMOXVEX_DATABASE_URL:-}}"
if [ -n "$DB_DSN" ]; then
    info "Using provided PROXMOXVEX_DATABASE_URL — skipping local PostgreSQL setup"
else
    case "$PKG_MGR" in
        dnf)
            # RHEL-family needs an explicit initdb before the service can start.
            if [ ! -s /var/lib/pgsql/data/PG_VERSION ]; then
                run_step "Initializing PostgreSQL cluster" \
                    postgresql-setup --initdb \
                    || run_step "Initializing PostgreSQL cluster" \
                        /usr/bin/postgresql-setup --initdb
            fi
            ;;
        pacman)
            # Arch ships no pre-initialized cluster either.
            if [ ! -s /var/lib/postgres/data/PG_VERSION ]; then
                run_step "Initializing PostgreSQL cluster" \
                    su - postgres -c "initdb -D /var/lib/postgres/data --locale=C.UTF-8 --encoding=UTF8"
            fi
            ;;
    esac
    run_step "Starting PostgreSQL" systemctl enable --now postgresql \
        || die "PostgreSQL failed to start — check: journalctl -u postgresql"

    # Generate a per-install password and (re)create role + database idempotently.
    DB_PASS="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-32)"
    su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" | grep -q 1 \
        && su - postgres -c "psql -c \"ALTER ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PASS'\"" >/dev/null \
        || su - postgres -c "psql -c \"CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS'\"" >/dev/null
    su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" | grep -q 1 \
        || su - postgres -c "createdb -O $DB_USER $DB_NAME" >/dev/null
    DB_DSN="postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
    ok "PostgreSQL running; role '$DB_USER' and database '$DB_NAME' ready"
fi

# ============================================================================
# Step 3: Service user + directories + release archive
# ============================================================================
step "Step 3/7: Installing ProxmoxVEx to $INSTALL_DIR"

id "$SERVICE_USER" &>/dev/null || useradd --system --no-create-home --shell /bin/false "$SERVICE_USER"
mkdir -p "$INSTALL_DIR"/{config,logs,ssl,static,web,images,backups}

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
info "Downloading $RELEASE_ARCHIVE"
run_step "Downloading release archive" curl -fsSL "$RELEASE_ARCHIVE" -o "$TMP/release.tar.gz" \
    || die "Download failed — check connectivity to proxmoxvex.com"
# Verify against the published checksum when one is served alongside.
if curl -fsSL "$RELEASE_ARCHIVE.sha256" -o "$TMP/release.sha256" 2>/dev/null; then
    (cd "$TMP" && sha256sum -c release.sha256 --status 2>/dev/null) \
        || (cd "$TMP" && echo "$(cut -d' ' -f1 release.sha256)  release.tar.gz" | sha256sum -c --status) \
        || die "Checksum verification failed for $RELEASE_ARCHIVE"
    ok "Checksum verified"
fi
tar -xzf "$TMP/release.tar.gz" -C "$INSTALL_DIR" || die "Failed to extract release archive"
[ -f "$INSTALL_DIR/ProxmoxVEx_multi_cluster.py" ] || die "Archive missing entry point — corrupt download?"
rm -rf "$INSTALL_DIR/.git" 2>/dev/null || true
chmod +x "$INSTALL_DIR/deploy.sh" "$INSTALL_DIR/update.sh" "$INSTALL_DIR/install.sh" 2>/dev/null || true
ok "Release extracted to $INSTALL_DIR"

# ============================================================================
# Step 4: Python environment
# ============================================================================
step "Step 4/7: Python environment"

# PYTHON_BIN may be a resolver-chosen custom interpreter — build with it.
run_step "Creating virtualenv" "$PYTHON_BIN" -m venv "$INSTALL_DIR/venv"
run_step "Upgrading pip" "$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
run_step "Installing Python dependencies" "$INSTALL_DIR/venv/bin/pip" install -q -r "$INSTALL_DIR/requirements.txt" \
    || die "pip install failed — see output above"
ok "Virtualenv ready ($("$INSTALL_DIR/venv/bin/python" --version | awk '{print $2}'))"

# ============================================================================
# Step 5: Environment file + port selection
# ============================================================================
step "Step 5/7: Configuration"

if [ "$INTERACTIVE" = true ] && [ -t 0 ]; then
    echo -e "${YELLOW}Web UI port:${NC} [1] 5000 (default)  [2] 443  [3] custom"
    read -r -p "Choice [1-3, default=1]: " CHOICE < /dev/tty || CHOICE=1
    case "${CHOICE:-1}" in
        2) ACCESS_PORT=443 ;;
        3) read -r -p "Port: " ACCESS_PORT < /dev/tty ;;
    esac
fi
[[ "$ACCESS_PORT" =~ ^[0-9]+$ ]] && [ "$ACCESS_PORT" -ge 1 ] && [ "$ACCESS_PORT" -le 65535 ] || die "Invalid port '$ACCESS_PORT'"

mkdir -p "$ENV_DIR"
chmod 750 "$ENV_DIR"
chown "root:$SERVICE_USER" "$ENV_DIR" 2>/dev/null || true

# Preserve an existing env file on upgrade; only (re)write the DSN when we own
# the local PostgreSQL role (external DSNs are left untouched).
if [ -f "$ENV_FILE" ]; then
    if [ -z "${PROXMOXVEX_DATABASE_URL:-}" ]; then
        sed -i "s|^PROXMOXVEX_DATABASE_URL=.*|PROXMOXVEX_DATABASE_URL=$DB_DSN|" "$ENV_FILE"
    fi
    grep -q '^PROXMOXVEX_PORT=' "$ENV_FILE" \
        && sed -i "s|^PROXMOXVEX_PORT=.*|PROXMOXVEX_PORT=$ACCESS_PORT|" "$ENV_FILE" \
        || echo "PROXMOXVEX_PORT=$ACCESS_PORT" >> "$ENV_FILE"
    info "Updated existing $ENV_FILE"
else
    cat > "$ENV_FILE" <<EOF
# ProxmoxVEx environment — consumed by ProxmoxVEx.service via EnvironmentFile=
PROXMOXVEX_DATABASE_URL=$DB_DSN
PROXMOXVEX_PORT=$ACCESS_PORT
# Bind all interfaces so the UI is reachable from the LAN. Comment out or set 0
# and place the app behind a reverse proxy to keep it on loopback.
PROXMOXVEX_BIND_ALL=$([ "$BIND_ALL" = true ] && echo 1 || echo 0)
EOF
    ok "Wrote $ENV_FILE"
fi
chmod 640 "$ENV_FILE"
chown "root:$SERVICE_USER" "$ENV_FILE" 2>/dev/null || true

# ============================================================================
# Step 6: systemd unit, sudoers, master key
# ============================================================================
step "Step 6/7: Service"

cat > /etc/systemd/system/ProxmoxVEx.service <<EOF
[Unit]
Description=ProxmoxVEx - Multi-cluster virtualization management
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service

[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$INSTALL_DIR
EnvironmentFile=-$ENV_FILE
Environment=PATH=$INSTALL_DIR/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=$INSTALL_DIR/venv/bin/python $INSTALL_DIR/ProxmoxVEx_multi_cluster.py
Restart=always
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE
PrivateTmp=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ProxmoxVEx

[Install]
WantedBy=multi-user.target
EOF

# Auto-update needs to restart its own unit — scope sudoers to exactly that.
cat > /etc/sudoers.d/ProxmoxVEx <<'EOF'
ProxmoxVEx ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart ProxmoxVEx, /usr/bin/systemctl restart ProxmoxVEx.service, /usr/bin/systemctl stop ProxmoxVEx, /usr/bin/systemctl stop ProxmoxVEx.service, /usr/bin/systemctl start ProxmoxVEx, /usr/bin/systemctl start ProxmoxVEx.service, /usr/bin/systemctl status ProxmoxVEx, /usr/bin/systemctl status ProxmoxVEx.service, /usr/bin/systemctl is-active ProxmoxVEx, /usr/bin/systemctl is-active ProxmoxVEx.service
EOF
chmod 440 /etc/sudoers.d/ProxmoxVEx

# Master encryption key lives outside the install dir so config backups do not
# carry the decryption key (0640 root:ProxmoxVEx — the service group reads it).
SYS_KEY="$ENV_DIR/secret.key"
if [ ! -f "$SYS_KEY" ]; then
    # PYTHON_BIN is the resolver-approved interpreter — a custom path may be
    # the only Python on hosts where `python3` itself was never installed.
    "$PYTHON_BIN" -c "
import base64, os, secrets
key = base64.urlsafe_b64encode(secrets.token_bytes(32))
fd = os.open('$SYS_KEY', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
try: os.write(fd, key)
finally: os.close(fd)
"
    chown "root:$SERVICE_USER" "$SYS_KEY" 2>/dev/null || true
    ok "Generated master key at $SYS_KEY"
else
    info "Master key already present at $SYS_KEY"
fi

chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR/config" "$INSTALL_DIR/ssl" 2>/dev/null || true

systemctl daemon-reload
systemctl enable ProxmoxVEx >/dev/null 2>&1
ok "ProxmoxVEx.service installed and enabled (port $ACCESS_PORT)"

# ============================================================================
# Step 7: Firewall + offline assets + start
# ============================================================================
step "Step 7/7: Firewall & start"

if [ "$CONFIGURE_FIREWALL" = true ]; then
    if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q 'Status: active'; then
        for p in "$ACCESS_PORT" "$((ACCESS_PORT+1))" "$((ACCESS_PORT+2))"; do
            ufw allow "$p/tcp" >/dev/null
        done
        ok "ufw: allowed $ACCESS_PORT-$((ACCESS_PORT+2))/tcp"
    elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
        for p in "$ACCESS_PORT" "$((ACCESS_PORT+1))" "$((ACCESS_PORT+2))"; do
            firewall-cmd --permanent --add-port="$p/tcp" >/dev/null
        done
        firewall-cmd --reload >/dev/null
        ok "firewalld: allowed $ACCESS_PORT-$((ACCESS_PORT+2))/tcp"
    else
        info "No active firewall detected — no rules added (ports $ACCESS_PORT-$((ACCESS_PORT+2))/tcp)"
    fi
fi

if [ "$DOWNLOAD_OFFLINE" = true ]; then
    if run_step "Downloading static assets for offline mode" \
        bash -c "cd '$INSTALL_DIR' && '$INSTALL_DIR/venv/bin/python' ProxmoxVEx_multi_cluster.py --download-static"; then
        ok "Offline assets cached"
    else
        warn "Some assets failed to download (non-critical)"
    fi
    # download-static ran as root — re-assert ownership so the service user can
    # refresh these files later via the in-app update mechanism.
    chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
fi

if ! systemctl restart ProxmoxVEx; then
    warn "systemctl restart failed — check: journalctl -u ProxmoxVEx -n 50"
fi
sleep 5
systemctl is-active --quiet ProxmoxVEx \
    && ok "ProxmoxVEx is running" \
    || warn "Service not active yet — check: journalctl -u ProxmoxVEx -f"
}

# ============================================================================
# Final summary — classic mode prints it directly; the TUI echoes it after the
# closing screen so the URL survives in scrollback.
# ============================================================================
print_summary() {
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; IP="${IP:-<server-ip>}"
[ "$ACCESS_PORT" = 443 ] && URL="https://$IP" || URL="https://$IP:$ACCESS_PORT"

echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║              ProxmoxVEx installation complete              ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "  Web UI:      ${CYAN}${BOLD}$URL${NC}  (self-signed cert on first run)"
echo -e "  Console WS:  $((ACCESS_PORT+1)) (VNC), $((ACCESS_PORT+2)) (SSH)"
echo ""
echo -e "  ${YELLOW}First login:${NC} create the admin account in the UI, then"
echo -e "  add a cluster: Settings → Clusters → Add Proxmox VE."
echo ""
echo -e "  Config:  $ENV_FILE"
echo -e "  Logs:    ${CYAN}journalctl -u ProxmoxVEx -f${NC}"
echo -e "  Service: ${CYAN}systemctl restart ProxmoxVEx${NC}"
echo ""
}

# ============================================================================
# Entry point — preflight, dependency scan, then TUI or classic flow.
# Preflight stays in plain text: these checks gate the UI bootstrap itself.
# ============================================================================
[ "$EUID" -eq 0 ] || die "Run as root: curl -fsSL https://proxmoxvex.com/downloads/install.sh | sudo bash"

case "$(uname -m)" in
    x86_64|aarch64) ;;
    *) die "Unsupported architecture $(uname -m) — ProxmoxVEx ships wheels for x86_64/aarch64 only" ;;
esac

# Distro detection must happen before setup_tui — installing `dialog` needs to
# know which package manager this host has.
[ -f /etc/os-release ] || die "Cannot detect OS — /etc/os-release missing"
. /etc/os-release
DISTRO_ID="${ID:-}"
DISTRO_LIKE="${ID_LIKE:-}"
info "Detected: ${PRETTY_NAME:-$DISTRO_ID}"

PKG_MGR=""
case "$DISTRO_ID $DISTRO_LIKE" in
    *debian*|*ubuntu*) PKG_MGR="apt" ;;
    *rhel*|*fedora*|*rocky*|*almalinux*|*centos*) PKG_MGR="dnf" ;;
    *arch*) PKG_MGR="pacman" ;;
esac
[ -n "$PKG_MGR" ] || die "Unsupported distro '$DISTRO_ID'. Supported: Debian/Ubuntu, RHEL/Rocky/Alma/Fedora, Arch — or use Docker."

export DEBIAN_FRONTEND=noninteractive

# The release archive comes over HTTPS — fail fast when the host is offline.
# `|| net_rc=$?` guards against `set -e` killing the script before the case
# can read the probe's return code.
net_rc=0
check_connectivity || net_rc=$?
case "$net_rc" in
    0) ;;
    1) die "No internet connection — cannot download ProxmoxVEx." ;;
    2) warn "Cannot verify connectivity (no curl/ping yet) — continuing anyway" ;;
esac

# Decide between the full-screen dialog UI and classic line output.
setup_tui

# Scan prerequisites once so both UIs report from the same data — the TUI
# re-scans after the database choice since that decides whether PostgreSQL is
# a prerequisite at all.
dep_collect_missing
dep_python_state
dep_disk_check

if [ "$TUI" = true ]; then
    tui_main
else
    dep_classic_report
    # Same system-update offer as the TUI — asks on a real tty, applies
    # silently when PROXMOXVEX_SYSTEM_UPDATE=1, otherwise reports and skips.
    if system_update_check; then
        _do_update=false
        case "$SYSTEM_UPDATE" in
            ask)
                if [ "$INTERACTIVE" = true ] && (exec 3</dev/tty) 2>/dev/null; then
                    _ans=""
                    read -r -p "Apply $UPDATES_PENDING pending system update(s) first? [Y/n] " _ans </dev/tty 2>/dev/null || _ans=n
                    case "${_ans:-y}" in ''|y|Y|yes) _do_update=true ;; esac
                else
                    info "$UPDATES_PENDING system update(s) pending — skipped (PROXMOXVEX_SYSTEM_UPDATE=1 applies them)"
                fi ;;
            *)
                _do_update=true ;;
        esac
        if [ "$_do_update" = true ]; then
            run_step "Applying $UPDATES_PENDING system update(s) — a few minutes is normal" _sys_upgrade \
                || warn "System updates failed — continuing with application install"
        fi
    fi
    run_install
    print_summary
fi
