#!/usr/bin/env bash
set -euo pipefail

# Базы загрузок. Переопределяемы через ENV — для air-gapped инсталляций (свой mirror)
# и тестирования. Дефолт — публичные хосты Afimon.
GUM_URL="${GUM_URL:-https://static.afi-d.ru/bin/gum-linux-amd64}"
COMPOSE_URL="${COMPOSE_URL:-https://static.afi-d.ru/products/klvd/install/docker-compose.yaml}"
KLVD_PSQL_URL="${KLVD_PSQL_URL:-https://static.afi-d.ru/products/klvd/install/klvd-psql.sh}"
REGISTRY="${REGISTRY:-registry-ru-2.afi-d.ru}"
SNIPPET_BASE="${SNIPPET_BASE:-https://static.afi-d.ru/snippets/linux-general/docker}"
readonly GUM_URL COMPOSE_URL KLVD_PSQL_URL REGISTRY SNIPPET_BASE
readonly STATE_FILE="$(pwd)/state.ini"
# Яркий цвет заголовков gum — дефолтный тускло-серый в тёмной консоли не виден.
readonly GUM_HEADER_FG="#4fc3f7"

# Стандартный секрет для non-interactive установки «всё-в-одном» (dev/lab/air-gapped).
# ПРЕДУПРЕЖДЕНИЕ: предсказуемый ключ — только для изолированной лаборатории или первого
# запуска за обратным прокси/файрволом. В продакшн смените через secrets/klvd_secret_key
# или укажите KLVD_SECRET_KEY.
readonly KLVD_DEFAULT_SECRET_KEY="klvd-standalone-default-secret-change-me-in-production"

GUM=""
INSTALL_DIR=""
TMP_DIR=""
JOIN_CLUSTER="no"
ROLES=""
LOCAL_SOLR="yes"
LOGS_DIR=""
STORAGE_DATA_DIR=""
SEARCH_INDEX_DIR=""
SECRET_MODE="stdin"
LOCAL_PG="yes"
PG_HOST="127.0.0.1"
PG_PORT="5432"
PG_DB="klvd"
PG_USER="appuser"
PG_CONF_DIR=""
PG_PASSWORD=""
SECRET_KEY=""
WEB_PORT="8001"
WEB_HTTP_PORT="8000"
IMAGE_TAG="${KLVD_IMAGE_TAG:-latest}"

# Диагностические/прогресс-сообщения всегда в stderr, никогда в stdout —
# иначе они попадают в захват $(...) (например, в g_secret) и портят значение.
log()   { printf '\033[1;34m[klvd]\033[0m %s\n' "$*" >&2; }
warn()  { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
err()   { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; }
die()   { err "$*"; exit 1; }
ok()    { printf '\033[1;32m[✓]\033[0m %s\n' "$*" >&2; }

need_root() {
    [[ "${EUID}" -eq 0 ]] || die "Запустите от root: sudo bash $0"
    if [[ ! -t 0 && "${KLVD_NONINTERACTIVE:-0}" != "1" ]]; then
        warn "Нет интерактивного терминала (TTY)."
        warn "Запустите интерактивно: sudo bash <файл-установщика> (не через pipe)."
    fi
}

as_docker_user() {
    if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
        sudo -u "$SUDO_USER" -H "$@"
    else
        "$@"
    fi
}
as_compose() {
    if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
        sudo -u "$SUDO_USER" -H bash -lc 'cd "$1" && shift && exec docker compose "$@"' \
            bash "$INSTALL_DIR" "$@"
    else
        (cd "$INSTALL_DIR" && docker compose "$@")
    fi
}

own_tree() {
    [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]] || return 0
    chown -R "$SUDO_USER":"$SUDO_USER" "$@" 2>/dev/null || true
}

state_get() {
    [[ -f "$STATE_FILE" ]] || return 0
    local k="$1"
    grep -E "^${k}=" "$STATE_FILE" 2>/dev/null | head -1 | cut -d'=' -f2- || true
}
state_set() {
    local k="$1" v="$2"
    case "$k" in
        PG_PASSWORD|SECRET_KEY|REGISTRY_PASS) return 0 ;;
    esac
    mkdir -p "$(dirname "$STATE_FILE")"
    touch "$STATE_FILE"; chmod 600 "$STATE_FILE"

    local tmp="$STATE_FILE.tmp.$$"
    awk -v k="$k" -v v="$v" '
        BEGIN { found=0 }
        $0 ~ "^" k "=" { print k "=" v; found=1; next }
        { print }
        END { if (!found) print k "=" v }
    ' "$STATE_FILE" > "$tmp" 2>/dev/null || die "state_set: не удалось записать $STATE_FILE."
    mv "$tmp" "$STATE_FILE"
    chmod 600 "$STATE_FILE"
    own_tree "$STATE_FILE"
}
step_done() { [[ "$(state_get "$1")" == "done" ]]; }
mark_done() { state_set "$1" "done"; }

g_header() {
    if [[ -x "$GUM" ]]; then
        "$GUM" style --border rounded --padding "0 2" --border-foreground 4 --bold "$1"
    else
        printf '\n=== %s ===\n' "$1"
    fi
}

g_input() {
    local label="$1" def="${2:-}" out=""
    if [[ -x "$GUM" ]]; then
        local args=(--prompt "› " --header "$label" --header.foreground "$GUM_HEADER_FG" --placeholder "введите и нажмите Enter…")
        [[ -n "$def" ]] && args+=(--value "$def")
        while true; do
            if out="$("$GUM" input "${args[@]}")"; then
                [[ -n "$out" || -n "$def" ]] && { out="${out:-$def}"; break; }
            else
                out="${def}"; break
            fi
        done
    else
        local ans; read -r -p "$label${def:+ [$def]}: " ans || ans=""
        out="${ans:-$def}"
    fi
    printf '%s\n' "$out"
}

g_password() {
    local label="$1" out=""
    if [[ -x "$GUM" ]]; then
        while true; do
            if out="$("$GUM" input --prompt "🔒 " --header "$label" --header.foreground "$GUM_HEADER_FG" --placeholder "ввод скрыт…" --password)"; then
                [[ -n "$out" ]] && break
            else
                out=""; break
            fi
        done
    else
        local ans; read -rs -p "$label: " ans; echo >&2; out="$ans"
    fi
    printf '%s\n' "$out"
}

g_confirm() {
    local q="$1" def="${2:-true}" hint
    [[ "$def" == true ]] && hint="Y/n" || hint="y/N"
    if [[ -x "$GUM" ]]; then
        "$GUM" confirm --default="$def" "$q"
    else
        local ans; read -r -p "$q [$hint] " ans || ans=""
        { [[ "$def" == true && -z "$ans" ]] || [[ "$ans" =~ ^[Yy] ]]; }
    fi
}

g_choose_many() {
    local header="$1"
    shift

    local options=()
    local item value label

    for item in "$@"; do
        value="${item%%|*}"
        label="${item#*|}"
        options+=("$label [$value]")
    done

    if [[ -x "$GUM" ]]; then
        log "Перемещение — стрелки ↑↓, отметить/снять — Пробел, подтвердить выбор — Ввод."
        "$GUM" choose \
            --no-limit \
            --header "$header" \
            --header.foreground "$GUM_HEADER_FG" \
            --selected-prefix "✓ " \
            --unselected-prefix "  " \
            "${options[@]}" \
        | sed -E 's/^.*\[([^][]+)]$/\1/'
    else
        printf '%s\n' "$header"

        local i=1
        for item in "$@"; do
            value="${item%%|*}"
            label="${item#*|}"
            printf '  %d) %s [%s]\n' "$i" "$label" "$value"
            ((i++))
        done

        local ans
        read -r -p "Введите номера через запятую: " ans || ans=""
        log "Ввод номеров завершён."

        tr ',' '\n' <<< "$ans" |
        while read -r i; do
            i="${i//[[:space:]]/}"

            [[ "$i" =~ ^[0-9]+$ ]] || continue
            ((i >= 1 && i <= $#)) || continue

            item="${!i}"
            printf '%s\n' "${item%%|*}"
        done
    fi
}

g_secret() {
    local label="$1" default_file="${2:-secret.txt}"
    local secrets_dir="$INSTALL_DIR/secrets"
    if g_confirm "Готовы ввести $label сейчас?" true; then
        local val
        val="$(g_password "$label")"
        [[ -n "$val" ]] && printf '%s\n' "$val" || return 1
    else
        mkdir -p "$secrets_dir"
        local fname
        fname="$(g_input "Имя файла в secrets/ (положите файл до продолжения)" "$default_file")"
        [[ -n "$fname" ]] || fname="$default_file"
        local fpath="$secrets_dir/$fname"
        warn "Положите файл: $fpath"
        warn "Можно прервать (Ctrl+C) — при перезапуске процесс продолжится с этого же шага."
        g_confirm "Файл готов. Продолжить?" false \
            || die "Ожидается файл: $fpath. Перезапустите скрипт после."
        [[ -s "$fpath" ]] || die "Файл пустой: $fpath"
        printf '%s' "$(cat "$fpath")"
    fi
}

run_spin() {
    local msg="$1"; shift
    if [[ ! -t 1 ]]; then log "$msg…"; "$@"; return $?; fi
    "$@" & local pid=$!
    local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' i=0
    while kill -0 "$pid" 2>/dev/null; do
        printf '\r\033[36m%s\033[0m %s' "${spin:i%10:1}" "$msg"; sleep 0.08; i=$((i+1))
    done
    printf '\r\033[K'
    wait "$pid"
}

ask_install_dir() {
    if step_done step_install_dir; then
        INSTALL_DIR="$(state_get install_dir)"
        log "Папка установки (из state.ini): $INSTALL_DIR"
        return
    fi
    local def="/opt/klvd"
    if [[ "${KLVD_NONINTERACTIVE:-0}" == "1" ]]; then
        INSTALL_DIR="${KLVD_INSTALL_DIR:-$def}"
        log "Папка установки (non-interactive): $INSTALL_DIR"
    else
        printf '\n\033[1;34m=== Куда установить САП «Клавдий»? ===\033[0m\n'
        log "Папка установки содержит конфигурацию (.env, secrets, docker-compose.yml) и рабочие данные."
        log "Содержимое писем и поисковый индекс по умолчанию размещаются внутри неё, но пути можно"
        log "поменять далее. Рекомендуется отдельный диск достаточного объёма."
        read -r -p "Папка установки [$def]: " ans
        INSTALL_DIR="${ans:-$def}"
    fi
    log "Папка установки: $INSTALL_DIR"
    mkdir -p "$INSTALL_DIR"
    own_tree "$INSTALL_DIR"
    state_set install_dir "$INSTALL_DIR"
    mark_done step_install_dir
}

make_tmp() {
    TMP_DIR="/tmp/klvd"
    mkdir -p "$TMP_DIR"
    log "Рабочая папка: $TMP_DIR"
}

download_verified() {
    local url="$1" dest="$2" label="${3:-артефакт}"
    if ! run_spin "Загружается $label" wget -qO "$dest" "$url"; then
        return 1
    fi
    local sum_file="$dest.sha256" want hash
    if wget -qO "$sum_file" "$url.sha256" 2>/dev/null && [[ -s "$sum_file" ]]; then
        want="$(awk '{print $1}' "$sum_file" | tr -dc 'a-fA-F0-9')"
        hash="$(sha256sum "$dest" | awk '{print $1}')"
        if [[ -z "$want" || "${want,,}" != "${hash,,}" ]]; then
            rm -f "$dest" "$sum_file"
            die "Контрольная сумма $label не совпала (ожидалась ${want:-?}, получена $hash). Возможна подмена или повреждение файла."
        fi
        ok "$label: контрольная сумма совпадает."
        rm -f "$sum_file"
    else
        rm -f "$sum_file"
        warn "$label: sidecar .sha256 не найден — целостность файла не проверена."
    fi
}

fetch_gum() {
    if [[ "${KLVD_NONINTERACTIVE:-0}" == "1" ]]; then
        GUM=""
        log "Non-interactive: gum TUI пропускается."
        mark_done step_gum
        return
    fi
    if step_done step_gum && [[ -x "$INSTALL_DIR/tmp/gum" ]]; then
        GUM="$INSTALL_DIR/tmp/gum"
        return
    fi
    local out="$TMP_DIR/gum"
    download_verified "$GUM_URL" "$out" "gum (TUI-промпты)" \
        || die "Не удалось скачать gum ($GUM_URL). Проверьте наличие доступа к https://static.afi-d.ru/."
    chmod +x "$out"
    GUM="$out"
    state_set gum_path "$GUM"
    mark_done step_gum
}

detect_distro() {
    [[ -r /etc/os-release ]] || die "Не удалось определить ОС (нет /etc/os-release)"
    source /etc/os-release
    local id="${ID:-}" vid="${VERSION_ID:-}"
    log "ОС: ${PRETTY_NAME:-unknown} (ID=$id, VERSION_ID=$vid, ID_LIKE=${ID_LIKE:-})"
    local ok=0
    if [[ "$id" == "ubuntu" ]]; then
        local major="${vid%%.*}"
        (( major >= 22 )) && ok=1
    elif [[ "$id" == "debian" ]]; then
        (( vid >= 12 )) && ok=1
    elif [[ "$id" == "astra" ]]; then
        # Astra Linux. VERSION_ID вида «1.8_x86-64». Astra 1.8 = Debian 12 base
        # (glibc 2.36) → поддерживается; 1.7 = Debian 10 — слишком старая.
        local major="${vid%%.*}"
        local minor_full="${vid#*.}"
        local minor="${minor_full%%_*}"
        major="${major//[!0-9]/}"
        minor="${minor//[!0-9]/}"
        if (( major >= 2 )) || { (( major == 1 )) && (( minor >= 8 )); }; then
            ok=1
        fi
    fi
    [[ $ok -eq 1 ]] || die "Поддерживается Ubuntu 22.04 LTS+, Debian 12+ и Astra Linux 1.8+. Найдено: ${PRETTY_NAME:-unknown}"
    echo "$id" > "$TMP_DIR/distro_id"
}

docker_ok_version() {
    command -v docker >/dev/null 2>&1 || return 1
    docker info >/dev/null 2>&1 || return 2
    docker compose version >/dev/null 2>&1 || return 3
    return 0
}

ensure_docker() {
    if step_done step_docker; then
        log "Docker уже настроен (по state.ini) — пропускается."
        return
    fi
    g_header "Docker"
    log "Проверка Docker + Compose…"
    if docker_ok_version; then
        ok "Docker и Compose plugin в порядке."
        mark_done step_docker
        return
    fi

    if command -v docker >/dev/null 2>&1; then
        warn "Docker установлен, но compose plugin отсутствует или версия не подходит."
        if [[ "${KLVD_NONINTERACTIVE:-0}" == "1" ]]; then
            log "Non-interactive: переустанавливаю Docker."
        elif g_confirm "Удалить текущую версию Docker и установить актуальную из оригинального источника?" true; then
            :
        else
            die "Без подходящего Docker + Compose установка невозможна. Установите docker-compose-plugin и перезапустите установку."
        fi
    fi

    local distro; distro="$(cat "$TMP_DIR/distro_id")"
    local script_name
    case "$distro" in
        ubuntu) script_name="01-ubuntu-docker-install.sh" ;;
        astra)  script_name="01-astra-docker-install.sh" ;;
        *)      script_name="01-debian12-docker-install.sh" ;;
    esac
    download_verified "${SNIPPET_BASE}/${script_name}" "$TMP_DIR/$script_name" "$script_name"
    chmod +x "$TMP_DIR/$script_name"
    log "Установка Docker (может занять несколько минут)…"
    bash "$TMP_DIR/$script_name" || die "Установка Docker не удалась."

    local target_user="${SUDO_USER:-$USER}"
    if [[ -n "$target_user" && "$target_user" != "root" ]]; then
        log "Добавление $target_user в docker-группу…"
        if download_verified "${SNIPPET_BASE}/02-docker-add-user.sh" "$TMP_DIR/02-docker-add-user.sh" "02-docker-add-user.sh" 2>/dev/null; then
            chmod +x "$TMP_DIR/02-docker-add-user.sh"
            bash "$TMP_DIR/02-docker-add-user.sh" "$target_user" || warn "usermod не выполнен — повторите вручную."
        else
            warn "Не удалось скачать 02-docker-add-user.sh — пропускается (выполните вручную: sudo usermod -aG docker $target_user)"
        fi
    fi
    ok "Docker готов."
    mark_done step_docker
}

ensure_registry() {
    if step_done step_registry; then
        log "Вход в реестр образов уже выполнен (по state.ini) — пропускается."
        return
    fi
    g_header "Registry: $REGISTRY"
    log "Проверка доступа…"
    if as_docker_user timeout 30 docker manifest inspect "$REGISTRY/afi/klvd/klvd-node:latest" >/dev/null 2>&1; then
        ok "Доступ к реестру образов есть (логин и пароль подошли)."
        mark_done step_registry
        return
    fi

    local code=""
    if command -v curl >/dev/null 2>&1; then
        code="$(timeout 15 curl -sS -o /dev/null -w '%{http_code}' "https://$REGISTRY/v2/" 2>/dev/null || true)"
    fi
    if [[ "$code" == "000" || -z "$code" ]]; then
        warn "Реестр образов $REGISTRY не отвечает по HTTPS. Проверьте наличие доступа к https://registry-ru-2.afi-d.ru/"
    else
        log "Реестр образов отвечает (HTTP $code) — требуется авторизация."
    fi

    if [[ "${KLVD_NONINTERACTIVE:-0}" == "1" ]]; then
        if [[ -z "${KLVD_REGISTRY_USER:-}" || -z "${KLVD_REGISTRY_PASS:-}" ]]; then
            warn "Non-interactive: учётные данные реестра не заданы (KLVD_REGISTRY_USER/KLVD_REGISTRY_PASS) — вход пропускается."
            warn "Получите логин и пароль на https://klvd.ru/ и выполните: docker login $REGISTRY, затем перезапустите."
            return
        fi
        if ! printf '%s' "$KLVD_REGISTRY_PASS" | as_docker_user docker login "$REGISTRY" --username "$KLVD_REGISTRY_USER" --password-stdin; then
            die "Вход в реестр не удался (KLVD_REGISTRY_USER=$KLVD_REGISTRY_USER). Проверьте пару логин-пароль."
        fi
        if ! as_docker_user timeout 30 docker manifest inspect "$REGISTRY/afi/klvd/klvd-node:latest" >/dev/null 2>&1; then
            die "Логин принят, но образ САП «Клавдий» недоступен. Обратитесь в техподдержку support@afi-d.ru"
        fi
        ok "Доступ к реестру образов подтверждён (non-interactive)."
        mark_done step_registry
        return
    fi

    if g_confirm "Есть логин и пароль от производителя для установки САП «Клавдий»?" true; then
        log "Учётные данные нужны для скачивания закрытых образов САП «Клавдий» из реестра."
        log "Логин и пароль выдаёт производитель; пароль вводится скрытно и не сохраняется в state.ini."
        local user pass
        user="$(g_input "Логин для реестра образов")"
        [[ -n "$user" ]] || die "Логин пуст."
        pass="$(g_password "Пароль для реестра образов")"
        [[ -n "$pass" ]] || die "Пароль пуст."
        if ! printf '%s' "$pass" | as_docker_user docker login "$REGISTRY" --username "$user" --password-stdin; then
            die "Войти не удалось. Проверьте правильность пары логин-пароль и перезапустите установку: $0 --reset"
        fi
        if ! as_docker_user timeout 30 docker manifest inspect "$REGISTRY/afi/klvd/klvd-node:latest" >/dev/null 2>&1; then
            die "Логин принят, но образ САП «Клавдий» недоступен. Обратитесь в техническую поддержку support@afi-d.ru"
        fi
        ok "Доступ к реестру образов подтверждён."
        mark_done step_registry
    else
        warn "Вход в реестр образов пропускается. Получите логин и пароль для доступа на веб-сайте https://klvd.ru/ и перезапустите установку: $0 --reset"
    fi
}

run_poll() {
    if step_done step_poll; then
        log "Опрос уже пройден (по state.ini) — загружаются выборы."
        JOIN_CLUSTER="$(state_get join_cluster)"
        ROLES="$(state_get roles)"
        LOCAL_SOLR="$(state_get local_solr)"
        LOGS_DIR="$(state_get logs_dir)"
        STORAGE_DATA_DIR="$(state_get storage_data_dir)"
        SEARCH_INDEX_DIR="$(state_get search_index_dir)"
        SECRET_MODE="$(state_get secret_mode)"
        LOCAL_PG="$(state_get local_pg)"
        PG_HOST="$(state_get pg_host)"
        PG_PORT="$(state_get pg_port)"
        PG_DB="$(state_get pg_db)"
        PG_USER="$(state_get pg_user)"
        PG_CONF_DIR="$(state_get pg_conf_dir)"
        WEB_PORT="$(state_get web_port)"; [[ -n "$WEB_PORT" ]] || WEB_PORT="8001"
        WEB_HTTP_PORT="$(state_get web_http_port)"; [[ -n "$WEB_HTTP_PORT" ]] || WEB_HTTP_PORT=""
        return
    fi

    g_header "Настройки узла"

    # ── Non-interactive: все параметры из ENV, дефолт — «всё-в-одном» ──────────
    if [[ "${KLVD_NONINTERACTIVE:-0}" == "1" ]]; then
        JOIN_CLUSTER="${KLVD_JOIN_CLUSTER:-no}"
        ROLES="${KLVD_ROLES:-storage,importer,indexer,imap,smtp}"
        LOCAL_SOLR="${KLVD_LOCAL_SOLR:-yes}"
        WEB_PORT="${KLVD_WEB_PORT:-8001}"
        WEB_HTTP_PORT="${KLVD_WEB_HTTP_PORT:-}"
        LOGS_DIR="${KLVD_LOGS_DIR:-$INSTALL_DIR/logs}"
        STORAGE_DATA_DIR="${KLVD_STORAGE_DATA_DIR:-$INSTALL_DIR/data-storage}"
        SEARCH_INDEX_DIR="${KLVD_SEARCH_INDEX_DIR:-$INSTALL_DIR/search-index}"
        LOCAL_PG="${KLVD_LOCAL_PG:-yes}"
        PG_HOST="${KLVD_PG_HOST:-127.0.0.1}"
        PG_PORT="${KLVD_PG_PORT:-5432}"
        PG_DB="${KLVD_PG_DB:-klvd}"
        PG_CONF_DIR="${KLVD_PG_CONF_DIR:-$INSTALL_DIR/postgresql}"
        SECRET_MODE="stdin"
        SECRET_KEY="${KLVD_SECRET_KEY:-$KLVD_DEFAULT_SECRET_KEY}"

        if [[ "$LOCAL_PG" != "yes" ]]; then
            # Внешняя СУБД — пользователь и пароль обязательны.
            [[ -n "${KLVD_PG_USER:-}" ]]    || die "Non-interactive external PG: укажите KLVD_PG_USER."
            [[ -n "${KLVD_PG_PASSWORD:-}" ]] || die "Non-interactive external PG: укажите KLVD_PG_PASSWORD."
            PG_USER="$KLVD_PG_USER"
            PG_PASSWORD="$KLVD_PG_PASSWORD"
        else
            PG_USER="${KLVD_PG_USER:-klvd_app_$(head -c 16 /dev/urandom | base64 | tr -dc 'a-z0-9' | head -c 10)}"
            # PG_PASSWORD для local автогенерируется в write_env_and_secrets.
        fi

        # Локальный Solr → принудительно добавляем indexer, если его нет.
        if [[ "$LOCAL_SOLR" == "yes" ]]; then
            [[ -z "$ROLES" || ",${ROLES}," != *",indexer,"* ]] && ROLES="${ROLES:+${ROLES},}indexer"
        fi
        # WUI — всегда.
        ROLES="${ROLES:+${ROLES},}wui"

        log "Non-interactive режим «всё-в-одном»:"
        log "  JOIN_CLUSTER=$JOIN_CLUSTER  ROLES=$ROLES  LOCAL_SOLR=$LOCAL_SOLR"
        log "  WEB_PORT=$WEB_PORT  LOCAL_PG=$LOCAL_PG  PG_HOST=$PG_HOST  PG_DB=$PG_DB"
        [[ "$LOCAL_PG" != "yes" ]] && log "  (внешняя СУБД: $PG_HOST:$PG_PORT/$PG_DB user=$PG_USER)"
        [[ -z "${KLVD_SECRET_KEY:-}" ]] && warn "Используется стандартный секрет non-interactive — смените его для продакшн."

        state_set join_cluster "$JOIN_CLUSTER"
        state_set roles "$ROLES"
        state_set local_solr "$LOCAL_SOLR"
        state_set logs_dir "$LOGS_DIR"
        state_set storage_data_dir "$STORAGE_DATA_DIR"
        state_set search_index_dir "$SEARCH_INDEX_DIR"
        state_set secret_mode "$SECRET_MODE"
        state_set local_pg "$LOCAL_PG"
        state_set pg_host "$PG_HOST"
        state_set pg_port "$PG_PORT"
        state_set pg_db "$PG_DB"
        state_set pg_user "$PG_USER"
        state_set pg_conf_dir "$PG_CONF_DIR"
        state_set web_port "$WEB_PORT"
        state_set web_http_port "$WEB_HTTP_PORT"
        mark_done step_poll
        return
    fi
    # ── конец non-interactive блока ────────────────────────────────────────────

    log "Режим установки определяет, подключается ли узел к действующему кластеру САП «Клавдий»"
    log "или разворачивается как самостоятельный узел (новая установка)."
    if g_confirm "Добавить узел в существующий кластер?" false; then
        JOIN_CLUSTER="yes"
        log "Режим: подключение к кластеру."
        log "Понадобятся: параметры внешней СУБД кластера и действующий секретный ключ кластера."
    else
        JOIN_CLUSTER="no"
        log "Режим: новая установка."
    fi

    log "Роли определяют, какие службы запустятся на узле. Панель управления включена всегда."
    log "Роли можно включать и отключать позже через панель — выбор задаёт лишь разрешённый набор."
    ROLES="$(
        g_choose_many "Роли узла (можно выбрать несколько):" \
            "storage| Хранилище — Этот узел будет хранить содержимое писем.'" \
            "importer| Импорт — Этот узел будет собирать письма из почтовых ящиков." \
            "indexer| Индексатор — Этот узел будет разбирать письма и вложения для создания поискового индекса " \
            "imap| IMAP-сервер — Этот узел будет принимать подключения от почтовых клиентов сотрудников для предоставления доступа к их архиву." \
            "smtp| SMTP-приёмник — Этот узел будет принимать копию SMTP-трафика для архивации писем." \
        | paste -sd ',' -
    )"

    if [[ -z "$ROLES" ]]; then
        log "Роли: не выбрано — узел запустится в режиме панели управления."
    else
        log "Роли: $ROLES"
    fi

    log "Solr обеспечивает полнотекстовый поиск по письмам и вложениям."
    log "Локальный Solr подходит для одиночного узла и небольших инсталляций."
    log "В кластере Solr обычно разворачается отдельно — тогда локальный не нужен."
    if g_confirm "Поставить локальный Solr для полнотекстового поиска?" true; then
        LOCAL_SOLR="yes"
    else
        LOCAL_SOLR="no"
        warn "Для полнотекстового поиска подключите Solr через панель управления."
    fi

    if [[ "$LOCAL_SOLR" == "yes" ]]; then
        if [[ -z "$ROLES" || ",${ROLES}," != *",indexer,"* ]]; then
            ROLES="${ROLES:+${ROLES},}indexer"
            log "Выбрана установка локального Solr: добавлена роль 'Индексатор'."
        fi
    fi

    ROLES="${ROLES:+${ROLES},}wui"

    log "Порт панели управления (WUI) — веб-интерфейс администрирования. Убедитесь, что порт свободен."
    while true; do
        local p; p="$(g_input "На каком порту запустить панель управления?" "$WEB_PORT")"
        if [[ "$p" =~ ^[0-9]+$ && "$p" -ge 1 && "$p" -le 65535 ]]; then
            WEB_PORT="$p"; break
        fi
        warn "«$p» — не похоже на порт. Введите число от 1 до 65535."
    done
    log "Порт панели управления: $WEB_PORT"

    # #66: панель работает по HTTPS. При заходе по http:// на TLS-порт соединение рвётся
    # (TLS-handshake), поэтому редирект сделан на ОТДЕЛЬНОМ plaintext-порту: при заходе
    # по http://host:<этот_порт> узел ответит 308-редиректом на https://host:<WEB_PORT>.
    # Пусто — не поднимать redirect-listener (текущее поведение). За TLS-терминирующим
    # обратным прокси (который сам редиректит) — оставьте пустым.
    log "Порт редиректа http→https: отдельный plaintext-порт, с которого посетитель"
    log "перенаправляется на HTTPS-панель. Оставьте пустым, если панель за обратным прокси."
    while true; do
        local hp; hp="$(g_input "Порт редиректа http→https (пусто — без редиректа)" "$WEB_HTTP_PORT")"
        if [[ -z "$hp" ]]; then WEB_HTTP_PORT=""; break; fi
        if [[ "$hp" =~ ^[0-9]+$ && "$hp" -ge 1 && "$hp" -le 65535 && "$hp" != "$WEB_PORT" ]]; then
            WEB_HTTP_PORT="$hp"; break
        fi
        warn "«$hp» — не похоже на порт либо совпадает с портом панели. Введите число от 1 до 65535, не равное $WEB_PORT, или оставьте пустым."
    done
    if [[ -n "$WEB_HTTP_PORT" ]]; then
        log "Порт редиректа: $WEB_HTTP_PORT → https://…:$WEB_PORT"
    else
        log "Редирект http→https: выключен."
    fi

    g_header "Папки данных"
    log "Папки используются для хранения логов, содержимого писем и поискового индекса."
    log "Убедитесь, что на выбранном диске достаточно места под ожидаемый объём архива."
    LOGS_DIR="$(g_input "Папка логов" "$INSTALL_DIR/logs")"
    [[ -n "$LOGS_DIR" ]] || LOGS_DIR="$INSTALL_DIR/logs"

    if [[ "$ROLES" == *"storage"* ]]; then
        log "Для роли «Хранилище» потребуется папка под содержимое писем — это основное место архива."
        STORAGE_DATA_DIR="$(g_input "Путь, где будет храниться содержимое писем" "$INSTALL_DIR/data-storage")"
        [[ -n "$STORAGE_DATA_DIR" ]] || STORAGE_DATA_DIR="$INSTALL_DIR/data-storage"
    else
        STORAGE_DATA_DIR="$INSTALL_DIR/data-storage"
    fi

    if [[ "$LOCAL_SOLR" == "yes" ]]; then
        log "Локальный Solr хранит поисковый индекс отдельной папкой."
        SEARCH_INDEX_DIR="$(g_input "Путь, где будет храниться поисковый индекс" "$INSTALL_DIR/search-index")"
        [[ -n "$SEARCH_INDEX_DIR" ]] || SEARCH_INDEX_DIR="$INSTALL_DIR/search-index"
    else
        SEARCH_INDEX_DIR="$INSTALL_DIR/search-index"
    fi

    if [[ "$JOIN_CLUSTER" == "yes" ]]; then
        LOCAL_PG="no"
        g_header "Подключение к СУБД кластера"
        log "Узел подключается к внешней PostgreSQL-совместимой СУБД действующего кластера."
        log "Локальная СУБД в этом режиме не используется."
        log "Указанный пользователь должен обладать всеми правами в рамках БД САП «Клавдий»"
        log "(создание и изменение таблиц, индексов, схем) — приложение само применяет миграции."
        PG_HOST="$(g_input "Адрес сервера PostgreSQL-совместимого сервера")"
        PG_PORT="$(g_input "Порт подключения" "5432")"
        PG_DB="$(g_input "Название базы данных САП «Клавдий»" "klvd")"
        PG_USER="$(g_input "Имя пользователя с правами работы с базой данных")"
        PG_PASSWORD="$(g_secret "Пароль пользователя" "pg_password")" \
            || die "Пароль не задан."

        g_header "Секретный ключ кластера"
        log "Секретный ключ кластера используется для HMAC и шифрования. Он един для всех узлов"
        log "кластера — введите действующий ключ, которым уже пользуются узлы кластера."
        log "Без верного ключа узел не сможет присоединиться к кластеру."
        SECRET_KEY="$(g_secret "Секретный ключ кластера" "klvd_secret_key")" \
            || die "Секретный ключ не задан."
        SECRET_MODE="stdin"

    else
        g_header "Секретный ключ узла"
        log "Секретный ключ узла используется для HMAC и шифрования данных. Достаточно длинной"
        log "случайной строки. Для самостоятельного узла новый ключ генерируется здесь."
        log "Если позже этот узел станет кластерным, ключ должен совпасть с ключом остальных узлов."
        SECRET_KEY="$(g_secret "Секретный ключ узла" "klvd_secret_key")" \
            || die "Секретный ключ не задан."
        SECRET_MODE="stdin"

        g_header "База данных"
        log "Локальная СУБД PostgreSQL разворачивается вместе с узлом (логин и пароль генерируются"
        log "автоматически). Для внешней СУБД — подготовьте базу и пользователя заранее."
        if g_confirm "Установить локальную СУБД PostgreSQL?" true; then
            LOCAL_PG="yes"
            PG_HOST="127.0.0.1"
            PG_DB="klvd"

            PG_USER="klvd_app_$(head -c 16 /dev/urandom | base64 | tr -dc 'a-z0-9' | head -c 10)"
            PG_PASSWORD="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)"
            log "Логин и пароль для локальной СУБД PostgreSQL сгенерированы автоматически и сохранены в secrets/."

            log "Папка конфигурации хранит postgresql.conf, pg_hba.conf и init.sql."
            PG_CONF_DIR="$(g_input "Путь для размещения конфигурации СУБД PostgreSQL" "$INSTALL_DIR/postgresql")"
            [[ -n "$PG_CONF_DIR" ]] || PG_CONF_DIR="$INSTALL_DIR/postgresql"
        else
            LOCAL_PG="no"
            g_header "Подключение к внешней СУБД"
            log "Узел подключается к внешней PostgreSQL-совместимой СУБД. Подготовьте базу заранее."
            log "Указанный пользователь должен обладать всеми правами в рамках указанной базы данных"
            log "(создание и изменение таблиц, индексов, схем) — приложение само применяет миграции."
            log "Поддерживается PostgreSQL 14+ и совместимые СУБД."
            PG_HOST="$(g_input "Адрес сервера")"
            PG_PORT="$(g_input "Порт подключения" "5432")"
            PG_DB="$(g_input "Название созданной базы данных" "klvd")"
            PG_USER="$(g_input "Имя пользователя с правами работы с базой данных")"
            PG_PASSWORD="$(g_secret "Пароль пользователя" "pg_password")" \
                || die "Пароль не задан."
        fi
    fi

    g_header "Сертификат панели управления"
    log "Панель администрирования всегда включена и требует TLS. На первое время генерируется"
    log "самоподписанный сертификат — браузер предупредит о нём, это нормально для начала работы."
    log "Сертификат можно заменить на свой через панель администрирования или API."
    if [[ "$JOIN_CLUSTER" == "yes" ]]; then
        log "При подключении к кластеру сертификат будет загружен из хранилища сертификатов кластера."
    fi

    state_set join_cluster "$JOIN_CLUSTER"
    state_set roles "$ROLES"
    state_set local_solr "$LOCAL_SOLR"
    state_set logs_dir "$LOGS_DIR"
    state_set storage_data_dir "$STORAGE_DATA_DIR"
    state_set search_index_dir "$SEARCH_INDEX_DIR"
    state_set secret_mode "$SECRET_MODE"
    state_set local_pg "$LOCAL_PG"
    state_set pg_host "$PG_HOST"
    state_set pg_port "$PG_PORT"
    state_set pg_db "$PG_DB"
    state_set pg_user "$PG_USER"
    state_set pg_conf_dir "$PG_CONF_DIR"
    state_set web_port "$WEB_PORT"
    state_set web_http_port "$WEB_HTTP_PORT"
    mark_done step_poll
}

write_pg_conf() {
cat <<'PGCONF'
# Конфигурация PostgreSQL для локальной установки САП «Клавдий».

# CONNECTIONS
# Single-node install (host network): klvd-node подключается к 127.0.0.1 → loopback-only.
# Для bridge-сети или multi-node — расширьте listen_addresses и pg_hba.
listen_addresses = '127.0.0.1'
max_connections = 200

# MEMORY (умеренные defaults; тюнить под RAM хоста)
shared_buffers = 256MB
effective_cache_size = 1GB
work_mem = 16MB
maintenance_work_mem = 256MB

# WAL
wal_level = replica
synchronous_commit = on
wal_compression = on
max_wal_size = 2GB
min_wal_size = 512MB

# CHECKPOINT
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9

# PARALLELISM
max_worker_processes = 4
max_parallel_workers = 4
max_parallel_workers_per_gather = 2

# AUTOVACUUM
autovacuum = on
autovacuum_max_workers = 3

# LOGGING
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d.log'
log_min_duration_statement = 1000ms
log_checkpoints = on
PGCONF
}

write_pg_hba() {
cat <<'PGHBA'
# pg_hba.conf для локальной установки САП «Клавдий».
# Single-node (host network): klvd_node подключается к 127.0.0.1 → только loopback.
# Для bridge-сети/multi-node — добавьте строки под ваши docker-сети:
#   host all all 172.16.0.0/12 scram-sha-256
host    all             all             127.0.0.1/32            scram-sha-256
host    all             all             ::1/128                 scram-sha-256
PGHBA
}

write_pg_init() {
cat <<'PGINIT'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gin;
PGINIT
}

write_env_and_secrets() {
    if step_done step_env; then
        log "Файлы .env/secrets уже созданы (по state.ini) — пропускается."
        return
    fi
    g_header "Конфигурация: .env + secrets"

    if [[ "$LOCAL_PG" == "yes" && -n "$PG_CONF_DIR" ]]; then
        mkdir -p "$PG_CONF_DIR"
        case "$PG_CONF_DIR" in
            /*) ;;
            *)  PG_CONF_DIR="$(cd "$PG_CONF_DIR" && pwd)";;
        esac
        state_set pg_conf_dir "$PG_CONF_DIR"
    fi

    local secrets_dir="$INSTALL_DIR/secrets"
    mkdir -p "$secrets_dir" "$LOGS_DIR" "$STORAGE_DATA_DIR"
    [[ "$LOCAL_SOLR" == "yes" ]] && mkdir -p "$SEARCH_INDEX_DIR"
    mkdir -p "$TMP_DIR/klvd"

    if [[ "$SECRET_MODE" == "stdin" ]]; then
        if [[ -z "$SECRET_KEY" ]]; then
            if [[ "$JOIN_CLUSTER" == "yes" ]]; then
                SECRET_KEY="$(g_password "Секретный ключ кластера (повторно)")"
            else
                SECRET_KEY="$(g_password "Секретный ключ узла (повторно)")"
            fi
            [[ -n "$SECRET_KEY" ]] || die "Секретный ключ пуст."
        fi
        printf '%s' "$SECRET_KEY" > "$secrets_dir/klvd_secret_key"
    fi
    chmod 600 "$secrets_dir/klvd_secret_key" 2>/dev/null || true

    if [[ -z "$PG_PASSWORD" ]]; then
        if [[ "$LOCAL_PG" == "yes" ]]; then
            [[ -z "$PG_USER" ]] && PG_USER="klvd_app_$(head -c 16 /dev/urandom | base64 | tr -dc 'a-z0-9' | head -c 10)"
            PG_PASSWORD="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)"
            warn "Пароль от СУБД PostgreSQL не сохранён — сгенерирован заново."
        else
            PG_PASSWORD="$(g_password "Введите повторно пароль от пользователя PostgreSQL-совместимой СУБД")"
            [[ -n "$PG_PASSWORD" ]] || die "Пароль не задан."
        fi
    fi
    printf '%s' "$PG_USER"     > "$secrets_dir/pg_user"
    printf '%s' "$PG_PASSWORD" > "$secrets_dir/pg_password"
    chmod 600 "$secrets_dir/pg_user" "$secrets_dir/pg_password"

    if [[ ! -f "$secrets_dir/ssl_cert" ]]; then
        run_spin "Генерация самоподписанного сертификата" \
            openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
                -keyout "$TMP_DIR/ssl_key.pem" -out "$TMP_DIR/ssl_cert.pem" \
                -subj "/CN=klvd-wui" 2>/dev/null \
            || die "openssl не выполнен (cert)."
        cat "$TMP_DIR/ssl_cert.pem" "$TMP_DIR/ssl_key.pem" > "$secrets_dir/ssl_cert"
        rm -f "$TMP_DIR/ssl_key.pem" "$TMP_DIR/ssl_cert.pem"
        chmod 600 "$secrets_dir/ssl_cert"
    fi

    {
        echo "# Конфигурация САП «Клавдий» (не-секретные параметры)."
        echo "# Секреты (пароли, ключи, сертификат) — в папке secrets/ (chmod 600), не здесь."
        echo "# Изменения применяются перезапуском: cd $INSTALL_DIR && docker compose up -d --force-recreate"
        echo "#"

        echo "# Тег Docker-образа узла (latest — последний релиз; для прод-пина используйте update.sh)."
        echo "KLVD_IMAGE_TAG=${IMAGE_TAG}"
        echo "# Разрешённые роли узла (cap, через запятую). Реальное вкл/выкл служб — через панель."
        echo "# Возможные: wui, storage, importer, indexer, imap, smtp. wui добавляется принудительно."
        echo "KLVD_ROLES=${ROLES}"
        echo

        echo "# Порт веб-панели управления (WUI). Должен быть свободен на хосте."
        echo "KLVD_WEB_LISTEN_PORT=${WEB_PORT}"
        echo "# Порт редиректа http→https (пусто — без редиректа). При заходе по http://host:<этот_порт>"
        echo "# узел отвечает 308-редиректом на https://host:<KLVD_WEB_LISTEN_PORT>. Панель за обратным"
        echo "# прокси с собственным редиректом — оставьте пустым."
        echo "KLVD_WEB_HTTP_PORT=${WEB_HTTP_PORT}"
        echo

        echo "# --- PostgreSQL-совместимая СУБД ---"
        echo "# Адрес сервера СУБД (127.0.0.1 — локальная, либо адрес внешней СУБД)."
        echo "POSTGRESQL_HOST=${PG_HOST}"
        echo "# Порт подключения (по умолчанию 5432)."
        echo "POSTGRESQL_PORT=${PG_PORT}"
        echo "# Название базы данных САП «Клавдий»."
        echo "POSTGRESQL_DB=${PG_DB}"
        echo "# Логин и пароль — в secrets/pg_user и secrets/pg_password (не в этом файле)."
        echo "# Режим SSL-подключения к СУБД:"
        echo "#   disable  — без шифрования (только для доверенной сети)."
        echo "#   prefer   — использовать SSL, если сервер его поддерживает, иначе без шифрования."
        echo "#   require  — обязательно шифрование (отказ, если СУБД не поддерживает SSL)."
        echo "#   verify-ca / verify-full — SSL с проверкой сертификата сервера."
        echo "# Для локальной СУБД достаточно prefer; для внешней по публичной сети — require/verify-full."
        echo "POSTGRESQL_SSL=prefer"

        if [[ "$LOCAL_PG" == "yes" && -n "$PG_CONF_DIR" ]]; then
            echo "# Папка с конфигурацией локальной СУБД (postgresql.conf, pg_hba.conf, init.sql)."
            echo "POSTGRESQL_CONF_HOST=${PG_CONF_DIR}"
        fi
        echo

        echo "# --- Папки данных на хосте (монтируются в контейнеры) ---"
        echo "# Содержимое писем (роль storage). Основное место архива — выбирайте диск достаточного объёма."
        echo "KLVD_STORAGE_DATA_HOST=${STORAGE_DATA_DIR}"
        echo "# Логи приложения."
        echo "KLVD_LOGS_HOST=${LOGS_DIR}"
        echo "# Временные файлы (автоматически очищается)."
        echo "KLVD_TMP_HOST=${TMP_DIR/klvd}"
        echo "# Поисковый индекс Solr (при локальном Solr)."
        echo "KLVD_SEARCH_INDEX_HOST=${SEARCH_INDEX_DIR}"
        echo

        if [[ "$ROLES" == *"smtp"* ]]; then
            echo "# Очередь исходящих писем SMTP (роль smtp). Persistent-путь — переживает рестарт."
            echo "KLVD_SMTP_QUEUE_DIR=/var/klvd/smtp-queue"
            echo
        fi

        echo "# --- Полнотекстовый поиск (Solr) ---"
        echo "# URL Solr. При локальном Solr — https://127.0.0.1:8983/solr (HTTPS на WUI-серте"
        echo "# из secrets/ssl_cert; self-signed, verify отключён внутри узла)."
        echo "# При внешнем Solr — укажите его адрес (http://host:8983 или https://...);"
        echo "# пусто — поиск отключён (подключается через панель)."
        if [[ "$LOCAL_SOLR" == "yes" ]]; then
            echo "KLVD_SOLR_URL=https://127.0.0.1:8983/solr"
        else
            echo "KLVD_SOLR_URL="
        fi
    } > "$INSTALL_DIR/.env"
    chmod 600 "$INSTALL_DIR/.env"

    if [[ "$LOCAL_PG" == "yes" && -n "$PG_CONF_DIR" ]]; then
        mkdir -p "$PG_CONF_DIR"
        [[ -f "$PG_CONF_DIR/postgresql.conf" ]] || write_pg_conf > "$PG_CONF_DIR/postgresql.conf"
        [[ -f "$PG_CONF_DIR/pg_hba.conf" ]]     || write_pg_hba  > "$PG_CONF_DIR/pg_hba.conf"
        [[ -f "$PG_CONF_DIR/init.sql" ]]        || write_pg_init > "$PG_CONF_DIR/init.sql"
    fi

    own_tree "$INSTALL_DIR"
    ok "Конфигурация создана."
    mark_done step_env
}

fetch_compose() {
    if step_done step_compose; then
        log "docker-compose.yml уже на месте (по state.ini) — пропускается."
        return
    fi
    g_header "docker-compose.yml"
    download_verified "$COMPOSE_URL" "$INSTALL_DIR/docker-compose.yml" "docker-compose.yml" \
        || die "Не удалось скачать docker-compose.yaml ($COMPOSE_URL). Проверьте доступ к https://static.afi-d.ru/"

    local tmp="$TMP_DIR/compose.processed.yml"
    python3 - "$INSTALL_DIR/docker-compose.yml" "$LOCAL_PG" "$LOCAL_SOLR" > "$tmp" <<'PY'
import re, sys
path, local_pg, local_solr = sys.argv[1], sys.argv[2], sys.argv[3]
s = open(path).read()
def keep_block(marker, do_keep):
    pat = re.compile(r'# \{\{%s\}\}(.*?)# \{\{/%s\}\}' % (marker, marker), re.S)
    def repl(m):
        return m.group(1) if do_keep == "yes" else ""
    return pat.sub(repl, s)
s = keep_block("POSTGRES", local_pg)
s = keep_block("PG_DEPS", local_pg)
s = keep_block("SOLR_DEPS", local_solr)
s = keep_block("SOLR", local_solr)
s = re.sub(r'\n{3,}', '\n\n', s)
# Удалить пустой depends_on: (если оба блока вырезаны — join без PG/Solr).
# depends_on: с записями имеет отступ 6+ spaces у children; пустой — следующий
# non-blank на отступе 0-4 (top-level key). Lookahead: 0-4 whitespace + non-ws.
s = re.sub(r'(?m)^    depends_on:\s*\n(?=\s{0,4}\S)', '', s)
print(s, end="")
PY
    mv "$tmp" "$INSTALL_DIR/docker-compose.yml"
    own_tree "$INSTALL_DIR/docker-compose.yml"
    mark_done step_compose
}

compose_up() {
    if step_done step_up; then
        log "Стек уже поднят (по state.ini)."
        as_compose ps
        return
    fi
    g_header "Запуск стека"
    log "Загрузка образов…"
    as_compose pull || warn "Загрузка не выполнена — возможна проблема с доступом к https://registry-ru-2.afi-d.ru, продолжается с локальным образом"
    log "Запуск контейнеров…"
    as_compose up -d || die "docker compose up не выполнен."
    ok "Стек поднят. Статус:"
    as_compose ps
    mark_done step_up
}

install_psql_helper() {
    g_header "Утилита klvd-psql"
    local target="$INSTALL_DIR/klvd-psql"
    # Канонический klvd-psql.sh живёт рядом с install.sh на static.afi-d.ru и
    # обновляется отдельно (update-checksums.sh + upload-installer.sh). Скачиваем
    # его (с проверкой .sha256) вместо inline-heredoc — чтобы и свежая установка,
    # и обновление klvd-psql на существующих узлах брали один и тот же файл.
    download_verified "$KLVD_PSQL_URL" "$target" "klvd-psql" \
        || die "Не удалось загрузить klvd-psql ($KLVD_PSQL_URL)."
    chmod 755 "$target"
    own_tree "$target"

    # При внешней СУБД klvd-psql использует эфемерный контейнер postgres:16 как
    # psql-клиент. Вытянем образ сразу (warn при неудаче — klvd-psql сам ретраит).
    if [[ "$LOCAL_PG" != "yes" ]]; then
        log "Для klvd-psql при внешней СУБД используется образ-клиент postgres:16."
        if docker image inspect postgres:16 >/dev/null 2>&1; then
            log "Образ postgres:16 уже присутствует."
        elif docker pull postgres:16 >/dev/null 2>&1; then
            ok "Образ postgres:16 (psql-клиент) вытянут."
        else
            warn "Не удалось вытянуть postgres:16 (Docker Hub недоступен?). klvd-psql попробует снова при первом запуске; для air-gapped — docker pull postgres:16 на машине с доступом и docker save/load сюда."
        fi
    fi

    if [[ -w /usr/local/bin ]]; then
        ln -sf "$target" /usr/local/bin/klvd-psql
        ok "Утилита klvd-psql установлена (в PATH). Запуск: klvd-psql"
    else
        ln -sf "$target" /usr/local/bin/klvd-psql 2>/dev/null || true
        ok "Утилита klvd-psql установлена: $target"
    fi
}

main() {
    need_root

    if [[ "${1:-}" == "--reset" ]]; then
        rm -f "$STATE_FILE"
        log "Сброшен прогресс установки (удалён $STATE_FILE)."
    fi
    g_header "Установка САП «Клавдий»"
    log "state.ini: $STATE_FILE"
    log "Установку можно прервать и продолжить позже — пройденные шаги сохраняются и не повторяются."
    log "Секреты (пароли, ключи) в state.ini не хранятся — при возобновлении их нужно ввести заново."
    log "Чтобы начать установку заново, выполните: $0 --reset"

    ask_install_dir
    make_tmp
    fetch_gum
    detect_distro
    ensure_docker
    ensure_registry
    run_poll
    write_env_and_secrets
    fetch_compose
    compose_up
    install_psql_helper

    ok "Готово. Панель управления доступна по адресу: http://<host>:${WEB_PORT}"
    log "Дальнейшая настройка выполняется через панель управления."
    log "Логин и пароль по умолчанию: admin/admin — смените пароль при первом входе."
    log "Запуск и остановка сервиса: cd $INSTALL_DIR && docker compose up -d | down"
}

main "$@"
