#!/usr/bin/env bash
# DFIRe release-bundle installer
# https://dfire.fi/install.sh

INSTALLER_VERSION=18

set -Eeuo pipefail

RELEASE_BASE_URL="${DFIRE_RELEASE_BASE_URL:-https://dfire.fi/release}"
CURRENT_VERSION_URL="${DFIRE_CURRENT_VERSION_URL:-https://dfire.fi/dfire_current_version.txt}"
INSTALLER_URL="${DFIRE_INSTALLER_URL:-https://dfire.fi/install.sh}"
INSTALL_DIR="${DFIRE_INSTALL_DIR:-$(pwd)}"
ENV_FILE=""
VERSION_FILE=""
PENDING_FILE=""
TARGET_VERSION=""
NON_INTERACTIVE=false
DATABASE_MODE=""
DATABASE_URL_INPUT=""
DIRECT_DATABASE_URL_INPUT=""
DEPLOYMENT_MODE=""
DFIRE_HOSTNAME=""
FRONTEND_BIND_INPUT=""
ADMIN_EMAIL=""
ADMIN_USERNAME=""
ADMIN_PASSWORD=""
GENERATED_ADMIN_PASSWORD=false
# generate (a new installation) or existing (rebuilding one from a backup).
KEY_SOURCE=""
SUPPLIED_SECRET_KEY=""
SUPPLIED_CREDENTIAL_KEY=""
STAGE_DIR=""
TARGET_BUNDLE_DIR=""
SOURCE_BUNDLE_DIR=""
BACKUP_DIR=""
LOCK_DIR=""
TMP_ENV_FILE=""
UPGRADE_REQUESTED=false

# Styling is applied only on a terminal, so captured output and CI logs stay
# plain text. NO_COLOR is honoured for operators who ask for it.
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
    C_BOLD=$'\033[1m'
    C_DIM=$'\033[2m'
    C_RESET=$'\033[0m'
else
    C_BOLD=''
    C_DIM=''
    C_RESET=''
fi

info() { printf '[INFO] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*" >&2; }
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }

section() { printf '\n%s%s%s\n\n' "$C_BOLD" "$1" "$C_RESET"; }

# Explanatory prose beside a prompt. One argument per line.
hint() {
    local line
    for line in "$@"; do
        printf '%s  %s%s\n' "$C_DIM" "$line" "$C_RESET"
    done
}

option() { printf '  %s  %s%s%s\n' "$1" "$C_DIM" "$2" "$C_RESET"; }

usage() {
    printf '%s\n' \
        'Install or upgrade DFIRe from an immutable release bundle.' \
        '' \
        'Usage:' \
        '  ./install.sh [OPTIONS]                    Install into an empty directory' \
        '  ./install.sh --upgrade [VERSION]          Adopt or upgrade an existing installation' \
        '' \
        'Changing an existing installation is never implicit: run it in a directory' \
        'that already holds one and it reports what it found without touching it.' \
        '' \
        'Options:' \
        '  --version VERSION               Select a specific release' \
        '  --upgrade [VERSION]             Adopt or upgrade the installation in this directory' \
        '  --install-dir DIR               Installation directory (default: current)' \
        '  --non-interactive               Never prompt; require configuration flags' \
        '  --database internal|external    Select bundled or operator-managed PostgreSQL' \
        '  --database-url URL              External PostgreSQL application URL' \
        '  --direct-database-url URL       Direct URL when the application URL is pooled' \
        '  --deployment http|external-proxy' \
        '  --hostname HOST                 Public hostname or IP address' \
        '  --frontend-bind IP:PORT:80      Docker frontend port mapping' \
        '  --admin-email EMAIL             Initial administrator email' \
        '  --admin-username USER           Initial administrator username' \
        '  --admin-password PASSWORD       Initial administrator password' \
        '  -h, --help                      Show this help' \
        '' \
        'Rebuilding an installation from a backup:' \
        '  A fresh install generates new encryption keys, and a backup taken by an' \
        '  earlier installation cannot be read with them. SECRET_KEY decrypts the' \
        '  backup archive itself; CREDENTIAL_ENCRYPTION_KEY decrypts the credentials' \
        '  stored inside it. Supply the originals through the environment:' \
        '' \
        '    set -a; . /path/to/old.env; set +a' \
        '    ./install.sh' \
        '' \
        '  SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY are read from the environment,' \
        '  as are DFIRE_SECRET_KEY and DFIRE_CREDENTIAL_ENCRYPTION_KEY. Set both or' \
        '  neither. Without them an interactive run offers to generate a new pair or' \
        '  to enter existing ones; restore the backup after DFIRe starts.' \
        '' \
        'Examples:' \
        '  ./install.sh --version 1.5.5' \
        '  ./install.sh                    # install, adopt, upgrade, or confirm current' \
        '  ./install.sh --non-interactive --database internal --deployment http --hostname localhost'
}

parse_args() {
    while (($#)); do
        case "$1" in
            --version)
                (($# >= 2)) || die "--version requires a value"
                TARGET_VERSION="$2"
                UPGRADE_REQUESTED=true
                shift 2
                ;;
            --upgrade)
                UPGRADE_REQUESTED=true
                shift
                if (($#)) && [[ "$1" != -* ]]; then
                    TARGET_VERSION="$1"
                    shift
                fi
                ;;
            --install-dir)
                (($# >= 2)) || die "--install-dir requires a value"
                INSTALL_DIR="$2"
                shift 2
                ;;
            --non-interactive)
                NON_INTERACTIVE=true
                shift
                ;;
            --database)
                (($# >= 2)) || die "--database requires a value"
                DATABASE_MODE="$2"
                shift 2
                ;;
            --database-url)
                (($# >= 2)) || die "--database-url requires a value"
                DATABASE_URL_INPUT="$2"
                shift 2
                ;;
            --direct-database-url)
                (($# >= 2)) || die "--direct-database-url requires a value"
                DIRECT_DATABASE_URL_INPUT="$2"
                shift 2
                ;;
            --deployment)
                (($# >= 2)) || die "--deployment requires a value"
                DEPLOYMENT_MODE="$2"
                shift 2
                ;;
            --hostname)
                (($# >= 2)) || die "--hostname requires a value"
                DFIRE_HOSTNAME="$2"
                shift 2
                ;;
            --frontend-bind)
                (($# >= 2)) || die "--frontend-bind requires a value"
                FRONTEND_BIND_INPUT="$2"
                shift 2
                ;;
            --admin-email)
                (($# >= 2)) || die "--admin-email requires a value"
                ADMIN_EMAIL="$2"
                shift 2
                ;;
            --admin-username)
                (($# >= 2)) || die "--admin-username requires a value"
                ADMIN_USERNAME="$2"
                shift 2
                ;;
            --admin-password)
                (($# >= 2)) || die "--admin-password requires a value"
                ADMIN_PASSWORD="$2"
                shift 2
                ;;
            -h|--help)
                usage
                exit 0
                ;;
            *)
                die "Unknown option: $1"
                ;;
        esac
    done
}

lock_owner_alive() {
    local owner
    owner=$(head -1 "$1/pid" 2>/dev/null | tr -d '[:space:]' || true)
    [[ "$owner" =~ ^[1-9][0-9]*$ ]] || return 1
    # ps rather than kill -0 so a lock held by another user is still seen.
    ps -p "$owner" >/dev/null 2>&1 || return 1
    printf '%s' "$owner"
}

acquire_lock() {
    local lock="$INSTALL_DIR/.dfire-lock" owner attempts=0
    while ! mkdir "$lock" 2>/dev/null; do
        [[ -d "$lock" ]] || \
            die "Could not create the installer lock in $INSTALL_DIR; run the installer as the owner of that directory"
        if owner=$(lock_owner_alive "$lock"); then
            die "Another installer run (process $owner) is working in $INSTALL_DIR; nothing was changed. Remove $lock if no installer is running."
        fi
        attempts=$((attempts + 1))
        ((attempts <= 3)) || \
            die "Could not take the installer lock in $INSTALL_DIR; remove $lock if no installer is running"
        # A run that has just taken the lock may not have recorded its process
        # yet, so confirm the absence before reclaiming.
        sleep 1
        if owner=$(lock_owner_alive "$lock"); then
            die "Another installer run (process $owner) is working in $INSTALL_DIR; nothing was changed. Remove $lock if no installer is running."
        fi
        warn "Reclaiming an installer lock left behind by a run that is no longer active"
        rm -f -- "$lock/pid"
        rmdir -- "$lock" 2>/dev/null || true
    done
    printf '%s\n' "$$" > "$lock/pid"
    LOCK_DIR="$lock"
}

release_lock() {
    local owner
    [[ -n "$LOCK_DIR" ]] || return 0
    owner=$(head -1 "$LOCK_DIR/pid" 2>/dev/null | tr -d '[:space:]' || true)
    if [[ "$owner" == "$$" ]]; then
        rm -f -- "$LOCK_DIR/pid"
        rmdir -- "$LOCK_DIR" 2>/dev/null || true
    fi
    LOCK_DIR=""
}

cleanup() {
    if [[ -n "$STAGE_DIR" && -d "$STAGE_DIR" ]]; then
        rm -rf -- "$STAGE_DIR"
    fi
    if [[ -n "$TMP_ENV_FILE" && -f "$TMP_ENV_FILE" ]]; then
        rm -f -- "$TMP_ENV_FILE"
    fi
    release_lock
}
trap cleanup EXIT

check_installer_version() {
    local remote_script remote_version
    info "Checking for installer updates"
    if ! remote_script=$(curl -fsSL --connect-timeout 5 --max-time 30 "$INSTALLER_URL" 2>/dev/null); then
        warn "Could not check for installer updates; continuing with installer ${INSTALLER_VERSION}"
        return
    fi
    remote_version=$(awk -F= '/^INSTALLER_VERSION=[0-9]+$/ { print $2; exit }' <<< "$remote_script")
    if [[ ! "$remote_version" =~ ^[0-9]{1,9}$ ]]; then
        warn "Could not determine the published installer version; continuing with installer ${INSTALLER_VERSION}"
        return
    fi
    if ((10#$remote_version > 10#$INSTALLER_VERSION)); then
        {
            printf '[ERROR] This installer is outdated (version %s); version %s is available.\n' \
                "$INSTALLER_VERSION" "$remote_version"
            printf '\nDownload the current installer and rerun the same command:\n\n'
            printf '  curl -fsSL %s -o install.sh\n' "$INSTALLER_URL"
            printf '  chmod +x install.sh\n\n'
        } >&2
        exit 1
    fi
    ok "Installer ${INSTALLER_VERSION} is current"
}

validate_version() {
    [[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
        die "Invalid release version: $1"
}

version_is_less() {
    local left_major left_minor left_patch right_major right_minor right_patch
    IFS=. read -r left_major left_minor left_patch <<< "$1"
    IFS=. read -r right_major right_minor right_patch <<< "$2"
    if ((10#$left_major != 10#$right_major)); then
        ((10#$left_major < 10#$right_major))
    elif ((10#$left_minor != 10#$right_minor)); then
        ((10#$left_minor < 10#$right_minor))
    else
        ((10#$left_patch < 10#$right_patch))
    fi
}

validate_plain_value() {
    local label="$1" value="$2"
    [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label cannot contain a newline"
    [[ "$value" != *"'"* ]] || die "$label cannot contain a single quote"
}

validate_hostname() {
    [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || die "Invalid hostname: $1"
}

validate_frontend_bind() {
    local bind="$1" host_port port
    [[ "$bind" =~ ^[0-9.]+:[0-9]+:80$ ]] || die "Frontend bind must have the form IP:PORT:80"
    host_port="${bind%:*}"
    port="${host_port##*:}"
    ((port >= 1 && port <= 65535)) || die "Frontend host port is out of range"
}

validate_database_url() {
    local label="$1" value="$2"
    [[ "$value" == postgres://* || "$value" == postgresql://* ]] || die "$label must be a PostgreSQL URL"
    validate_plain_value "$label" "$value"
}

validate_admin() {
    if [[ -z "$ADMIN_EMAIL$ADMIN_USERNAME$ADMIN_PASSWORD" ]]; then
        return
    fi
    [[ -n "$ADMIN_EMAIL" && -n "$ADMIN_USERNAME" && -n "$ADMIN_PASSWORD" ]] || \
        die "Administrator email, username, and password must be provided together"
    [[ "$ADMIN_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || die "Invalid administrator email"
    validate_plain_value "Administrator email" "$ADMIN_EMAIL"
    [[ "$ADMIN_USERNAME" =~ ^[A-Za-z][A-Za-z0-9_-]{1,29}$ ]] || die "Invalid administrator username"
    ((${#ADMIN_PASSWORD} >= 12)) || die "Administrator password must be at least 12 characters"
    validate_plain_value "Administrator password" "$ADMIN_PASSWORD"
}

check_prerequisites() {
    local command_name compose_version compose_numeric
    for command_name in curl openssl tar docker; do
        command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
    done
    docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required"
    compose_version=$(docker compose version --short)
    compose_numeric="${compose_version#v}"
    if [[ "$compose_numeric" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
        compose_numeric="${BASH_REMATCH[1]}"
    else
        die "Could not parse Docker Compose version: $compose_version"
    fi
    version_is_less "$compose_numeric" "2.24.4" && \
        die "Docker Compose 2.24.4 or newer is required (found $compose_version)"
    docker info >/dev/null 2>&1 || die "Cannot connect to the Docker daemon"
}

resolve_version() {
    local response
    if [[ -n "$TARGET_VERSION" ]]; then
        validate_version "$TARGET_VERSION"
        return
    fi
    if [[ -e "$PENDING_FILE" || -L "$PENDING_FILE" ]]; then
        validate_pending_marker
        TARGET_VERSION=$(pending_get VERSION)
        info "Resuming the pending DFIRe ${TARGET_VERSION} operation"
        return
    fi
    info "Resolving the current DFIRe release"
    # Check curl on its own so a network failure is the pipeline exit status,
    # not a SIGPIPE from head closing the read early.
    response=$(curl -fsSL --connect-timeout 10 --max-time 30 "$CURRENT_VERSION_URL") || \
        die "Could not read the current release from $CURRENT_VERSION_URL"
    TARGET_VERSION=$(printf '%s\n' "$response" | head -1 | tr -d '[:space:]')
    validate_version "$TARGET_VERSION"
}

file_sha256() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | cut -d' ' -f1
    elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$1" | cut -d' ' -f1
    else
        die "sha256sum or shasum is required"
    fi
}

download_verified_bundle() {
    local version="$1" workspace="$2"
    local release_url archive_name expected actual members expected_members
    release_url="${RELEASE_BASE_URL%/}/${version}"
    archive_name="dfire-${version}.tar.gz"
    mkdir "$workspace"

    info "Downloading DFIRe ${version} release bundle"
    curl -fsSL --connect-timeout 10 --max-time 120 \
        "$release_url/SHA256SUMS" -o "$workspace/SHA256SUMS" || \
        die "Could not download $release_url/SHA256SUMS"
    curl -fsSL --connect-timeout 10 --max-time 300 \
        "$release_url/$archive_name" -o "$workspace/$archive_name" || \
        die "Could not download $release_url/$archive_name"

    expected=$(awk -v file="$archive_name" '$2 == file { print $1 }' "$workspace/SHA256SUMS")
    [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || die "No valid checksum was published for $archive_name"
    actual=$(file_sha256 "$workspace/$archive_name")
    [[ "$actual" == "$expected" ]] || die "Checksum verification failed for $archive_name"
    ok "DFIRe ${version} release bundle checksum verified"

    members=$(tar -tzf "$workspace/$archive_name" | LC_ALL=C sort)
    expected_members=$(printf '%s\n' \
        .env-example README.md compose.external-db.yaml \
        compose.internal-db.yaml compose.yaml | LC_ALL=C sort)
    [[ "$members" == "$expected_members" ]] || \
        die "Release archive does not contain the exact expected file set"
    if ! tar -tvzf "$workspace/$archive_name" | awk '$1 !~ /^-/ { exit 1 }'; then
        die "Release archive contains a non-regular file"
    fi

    mkdir "$workspace/bundle"
    tar --no-same-owner -xzf "$workspace/$archive_name" -C "$workspace/bundle"
    for entry in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        [[ -f "$workspace/bundle/$entry" ]] || die "Release bundle is missing $entry"
    done
}

download_bundle() {
    STAGE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dfire-install.XXXXXX")
    download_verified_bundle "$TARGET_VERSION" "$STAGE_DIR/target"
    TARGET_BUNDLE_DIR="$STAGE_DIR/target/bundle"
}

# Emits one .env assignment. Call it directly in the current shell, never
# inside $( ): die() from a command substitution exits only the subshell, so a
# rejected value would silently reach .env as an empty assignment.
env_assign() {
    local key="$1" value="$2"
    validate_plain_value "Value for $key" "$value"
    printf "%s='%s'\n" "$key" "$value"
}

dotenv_get() {
    local key="$1" file="$2" line value
    [[ -f "$file" ]] || return 1
    line=$(grep -E "^${key}=" "$file" | tail -1 || true)
    [[ -n "$line" ]] || return 1
    value="${line#*=}"
    if [[ "$value" == "'"*"'" && ${#value} -ge 2 ]]; then
        value="${value:1:${#value}-2}"
    elif [[ "$value" == '"'*'"' && ${#value} -ge 2 ]]; then
        value="${value:1:${#value}-2}"
    fi
    printf '%s' "$value"
}

set_env_value() {
    local key="$1" value="$2" file="$3" tmp line found=false
    tmp=$(mktemp "${file}.tmp.XXXXXX")
    TMP_ENV_FILE="$tmp"
    chmod 600 "$tmp"
    while IFS= read -r line || [[ -n "$line" ]]; do
        if [[ "$line" == "$key="* ]]; then
            if [[ "$found" == "false" ]]; then
                env_assign "$key" "$value" >> "$tmp"
                found=true
            fi
        else
            printf '%s\n' "$line" >> "$tmp"
        fi
    done < "$file"
    if [[ "$found" == "false" ]]; then
        printf '\n' >> "$tmp"
        env_assign "$key" "$value" >> "$tmp"
    fi
    mv "$tmp" "$file"
    TMP_ENV_FILE=""
    chmod 600 "$file"
}

marker_get() {
    local key="$1" line
    [[ -f "$VERSION_FILE" ]] || return 1
    line=$(grep -E "^${key}=" "$VERSION_FILE" | tail -1 || true)
    [[ -n "$line" ]] || return 1
    printf '%s' "${line#*=}"
}

pending_get() {
    local key="$1" line
    [[ -f "$PENDING_FILE" ]] || return 1
    line=$(grep -E "^${key}=" "$PENDING_FILE" | tail -1 || true)
    [[ -n "$line" ]] || return 1
    printf '%s' "${line#*=}"
}

validate_pending_marker() {
    local keys expected version project db_mode operation print_admin_password recovery_dir
    [[ -f "$PENDING_FILE" && ! -L "$PENDING_FILE" ]] || \
        die ".dfire-pending must be a regular, non-symlink file"
    keys=$(awk -F= '{ print $1 }' "$PENDING_FILE")
    expected=$(printf '%s\n' \
        VERSION COMPOSE_PROJECT_NAME DATABASE_MODE OPERATION \
        PRINT_ADMIN_PASSWORD RECOVERY_DIR)
    [[ "$keys" == "$expected" ]] || die "Invalid field layout in .dfire-pending"

    version=$(pending_get VERSION || true)
    project=$(pending_get COMPOSE_PROJECT_NAME || true)
    db_mode=$(pending_get DATABASE_MODE || true)
    operation=$(pending_get OPERATION || true)
    print_admin_password=$(pending_get PRINT_ADMIN_PASSWORD || true)
    recovery_dir=$(pending_get RECOVERY_DIR || true)

    validate_version "$version"
    [[ "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || die "Invalid project name in .dfire-pending"
    [[ "$db_mode" == "internal" || "$db_mode" == "external" ]] || \
        die "Invalid database mode in .dfire-pending"
    [[ "$operation" == "fresh" || "$operation" == "legacy" || \
       "$operation" == "manual" || "$operation" == "upgrade" ]] || \
        die "Invalid operation in .dfire-pending"
    [[ "$print_admin_password" == "true" || "$print_admin_password" == "false" ]] || \
        die "Invalid password-display state in .dfire-pending"
    if [[ "$operation" == "fresh" ]]; then
        [[ -z "$recovery_dir" ]] || die "Fresh install pending state cannot name a recovery directory"
    else
        [[ -n "$recovery_dir" ]] || die "Incomplete update has no original recovery directory"
    fi
    validate_recovery_dir "$recovery_dir"
}

project_from_backend_label() {
    local service project
    service=$(docker inspect dfire_backend_prod \
        --format '{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || true)
    project=$(docker inspect dfire_backend_prod \
        --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || true)
    [[ "$service" == "backend" ]] || return 1
    [[ "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || return 1
    printf '%s' "$project"
}

installed_backend_version() {
    local found image
    found=$(docker exec dfire_backend_prod sh -c 'cat /app/VERSION' 2>/dev/null | tr -d '[:space:]' || true)
    if [[ -z "$found" ]]; then
        image=$(docker inspect dfire_backend_prod --format '{{.Image}}' 2>/dev/null || true)
        if [[ -n "$image" ]]; then
            found=$(docker image inspect "$image" \
                --format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null | tr -d '[:space:]' || true)
        fi
    fi
    printf '%s' "${found#v}"
}

validate_installed_version() {
    [[ -n "$1" ]] || die "Could not determine the installed DFIRe backend version"
    validate_version "$1"
}

detect_legacy_install() {
    [[ -f "$ENV_FILE" && -f "$INSTALL_DIR/docker-compose.prod.yml" ]] || return 1
    project_from_backend_label >/dev/null 2>&1
}

detect_manual_bundle() {
    local name
    [[ -f "$ENV_FILE" && ! -L "$ENV_FILE" ]] || return 1
    for name in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        [[ -f "$INSTALL_DIR/$name" && ! -L "$INSTALL_DIR/$name" ]] || return 1
    done
    project_from_backend_label >/dev/null 2>&1
}

detect_incomplete_bundle() {
    [[ -f "$PENDING_FILE" && -f "$ENV_FILE" ]] || return 1
}

# .env is the one installer file an operator recognises as important, and the
# only one that cannot be reconstructed, so its absence stays fatal.
die_missing_env() {
    local backup="" candidate running
    for candidate in "$INSTALL_DIR"/.dfire-backup-*/.env; do
        [[ -f "$candidate" ]] || continue
        if [[ -z "$backup" || "$candidate" > "$backup" ]]; then
            backup="$candidate"
        fi
    done
    running=$(project_from_backend_label || true)
    {
        printf '[ERROR] No .env exists in %s.\n' "$INSTALL_DIR"
        if [[ -n "$running" ]]; then
            printf 'A DFIRe stack is running under Compose project "%s", so this is an installation whose environment file was removed.\n' \
                "$running"
        fi
        printf '.env holds SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY. They are stored nowhere else, and the credentials saved in this installation cannot be decrypted without them, so the installer cannot rebuild it.\n'
        if [[ -n "$backup" ]]; then
            printf 'A copy from an earlier installer run is at:\n\n  %s\n\n' "$backup"
        fi
        printf 'Restore .env into %s and run the installer again.\n' "$INSTALL_DIR"
    } >&2
    exit 1
}

legacy_database_mode() {
    local external
    external=$(dotenv_get USE_EXTERNAL_DB "$ENV_FILE" || true)
    case "$external" in
        true) printf 'external' ;;
        false) printf 'internal' ;;
        *) die "Legacy .env must contain USE_EXTERNAL_DB=true or USE_EXTERNAL_DB=false" ;;
    esac
}

manual_database_mode() {
    local compose_files
    compose_files=$(dotenv_get COMPOSE_FILE "$ENV_FILE" || true)
    case "$compose_files" in
        compose.yaml:compose.internal-db.yaml) printf 'internal' ;;
        compose.yaml:compose.external-db.yaml) printf 'external' ;;
        *)
            die "Manual bundle .env must select exactly compose.yaml and one supported database overlay"
            ;;
    esac
}

verify_manual_bundle_files() {
    local installed_version="$1" name
    if [[ "$installed_version" == "$TARGET_VERSION" ]]; then
        SOURCE_BUNDLE_DIR="$TARGET_BUNDLE_DIR"
    else
        download_verified_bundle "$installed_version" "$STAGE_DIR/source"
        SOURCE_BUNDLE_DIR="$STAGE_DIR/source/bundle"
    fi
    for name in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        [[ -f "$INSTALL_DIR/$name" && ! -L "$INSTALL_DIR/$name" ]] || \
            die "Manual bundle file $name is missing or is not a regular file"
        cmp -s "$SOURCE_BUNDLE_DIR/$name" "$INSTALL_DIR/$name" || \
            die "Manual bundle file $name differs from published DFIRe ${installed_version}; customized bundles remain operator-managed"
    done
}

frontend_bind_from_container() {
    docker inspect dfire_frontend_prod --format \
        '{{(index (index .HostConfig.PortBindings "80/tcp") 0).HostIp}}:{{(index (index .HostConfig.PortBindings "80/tcp") 0).HostPort}}:80' \
        2>/dev/null || true
}

volume_for_key() {
    local project="$1" key="$2" volumes
    volumes=$(docker volume ls \
        --filter "label=com.docker.compose.project=$project" \
        --filter "label=com.docker.compose.volume=$key" \
        --format '{{.Name}}')
    if [[ "$volumes" == *$'\n'* ]]; then
        die "Multiple volumes have Compose labels for project '$project' and key '$key'"
    fi
    printf '%s' "$volumes"
}

database_overlay() {
    case "$1" in
        internal) printf '%s/compose.internal-db.yaml' "$2" ;;
        external) printf '%s/compose.external-db.yaml' "$2" ;;
        *) die "Unknown database mode: $1" ;;
    esac
}

compose_run() {
    local bundle_dir="$1" project="$2" db_mode="$3"
    shift 3
    local overlay
    overlay=$(database_overlay "$db_mode" "$bundle_dir")
    (
        cd "$INSTALL_DIR"
        env \
            -u COMPOSE_FILE -u COMPOSE_PROJECT_NAME \
            -u DEBUG -u SECRET_KEY -u CREDENTIAL_ENCRYPTION_KEY \
            -u DATABASE_URL -u DFIRE_DIRECT_DATABASE_URL \
            -u POSTGRES_DB -u POSTGRES_USER -u POSTGRES_PASSWORD \
            -u REDIS_HOST -u REDIS_PORT -u REDIS_PASSWORD \
            -u ALLOWED_HOSTS -u CORS_ALLOWED_ORIGINS -u CSRF_TRUSTED_ORIGINS \
            -u TRUST_PROXY_HEADERS -u AUTH_COOKIE_SECURE -u DFIRE_ENVIRONMENT \
            -u GUNICORN_WORKERS -u DJANGO_SUPERUSER_EMAIL \
            -u DJANGO_SUPERUSER_USERNAME -u DJANGO_SUPERUSER_PASSWORD \
            -u FRONTEND_BIND \
            docker compose \
                --project-name "$project" \
                --project-directory "$INSTALL_DIR" \
                --env-file "$ENV_FILE" \
                -f "$bundle_dir/compose.yaml" \
                -f "$overlay" \
                "$@"
    )
}

copy_bundle_files() {
    local source_dir="$1" name tmp
    for name in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        tmp="$INSTALL_DIR/.${name##*/}.new.$$"
        cp "$source_dir/$name" "$tmp"
        chmod 0644 "$tmp"
        mv "$tmp" "$INSTALL_DIR/$name"
    done
}

bundle_files_match() {
    local source_dir="$1" name
    for name in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        [[ -f "$INSTALL_DIR/$name" && ! -L "$INSTALL_DIR/$name" ]] || return 1
        cmp -s "$source_dir/$name" "$INSTALL_DIR/$name" || return 1
    done
}

verify_partial_fresh_bundle() {
    local name
    for name in compose.yaml compose.internal-db.yaml compose.external-db.yaml .env-example README.md; do
        if [[ -e "$INSTALL_DIR/$name" || -L "$INSTALL_DIR/$name" ]]; then
            [[ -f "$INSTALL_DIR/$name" && ! -L "$INSTALL_DIR/$name" ]] || \
                die "Existing $name is not a regular installer-owned file"
            cmp -s "$TARGET_BUNDLE_DIR/$name" "$INSTALL_DIR/$name" || \
                die "Existing $name does not match the pending release bundle and will not be overwritten"
        fi
    done
    for name in docker-compose.prod.yml docker-compose.external-db.yml; do
        [[ ! -e "$INSTALL_DIR/$name" && ! -L "$INSTALL_DIR/$name" ]] || \
            die "Existing $name is not part of a fresh bundle install and will not be overwritten"
    done
}

backup_control_files() {
    local name
    BACKUP_DIR="$INSTALL_DIR/.dfire-backup-$(date -u +%Y%m%d%H%M%S)-$$"
    mkdir "$BACKUP_DIR"
    chmod 0700 "$BACKUP_DIR"
    for name in .env .dfire-version .dfire-pending compose.yaml compose.internal-db.yaml \
        compose.external-db.yaml docker-compose.prod.yml \
        docker-compose.external-db.yml setup-https.sh README.md .env-example; do
        if [[ -f "$INSTALL_DIR/$name" ]]; then
            cp -p "$INSTALL_DIR/$name" "$BACKUP_DIR/$name"
        fi
    done
    ok "Existing control files backed up to $BACKUP_DIR"
}

write_pending_marker() {
    local project="$1" db_mode="$2" operation="$3" print_admin_password="${4:-false}" recovery_dir="${5:-}" tmp
    tmp=$(mktemp "$INSTALL_DIR/.dfire-pending.tmp.XXXXXX")
    chmod 0644 "$tmp"
    {
        printf 'VERSION=%s\n' "$TARGET_VERSION"
        printf 'COMPOSE_PROJECT_NAME=%s\n' "$project"
        printf 'DATABASE_MODE=%s\n' "$db_mode"
        printf 'OPERATION=%s\n' "$operation"
        printf 'PRINT_ADMIN_PASSWORD=%s\n' "$print_admin_password"
        printf 'RECOVERY_DIR=%s\n' "$recovery_dir"
    } >> "$tmp"
    mv "$tmp" "$PENDING_FILE"
}

validate_recovery_dir() {
    local recovery_dir="$1" relative
    [[ -n "$recovery_dir" ]] || return 0
    [[ "$recovery_dir" == "$INSTALL_DIR"/.dfire-backup-* ]] || \
        die "Invalid recovery directory in .dfire-pending"
    relative="${recovery_dir#"$INSTALL_DIR"/}"
    [[ "$relative" != */* ]] || die "Invalid recovery directory in .dfire-pending"
    # A backup directory that was deleted between runs is clutter an operator
    # is entitled to remove, so its absence must never block an update.
    if [[ ! -e "$recovery_dir" && ! -L "$recovery_dir" ]]; then
        warn "The recovery directory recorded in .dfire-pending no longer exists: $recovery_dir"
        return 0
    fi
    [[ -d "$recovery_dir" && ! -L "$recovery_dir" ]] || \
        die "Recovery directory from .dfire-pending is not a plain directory: $recovery_dir"
}

write_version_marker() {
    local project="$1" db_mode="$2" version="${3:-$TARGET_VERSION}" tmp
    tmp=$(mktemp "$INSTALL_DIR/.dfire-version.tmp.XXXXXX")
    chmod 0644 "$tmp"
    {
        printf 'VERSION=%s\n' "$version"
        printf 'COMPOSE_PROJECT_NAME=%s\n' "$project"
        printf 'DATABASE_MODE=%s\n' "$db_mode"
        printf 'COMPOSE_FILES=compose.yaml:compose.%s-db.yaml\n' "$db_mode"
        printf 'INSTALLED_AT=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    } >> "$tmp"
    mv "$tmp" "$VERSION_FILE"
    rm -f -- "$PENDING_FILE"
}

probe_host_from_bind() {
    local host_port host
    host_port="${1%:*}"
    host="${host_port%:*}"
    # A wildcard bind is reachable over loopback; anything else must be
    # contacted on the address it was actually bound to.
    if [[ "$host" == "0.0.0.0" ]]; then
        host="127.0.0.1"
    fi
    printf '%s' "$host"
}

allowed_hosts_contains() {
    local list="$1" candidate="$2" entry
    while IFS= read -r entry; do
        entry="${entry//[[:space:]]/}"
        [[ -n "$entry" ]] || continue
        if [[ "$entry" == "$candidate" || "$entry" == '*' ]]; then
            return 0
        fi
    done <<< "${list//,/$'\n'}"
    return 1
}

# The route check reaches Django with an IP literal in the Host header, and
# Django answers 400 for any host outside ALLOWED_HOSTS. Deployments created by
# the old installer in a production mode carry "<hostname>,localhost" and no
# loopback address at all, so an entirely healthy stack would fail its own
# adoption. Add the addresses the check will use rather than leaving that to
# chance; each is an exact literal that resolves to this machine.
ensure_probe_hosts_allowed() {
    local bind="$1" allowed candidate added=""
    allowed=$(dotenv_get ALLOWED_HOSTS "$ENV_FILE" || true)
    [[ -n "$allowed" ]] || return 0
    for candidate in localhost 127.0.0.1 "$(probe_host_from_bind "$bind")"; do
        if ! allowed_hosts_contains "$allowed" "$candidate"; then
            allowed="${allowed},${candidate}"
            added="${added:+${added}, }${candidate}"
        fi
    done
    [[ -n "$added" ]] || return 0
    set_env_value ALLOWED_HOSTS "$allowed" "$ENV_FILE"
    info "Added ${added} to ALLOWED_HOSTS so DFIRe answers the installer's route check on this machine"
}

probe_routes() {
    local bind host_port host port attempts=0
    bind=$(dotenv_get FRONTEND_BIND "$ENV_FILE" || true)
    validate_frontend_bind "$bind"
    host_port="${bind%:*}"
    port="${host_port##*:}"
    host=$(probe_host_from_bind "$bind")

    info "Checking frontend and database-backed API routes"
    while ((attempts < 30)); do
        if curl -fsS --max-time 10 "http://${host}:${port}/health" >/dev/null 2>&1 && \
           curl -fsS --max-time 10 "http://${host}:${port}/api/health/" >/dev/null 2>&1; then
            ok "Frontend and API health checks passed"
            return
        fi
        attempts=$((attempts + 1))
        sleep 2
    done
    die "Route checks failed; inspect 'docker compose ps' and 'docker compose logs'. The previous control files remain in ${BACKUP_DIR:-the installation directory}."
}

# Compose creates a fresh, empty set of volumes when it is told to use a
# project name that does not match the running stack. The stack then comes up
# healthy and empty while the real data sits orphaned under the old name, so
# this has to be refused before any 'up', not detected after one.
assert_project_matches_running_stack() {
    local project="$1" running
    running=$(project_from_backend_label || true)
    [[ -n "$running" ]] || return 0
    [[ "$running" != "$project" ]] || return 0
    {
        printf '[ERROR] A DFIRe stack is running under Compose project "%s", but this installation resolves to "%s".\n' \
            "$running" "$project"
        printf 'Starting Compose as "%s" would create a second, empty set of volumes and abandon the existing data.\n' \
            "$project"
        printf 'No containers were changed. Correct COMPOSE_PROJECT_NAME, or run the installer from the directory that owns the running deployment, then try again.\n'
    } >&2
    exit 1
}

project_container_state() {
    docker ps -a \
        --filter "label=com.docker.compose.project=$1" \
        --format '{{.Label "com.docker.compose.service"}}|{{.ID}}' 2>/dev/null || true
}

# Services whose container is new or was replaced between two state snapshots.
recreated_services() {
    local before="$1" after="$2" service id changed=""
    while IFS='|' read -r service id; do
        [[ -n "$service" && -n "$id" ]] || continue
        if grep -qxF "${service}|${id}" <<< "$before"; then
            continue
        fi
        if [[ -z "$changed" ]]; then
            changed="$service"
        else
            changed="$changed, $service"
        fi
    done <<< "$after"
    printf '%s' "$changed"
}

verify_project_and_volumes() {
    local project="$1" before="$2" actual_project key expected actual
    actual_project=$(project_from_backend_label || true)
    [[ "$actual_project" == "$project" ]] || die "Compose project identity changed unexpectedly"

    while IFS='|' read -r key expected; do
        [[ -n "$key" && -n "$expected" ]] || continue
        docker volume inspect "$expected" >/dev/null 2>&1 || die "Existing volume disappeared: $expected"
        actual=$(volume_for_key "$project" "$key")
        [[ "$actual" == "$expected" ]] || die "Volume identity changed for $key"
    done <<< "$before"
}

# Strip leading and trailing whitespace. Pasting a key into a terminal picks
# up a stray space or newline more often than anyone expects, and the result
# is a system that installs cleanly and then cannot decrypt its own data.
trim_whitespace() {
    local value="$1"
    value="${value#"${value%%[![:space:]]*}"}"
    value="${value%"${value##*[![:space:]]}"}"
    printf '%s' "$value"
}

# Both the DFIRE_-prefixed name and the plain one the value carries in .env,
# so `set -a; . ./old.env; set +a` before running the installer is enough to
# rebuild an installation on its original keys.
key_from_env() {
    local value="${!1-}"
    [[ -n "$value" ]] || value="${!2-}"
    trim_whitespace "$value"
}

# Read one key back visibly. Deliberately not `read -s`: the point of typing a
# key here is to see that what arrived is the whole key and nothing else.
# Hiding it would defeat the check, and anyone able to read this terminal can
# already read the .env file the key is about to be written into.
prompt_for_key() {
    local label="$1" raw trimmed
    while true; do
        # IFS= so `read` does not quietly strip surrounding whitespace: the
        # operator should be told their paste carried some, not have it fixed
        # behind their back.
        IFS= read -r -p "${label}: " raw || die "No ${label} supplied"
        trimmed=$(trim_whitespace "$raw")
        if [[ -z "$trimmed" ]]; then
            warn "${label} cannot be empty."
            continue
        fi
        if [[ "$raw" != "$trimmed" ]]; then
            warn "Ignored whitespace around the pasted ${label}."
        fi
        printf '%s' "$trimmed"
        return
    done
}

# Every DFIRe installer generates this key as 32 random bytes in URL-safe
# base64, so a value of another shape is usually a truncated paste. This only
# warns: the backend hashes whatever it is given, and deployments built by
# hand hold keys that never had this shape, so refusing one would block a
# legitimate recovery to catch a typo.
warn_unexpected_credential_key_shape() {
    [[ "$1" =~ ^[A-Za-z0-9_-]{43}=$ ]] && return 0
    warn "CREDENTIAL_ENCRYPTION_KEY is ${#1} characters and does not have the shape DFIRe generates (43 URL-safe base64 characters and '='). Check that the whole value was copied. Continuing, because a hand-built installation can legitimately use another value."
}

# A rebuilt installation has to come up on the keys its data was encrypted
# with. SECRET_KEY decrypts the backup archive itself, and
# CREDENTIAL_ENCRYPTION_KEY decrypts the credentials inside it, so a new pair
# leaves the archive unreadable and every encrypted field unrecoverable.
# Neither key has a required format: the backend hashes whatever it is given.
configure_encryption_keys() {
    local env_secret env_credential choice

    env_secret=$(key_from_env DFIRE_SECRET_KEY SECRET_KEY)
    env_credential=$(key_from_env DFIRE_CREDENTIAL_ENCRYPTION_KEY CREDENTIAL_ENCRYPTION_KEY)

    if [[ -n "$env_secret" && -n "$env_credential" ]]; then
        SUPPLIED_SECRET_KEY="$env_secret"
        SUPPLIED_CREDENTIAL_KEY="$env_credential"
        KEY_SOURCE=existing
        info 'Using the SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY found in the environment'
        warn_unexpected_credential_key_shape "$SUPPLIED_CREDENTIAL_KEY"
        return
    fi

    # One without the other is always a mistake, and a silent guess here costs
    # the operator a working installation they cannot read.
    if [[ -n "$env_secret" || -n "$env_credential" ]]; then
        die 'Only one of SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY is set in the environment. Set both to rebuild an earlier installation, or neither to generate a new pair. An installation that never set CREDENTIAL_ENCRYPTION_KEY encrypted its data with SECRET_KEY, so supply that same value for both.'
    fi

    if [[ "$NON_INTERACTIVE" == "true" ]]; then
        KEY_SOURCE=generate
        return
    fi

    section 'Encryption keys'
    hint 'A new installation generates its own keys. Rebuilding one from a backup' \
         'needs the keys the backup was made with: SECRET_KEY decrypts the archive' \
         'itself, and CREDENTIAL_ENCRYPTION_KEY decrypts the credentials in it.'
    printf '\n'
    option '1. Generate new keys ' '(new installation)'
    option '2. Use existing keys ' '(restore a backup from an earlier installation)'
    printf '\n'
    # Input ending here is not an error: generating a new pair is what a fresh
    # installation wants, and an operator rebuilding one supplies the keys
    # through the environment rather than by answering this prompt.
    read -r -p 'Select [1]: ' choice || choice=""
    case "${choice:-1}" in
        1)
            KEY_SOURCE=generate
            return
            ;;
        2)
            KEY_SOURCE=existing
            ;;
        *)
            die "Invalid encryption key selection"
            ;;
    esac

    hint 'Paste each key exactly as it appears in the old .env file. They are shown' \
         'as you type so you can confirm the whole value arrived.'
    printf '\n'
    SUPPLIED_SECRET_KEY=$(prompt_for_key SECRET_KEY)
    SUPPLIED_CREDENTIAL_KEY=$(prompt_for_key CREDENTIAL_ENCRYPTION_KEY)
    warn_unexpected_credential_key_shape "$SUPPLIED_CREDENTIAL_KEY"
}

configure_fresh_install() {
    local choice create_admin protocol host_port host_port_number fresh_probe_host

    if [[ -z "$DATABASE_MODE" ]]; then
        if [[ "$NON_INTERACTIVE" == "true" ]]; then
            die "--database is required in non-interactive mode"
        fi
        section 'Database'
        hint 'DFIRe needs PostgreSQL 16. Point it at a server you manage, or let' \
             'it run one in a container for evaluation.'
        printf '\n'
        option '1. External PostgreSQL' '(recommended: you control backups and availability)'
        option '2. Bundled PostgreSQL ' '(evaluation only: data lives in a Docker volume here)'
        printf '\n'
        read -r -p 'Select [1]: ' choice
        case "${choice:-1}" in
            1) DATABASE_MODE=external ;;
            2) DATABASE_MODE=internal ;;
            *) die "Invalid database selection" ;;
        esac
    fi
    [[ "$DATABASE_MODE" == "internal" || "$DATABASE_MODE" == "external" ]] || \
        die "--database must be internal or external"

    if [[ "$DATABASE_MODE" == "external" && -z "$DATABASE_URL_INPUT" ]]; then
        [[ "$NON_INTERACTIVE" == "false" ]] || die "--database-url is required for an external database"
        section 'PostgreSQL connection'
        hint 'Migrations and backups need a direct connection, which cannot run' \
             'through a transaction pooler. If you use one, DFIRe needs both the' \
             'pooled URL and a direct URL, so it is worth answering this first.'
        printf '\n'
        # Asked before the URLs, not after: otherwise you only discover which
        # endpoint was wanted once the first one has already been pasted.
        read -r -p 'Connecting through a connection pooler (PgBouncer, RDS Proxy, Supabase)? [y/N]: ' choice
        printf '\n'
        # Deliberately echoed. The operator is pasting a string they already
        # hold, and not seeing it is how a mistyped URL reaches the config.
        if [[ "$choice" =~ ^[Yy]$ ]]; then
            hint 'The pooled endpoint your application traffic uses.' \
                 'Example: postgres://dfire:password@pooler.example.com:6543/dfire'
            read -r -p 'Pooled PostgreSQL URL: ' DATABASE_URL_INPUT
            printf '\n'
            hint 'The direct endpoint, bypassing the pooler. Usually the same' \
                 'credentials on the database port.' \
                 'Example: postgres://dfire:password@db.example.com:5432/dfire'
            read -r -p 'Direct PostgreSQL URL: ' DIRECT_DATABASE_URL_INPUT
        else
            hint 'Example: postgres://dfire:password@db.example.com:5432/dfire'
            read -r -p 'PostgreSQL URL: ' DATABASE_URL_INPUT
        fi
    fi
    if [[ "$DATABASE_MODE" == "external" ]]; then
        validate_database_url "Database URL" "$DATABASE_URL_INPUT"
        if [[ -n "$DIRECT_DATABASE_URL_INPUT" ]]; then
            validate_database_url "Direct database URL" "$DIRECT_DATABASE_URL_INPUT"
        fi
    fi

    if [[ -z "$DEPLOYMENT_MODE" ]]; then
        if [[ "$NON_INTERACTIVE" == "true" ]]; then
            die "--deployment is required in non-interactive mode"
        fi
        section 'Web access'
        hint 'How people reach DFIRe. Behind a reverse proxy it listens on this' \
             'host only and your proxy terminates TLS.'
        printf '\n'
        option '1. External reverse proxy with HTTPS' '(recommended: binds 127.0.0.1:8080)'
        option '2. Direct HTTP                      ' '(evaluation only: binds 0.0.0.0:8080)'
        printf '\n'
        read -r -p 'Select [1]: ' choice
        case "${choice:-1}" in
            1) DEPLOYMENT_MODE="external-proxy" ;;
            2) DEPLOYMENT_MODE=http ;;
            *) die "Invalid deployment selection" ;;
        esac
    fi
    [[ "$DEPLOYMENT_MODE" == "http" || "$DEPLOYMENT_MODE" == "external-proxy" ]] || \
        die "--deployment must be http or external-proxy"

    if [[ -z "$DFIRE_HOSTNAME" ]]; then
        [[ "$NON_INTERACTIVE" == "false" ]] || die "--hostname is required in non-interactive mode"
        section 'Hostname'
        hint 'The name or address people type in their browser. It is written into' \
             'the allowed hosts, CORS and CSRF settings, so it has to match how' \
             'DFIRe is actually reached.'
        printf '\n'
        read -r -p 'Public hostname or IP: ' DFIRE_HOSTNAME
    fi
    validate_hostname "$DFIRE_HOSTNAME"

    if [[ -z "$FRONTEND_BIND_INPUT" ]]; then
        if [[ "$DEPLOYMENT_MODE" == "http" ]]; then
            FRONTEND_BIND_INPUT="0.0.0.0:8080:80"
        else
            FRONTEND_BIND_INPUT="127.0.0.1:8080:80"
        fi
    fi
    validate_frontend_bind "$FRONTEND_BIND_INPUT"

    if [[ "$NON_INTERACTIVE" == "false" && -z "$ADMIN_EMAIL$ADMIN_USERNAME$ADMIN_PASSWORD" ]]; then
        section 'Administrator account'
        hint 'Created the first time DFIRe starts. The password is used once and' \
             'then removed from the stored configuration. You can skip this and' \
             'create the account later.'
        printf '\n'
        read -r -p 'Create the initial administrator now? [y/N]: ' create_admin
        if [[ "$create_admin" =~ ^[Yy]$ ]]; then
            printf '\n'
            read -r -p 'Administrator email: ' ADMIN_EMAIL
            read -r -p 'Administrator username [admin]: ' ADMIN_USERNAME
            ADMIN_USERNAME="${ADMIN_USERNAME:-admin}"
            hint 'Leave empty to generate one, which is shown once at the end.'
            read -r -s -p 'Administrator password: ' ADMIN_PASSWORD
            printf '\n'
            if [[ -z "$ADMIN_PASSWORD" ]]; then
                ADMIN_PASSWORD=$(openssl rand -hex 16)
                GENERATED_ADMIN_PASSWORD=true
            fi
        fi
    fi
    validate_admin
    configure_encryption_keys

    protocol=https
    if [[ "$DEPLOYMENT_MODE" == "http" ]]; then
        protocol=http
    fi
    host_port="${FRONTEND_BIND_INPUT%:*}"
    host_port_number="${host_port##*:}"

    if [[ "$KEY_SOURCE" == "existing" ]]; then
        FRESH_SECRET_KEY="$SUPPLIED_SECRET_KEY"
        FRESH_CREDENTIAL_KEY="$SUPPLIED_CREDENTIAL_KEY"
        info 'Installing with the supplied encryption keys; restore the backup once DFIRe is up'
    else
        FRESH_SECRET_KEY=$(openssl rand -hex 32)
        FRESH_CREDENTIAL_KEY=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '\n')
    fi
    FRESH_REDIS_PASSWORD=$(openssl rand -hex 24)
    FRESH_POSTGRES_PASSWORD=$(openssl rand -hex 24)
    FRESH_PROJECT=$(basename "$INSTALL_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')
    [[ "$FRESH_PROJECT" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || FRESH_PROJECT=dfire
    FRESH_ALLOWED_HOSTS="${DFIRE_HOSTNAME},localhost,127.0.0.1"
    # Binding a specific interface rather than the wildcard means the route
    # check contacts that address, so it has to be accepted as a Host too.
    fresh_probe_host=$(probe_host_from_bind "$FRONTEND_BIND_INPUT")
    if ! allowed_hosts_contains "$FRESH_ALLOWED_HOSTS" "$fresh_probe_host"; then
        FRESH_ALLOWED_HOSTS="${FRESH_ALLOWED_HOSTS},${fresh_probe_host}"
    fi
    FRESH_ORIGIN="${protocol}://${DFIRE_HOSTNAME}"
    if [[ "$DEPLOYMENT_MODE" == "http" && "$host_port_number" != "80" ]]; then
        FRESH_ORIGIN="${FRESH_ORIGIN}:${host_port_number}"
    fi
}

write_fresh_env() {
    local tmp compose_files cookie_secure trust_proxy_headers
    compose_files="compose.yaml:compose.${DATABASE_MODE}-db.yaml"
    cookie_secure=True
    trust_proxy_headers=true
    if [[ "$DEPLOYMENT_MODE" == "http" ]]; then
        cookie_secure=False
        trust_proxy_headers=false
    fi
    tmp=$(mktemp "$INSTALL_DIR/.env.tmp.XXXXXX")
    TMP_ENV_FILE="$tmp"
    chmod 600 "$tmp"
    {
        printf '# DFIRe %s configuration generated by installer %s\n' "$TARGET_VERSION" "$INSTALLER_VERSION"
        env_assign COMPOSE_PROJECT_NAME "$FRESH_PROJECT"
        env_assign COMPOSE_PATH_SEPARATOR ':'
        env_assign COMPOSE_FILE "$compose_files"
        env_assign FRONTEND_BIND "$FRONTEND_BIND_INPUT"
        env_assign DATABASE_URL "$DATABASE_URL_INPUT"
        env_assign DFIRE_DIRECT_DATABASE_URL "$DIRECT_DATABASE_URL_INPUT"
        env_assign POSTGRES_DB dfire
        env_assign POSTGRES_USER dfire
        env_assign POSTGRES_PASSWORD "$FRESH_POSTGRES_PASSWORD"
        env_assign REDIS_HOST redis
        env_assign REDIS_PORT 6379
        env_assign REDIS_PASSWORD "$FRESH_REDIS_PASSWORD"
        env_assign SECRET_KEY "$FRESH_SECRET_KEY"
        env_assign CREDENTIAL_ENCRYPTION_KEY "$FRESH_CREDENTIAL_KEY"
        env_assign ALLOWED_HOSTS "$FRESH_ALLOWED_HOSTS"
        env_assign CORS_ALLOWED_ORIGINS "$FRESH_ORIGIN"
        env_assign CSRF_TRUSTED_ORIGINS "$FRESH_ORIGIN"
        env_assign TRUST_PROXY_HEADERS "$trust_proxy_headers"
        env_assign AUTH_COOKIE_SECURE "$cookie_secure"
        env_assign DEBUG false
        env_assign DFIRE_ENVIRONMENT production
        env_assign GUNICORN_WORKERS 2
        env_assign DJANGO_SUPERUSER_EMAIL "$ADMIN_EMAIL"
        env_assign DJANGO_SUPERUSER_USERNAME "$ADMIN_USERNAME"
        env_assign DJANGO_SUPERUSER_PASSWORD "$ADMIN_PASSWORD"
    } >> "$tmp"
    mv "$tmp" "$ENV_FILE"
    TMP_ENV_FILE=""
    chmod 600 "$ENV_FILE"
}

install_fresh() {
    local resuming="${1:-false}" pending_project="${2:-}" pending_db_mode="${3:-}" pending_print="${4:-false}"
    local project target
    [[ ! -e "$ENV_FILE" && ! -e "$VERSION_FILE" ]] || \
        die "An installation or .env already exists in $INSTALL_DIR; choose an empty directory for a fresh install"
    if [[ "$resuming" == "true" ]]; then
        verify_partial_fresh_bundle
        if [[ -n "$DATABASE_MODE" && "$DATABASE_MODE" != "$pending_db_mode" ]]; then
            die "--database does not match the incomplete fresh install"
        fi
        DATABASE_MODE="$pending_db_mode"
    else
        [[ ! -e "$PENDING_FILE" && ! -L "$PENDING_FILE" ]] || \
            die "An incomplete installation marker exists; inspect it and rerun the installer"
        for target in compose.yaml compose.internal-db.yaml compose.external-db.yaml \
            .env-example README.md docker-compose.prod.yml docker-compose.external-db.yml; do
            [[ ! -e "$INSTALL_DIR/$target" && ! -L "$INSTALL_DIR/$target" ]] || \
                die "Existing $target would be overwritten; choose an empty installation directory"
        done
    fi
    configure_fresh_install
    project="$FRESH_PROJECT"
    assert_project_matches_running_stack "$project"
    if [[ "$resuming" == "true" ]]; then
        [[ "$project" == "$pending_project" && "$DATABASE_MODE" == "$pending_db_mode" ]] || \
            die "The selected configuration does not match the incomplete fresh install"
        [[ "$GENERATED_ADMIN_PASSWORD" == "$pending_print" ]] || \
            die "The administrator password choice does not match the incomplete fresh install"
    fi
    write_pending_marker "$project" "$DATABASE_MODE" fresh "$GENERATED_ADMIN_PASSWORD" ""
    copy_bundle_files "$TARGET_BUNDLE_DIR"
    write_fresh_env

    info "Validating Docker Compose configuration"
    compose_run "$INSTALL_DIR" "$project" "$DATABASE_MODE" config -q
    info "Pulling pinned DFIRe ${TARGET_VERSION} images"
    compose_run "$INSTALL_DIR" "$project" "$DATABASE_MODE" pull
    info "Starting DFIRe"
    compose_run "$INSTALL_DIR" "$project" "$DATABASE_MODE" up -d --wait --wait-timeout 600
    probe_routes
    if [[ -n "$ADMIN_PASSWORD" ]]; then
        if [[ "$GENERATED_ADMIN_PASSWORD" == "true" ]]; then
            printf 'Initial administrator password: %s\n' "$ADMIN_PASSWORD"
            warn "Store this password securely now. It will be removed from the persistent and running container configuration."
        fi
        info "Removing the bootstrap administrator password from the persistent configuration"
        set_env_value DJANGO_SUPERUSER_PASSWORD "" "$ENV_FILE"
        compose_run "$INSTALL_DIR" "$project" "$DATABASE_MODE" up -d --wait --wait-timeout 600
        probe_routes
    fi
    write_version_marker "$project" "$DATABASE_MODE"
    ok "DFIRe ${TARGET_VERSION} installed in $INSTALL_DIR"
    if [[ "$DEPLOYMENT_MODE" == "external-proxy" ]]; then
        printf 'DFIRe will be available at %s after your external reverse proxy is configured.\n' "$FRESH_ORIGIN"
    else
        printf 'Access DFIRe at %s\n' "$FRESH_ORIGIN"
    fi
}

upgrade_or_adopt() {
    local legacy=false manual=false resuming=false converge=false project db_mode installed_version
    local current_version bind
    local pending_version operation=upgrade pending_project pending_db_mode running_project
    local secret_before credential_before secret_after credential_after
    local volume_state="" key volume backend_id backend_running bootstrap_password print_admin_password=false
    local container_before="" container_after="" changed_services=""

    if [[ ! -f "$ENV_FILE" ]]; then
        [[ -e "$PENDING_FILE" || -L "$PENDING_FILE" ]] || die_missing_env
        validate_pending_marker
        pending_version=$(pending_get VERSION)
        pending_project=$(pending_get COMPOSE_PROJECT_NAME)
        pending_db_mode=$(pending_get DATABASE_MODE)
        operation=$(pending_get OPERATION)
        print_admin_password=$(pending_get PRINT_ADMIN_PASSWORD)
        [[ "$operation" == "fresh" ]] || \
            die "A pending install without .env must be a fresh installation"
        [[ "$pending_version" == "$TARGET_VERSION" ]] || \
            die "This incomplete installation must resume version $pending_version"
        info "Resuming DFIRe ${TARGET_VERSION} setup before its environment file was written"
        install_fresh true "$pending_project" "$pending_db_mode" "$print_admin_password"
        return
    fi

    if [[ -e "$PENDING_FILE" || -L "$PENDING_FILE" ]]; then
        validate_pending_marker
    fi

    if [[ -f "$VERSION_FILE" ]]; then
        current_version=$(marker_get VERSION || true)
        project=$(marker_get COMPOSE_PROJECT_NAME || true)
        db_mode=$(marker_get DATABASE_MODE || true)
        validate_version "$current_version"
        [[ "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || die "Invalid project name in .dfire-version"
        [[ "$db_mode" == "internal" || "$db_mode" == "external" ]] || die "Invalid database mode in .dfire-version"
        version_is_less "$TARGET_VERSION" "$current_version" && \
            die "Downgrades are not supported (${current_version} to ${TARGET_VERSION})"
        if [[ "$TARGET_VERSION" == "$current_version" && ! -f "$PENDING_FILE" ]]; then
            # Rerunning the same version is how a degraded stack is repaired,
            # so converge it instead of trusting the marker and returning.
            converge=true
            info "DFIRe is already at ${TARGET_VERSION}; checking that the running stack matches the release"
        fi
        if [[ -f "$PENDING_FILE" ]]; then
            resuming=true
            pending_version=$(pending_get VERSION || true)
            pending_project=$(pending_get COMPOSE_PROJECT_NAME || true)
            pending_db_mode=$(pending_get DATABASE_MODE || true)
            operation=$(pending_get OPERATION || true)
            print_admin_password=$(pending_get PRINT_ADMIN_PASSWORD || true)
            BACKUP_DIR=$(pending_get RECOVERY_DIR || true)
            [[ "$pending_version" == "$TARGET_VERSION" ]] || \
                die "An incomplete ${pending_version:-unknown} update exists. Resume that version or restore its backup before selecting another release."
            [[ "$operation" == "upgrade" ]] || \
                die "Pending state beside .dfire-version must be an upgrade"
            [[ "$pending_project" == "$project" && "$pending_db_mode" == "$db_mode" ]] || \
                die "Pending project or database mode does not match .dfire-version"
            info "Resuming the incomplete DFIRe ${TARGET_VERSION} update"
        elif [[ "$converge" == "false" ]]; then
            info "Upgrading managed DFIRe ${current_version} to ${TARGET_VERSION}"
        fi
    elif detect_incomplete_bundle; then
        resuming=true
        pending_version=$(pending_get VERSION || true)
        project=$(pending_get COMPOSE_PROJECT_NAME || true)
        db_mode=$(pending_get DATABASE_MODE || true)
        operation=$(pending_get OPERATION || true)
        print_admin_password=$(pending_get PRINT_ADMIN_PASSWORD || true)
        BACKUP_DIR=$(pending_get RECOVERY_DIR || true)
        [[ "$TARGET_VERSION" == "$pending_version" ]] || \
            die "This incomplete installation must resume version $pending_version"
        [[ "$operation" == "fresh" || "$operation" == "legacy" || "$operation" == "manual" ]] || \
            die "Pending state without .dfire-version has an invalid operation"
        if [[ "$operation" == "legacy" || "$operation" == "manual" ]]; then
            installed_version=$(installed_backend_version)
            validate_installed_version "$installed_version"
            version_is_less "$TARGET_VERSION" "$installed_version" && \
                die "Downgrades are not supported (${installed_version} to ${TARGET_VERSION})"
        fi
        [[ "$operation" == "legacy" ]] && legacy=true
        [[ "$operation" == "manual" ]] && manual=true
        info "Resuming incomplete DFIRe ${TARGET_VERSION} installation"
    elif detect_legacy_install; then
        legacy=true
        operation=legacy
        project=$(project_from_backend_label) || die "Could not identify the legacy Compose project from Docker labels"
        db_mode=$(legacy_database_mode)
        installed_version=$(installed_backend_version)
        validate_installed_version "$installed_version"
        version_is_less "$TARGET_VERSION" "$installed_version" && \
            die "Downgrades are not supported (${installed_version} to ${TARGET_VERSION})"
        info "Adopting legacy DFIRe ${installed_version} project '$project' directly into ${TARGET_VERSION}"
    elif detect_manual_bundle; then
        manual=true
        operation=manual
        project=$(dotenv_get COMPOSE_PROJECT_NAME "$ENV_FILE" || true)
        [[ "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || die "Manual bundle .env has an invalid COMPOSE_PROJECT_NAME"
        running_project=$(project_from_backend_label) || die "Could not identify the running Compose project from Docker labels"
        [[ "$running_project" == "$project" ]] || \
            die "Manual bundle COMPOSE_PROJECT_NAME does not match the running project"
        db_mode=$(manual_database_mode)
        installed_version=$(installed_backend_version)
        validate_installed_version "$installed_version"
        version_is_less "$TARGET_VERSION" "$installed_version" && \
            die "Downgrades are not supported (${installed_version} to ${TARGET_VERSION})"
        verify_manual_bundle_files "$installed_version"
        info "Recognized intact manual DFIRe ${installed_version} release bundle for project '$project'"
    else
        die "Existing files are not a managed install, a recognized old-installer layout, or an intact published release bundle"
    fi

    assert_project_matches_running_stack "$project"

    secret_before=$(dotenv_get SECRET_KEY "$ENV_FILE" || true)
    credential_before=$(dotenv_get CREDENTIAL_ENCRYPTION_KEY "$ENV_FILE" || true)
    [[ -n "$secret_before" && -n "$credential_before" ]] || die "Existing persistent encryption keys are missing"
    if [[ "$db_mode" == "external" ]]; then
        [[ -n "$(dotenv_get DATABASE_URL "$ENV_FILE" || true)" ]] || die "External database mode has no DATABASE_URL"
    fi

    bind=$(dotenv_get FRONTEND_BIND "$ENV_FILE" || true)
    if [[ -z "$bind" ]]; then
        bind=$(frontend_bind_from_container)
    fi
    validate_frontend_bind "$bind"

    backend_id=$(docker inspect dfire_backend_prod --format '{{.Id}}' 2>/dev/null || true)
    backend_running=$(docker inspect dfire_backend_prod --format '{{.State.Running}}' 2>/dev/null || true)
    container_before=$(project_container_state "$project")
    for key in redis_data media_data static_data; do
        volume=$(volume_for_key "$project" "$key")
        if [[ "$operation" != "fresh" && -z "$volume" ]]; then
            die "Expected Compose volume label is missing for $key"
        fi
        [[ -z "$volume" ]] || volume_state+="${key}|${volume}"$'\n'
    done
    if [[ "$db_mode" == "internal" ]]; then
        volume=$(volume_for_key "$project" postgres_data)
        if [[ "$operation" != "fresh" && -z "$volume" ]]; then
            die "Expected Compose volume label is missing for postgres_data"
        fi
        [[ -z "$volume" ]] || volume_state+="postgres_data|${volume}"$'\n'
    fi

    if [[ "$manual" == "true" && "$resuming" == "false" ]]; then
        info "Validating the installed manual release bundle"
        compose_run "$INSTALL_DIR" "$project" "$db_mode" config -q
        if [[ "$installed_version" == "$TARGET_VERSION" ]]; then
            probe_routes
            verify_project_and_volumes "$project" "$volume_state"
            write_version_marker "$project" "$db_mode" "$installed_version"
            ok "Manual DFIRe ${installed_version} release bundle adopted; no containers or bundle files were changed"
            return
        fi
    fi

    info "Validating the candidate bundle while the existing stack is untouched"
    compose_run "$TARGET_BUNDLE_DIR" "$project" "$db_mode" config -q
    info "Pulling pinned DFIRe ${TARGET_VERSION} images while the existing stack remains available"
    compose_run "$TARGET_BUNDLE_DIR" "$project" "$db_mode" pull
    if [[ -n "$backend_id" && "$backend_running" == "true" ]]; then
        [[ "$(docker inspect dfire_backend_prod --format '{{.Id}}' 2>/dev/null || true)" == "$backend_id" ]] || \
            die "The existing backend changed during candidate validation"
        [[ "$(docker inspect dfire_backend_prod --format '{{.State.Running}}' 2>/dev/null || true)" == "true" ]] || \
            die "The existing backend stopped during candidate validation"
    fi

    if [[ "$converge" == "true" ]]; then
        # Nothing needs resuming when the target and installed versions match,
        # so no pending marker is written, and control files are only archived
        # when there is genuinely something to replace.
        if bundle_files_match "$TARGET_BUNDLE_DIR"; then
            info "Release bundle files already match DFIRe ${TARGET_VERSION}"
        else
            backup_control_files
            copy_bundle_files "$TARGET_BUNDLE_DIR"
            info "Restored the DFIRe ${TARGET_VERSION} release bundle files"
        fi
    else
        if [[ "$resuming" == "false" ]]; then
            backup_control_files
        elif [[ -n "$BACKUP_DIR" && ! -d "$BACKUP_DIR" ]]; then
            # The recorded recovery copy was deleted between runs. Replace it
            # before this run retires any control file of its own.
            warn "Making a new recovery copy because the recorded one is gone"
            backup_control_files
        fi
        write_pending_marker "$project" "$db_mode" "$operation" "$print_admin_password" "$BACKUP_DIR"
        copy_bundle_files "$TARGET_BUNDLE_DIR"
    fi
    set_env_value COMPOSE_PROJECT_NAME "$project" "$ENV_FILE"
    set_env_value COMPOSE_PATH_SEPARATOR ':' "$ENV_FILE"
    set_env_value COMPOSE_FILE "compose.yaml:compose.${db_mode}-db.yaml" "$ENV_FILE"
    set_env_value FRONTEND_BIND "$bind" "$ENV_FILE"
    ensure_probe_hosts_allowed "$bind"
    if [[ "$legacy" == "true" ]]; then
        # The old full-installer Compose model hard-coded this effective value
        # even when its generated .env said false. Preserve runtime behavior.
        set_env_value TRUST_PROXY_HEADERS true "$ENV_FILE"
    fi

    secret_after=$(dotenv_get SECRET_KEY "$ENV_FILE" || true)
    credential_after=$(dotenv_get CREDENTIAL_ENCRYPTION_KEY "$ENV_FILE" || true)
    [[ "$secret_after" == "$secret_before" && "$credential_after" == "$credential_before" ]] || \
        die "Persistent encryption keys changed while preparing the upgrade"

    if [[ "$converge" == "true" ]]; then
        info "Reconciling the running stack with DFIRe ${TARGET_VERSION}"
    else
        info "Applying DFIRe ${TARGET_VERSION} in place"
    fi
    if ! compose_run "$INSTALL_DIR" "$project" "$db_mode" up -d --wait --wait-timeout 600; then
        die "Compose update failed. No target version marker was written. Restore control files from ${BACKUP_DIR:-the installation directory} and review container logs."
    fi
    probe_routes
    if [[ "$operation" == "fresh" ]]; then
        bootstrap_password=$(dotenv_get DJANGO_SUPERUSER_PASSWORD "$ENV_FILE" || true)
        if [[ -n "$bootstrap_password" ]]; then
            if [[ "$print_admin_password" == "true" ]]; then
                printf 'Initial administrator password: %s\n' "$bootstrap_password"
                warn "Store this password securely now. It will be removed from the persistent and running container configuration."
            fi
            set_env_value DJANGO_SUPERUSER_PASSWORD "" "$ENV_FILE"
            compose_run "$INSTALL_DIR" "$project" "$db_mode" up -d --wait --wait-timeout 600
            probe_routes
        fi
    fi
    verify_project_and_volumes "$project" "$volume_state"
    if [[ "$legacy" == "true" ]]; then
        rm -f -- \
            "$INSTALL_DIR/docker-compose.prod.yml" \
            "$INSTALL_DIR/docker-compose.external-db.yml" \
            "$INSTALL_DIR/setup-https.sh"
        info "Archived legacy Compose and HTTPS helper files in $BACKUP_DIR; host proxy and certificate state were not changed"
    fi
    write_version_marker "$project" "$db_mode"

    if [[ "$converge" == "true" ]]; then
        container_after=$(project_container_state "$project")
        changed_services=$(recreated_services "$container_before" "$container_after")
        if [[ -n "$changed_services" ]]; then
            ok "DFIRe ${TARGET_VERSION} stack restored; started or recreated: ${changed_services}"
        else
            ok "DFIRe ${TARGET_VERSION} is already deployed as released; no containers were changed"
        fi
    elif [[ "$legacy" == "true" ]]; then
        ok "Legacy DFIRe installation adopted and updated to ${TARGET_VERSION} without changing its project, volumes, secrets, or host proxy"
    elif [[ "$manual" == "true" ]]; then
        ok "Manual DFIRe release bundle adopted and updated to ${TARGET_VERSION}"
    else
        ok "DFIRe upgraded to ${TARGET_VERSION}"
    fi
    if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
        printf 'Recovery copy: %s\n' "$BACKUP_DIR"
    fi
}

# Changing a running deployment is never the default action of a bare command.
# The old installer presented a menu here; this states what it found, what it
# would do, and exits non-zero so an automated caller cannot mistake having
# done nothing for a completed upgrade.
report_existing_deployment() {
    local installed=""
    if [[ -f "$VERSION_FILE" ]]; then
        installed=$(marker_get VERSION || true)
    fi
    if [[ -z "$installed" ]]; then
        installed=$(installed_backend_version)
    fi
    {
        printf '\n%sDFIRe is already installed in %s%s\n\n' "$C_BOLD" "$INSTALL_DIR" "$C_RESET"
        if [[ -n "$installed" ]]; then
            printf '  Installed version: %s\n' "$installed"
        fi
        printf '  Current release:   %s\n\n' "$TARGET_VERSION"
        printf 'Nothing was changed. Upgrading or adopting an existing installation\n'
        printf 'is a deliberate action, so run it explicitly:\n\n'
        printf '  %s./install.sh --upgrade%s            adopt or upgrade to %s\n' \
            "$C_BOLD" "$C_RESET" "$TARGET_VERSION"
        printf '  %s./install.sh --upgrade VERSION%s    select a specific release\n\n' \
            "$C_BOLD" "$C_RESET"
        printf 'Run ./install.sh --help for every option.\n\n'
    } >&2
    exit 1
}

main() {
    local control_files_present=false stack_running=false

    parse_args "$@"
    command -v curl >/dev/null 2>&1 || die "curl is required"
    check_installer_version
    check_prerequisites

    mkdir -p "$INSTALL_DIR"
    INSTALL_DIR=$(cd "$INSTALL_DIR" && pwd -P)
    ENV_FILE="$INSTALL_DIR/.env"
    VERSION_FILE="$INSTALL_DIR/.dfire-version"
    PENDING_FILE="$INSTALL_DIR/.dfire-pending"

    acquire_lock
    resolve_version

    if [[ -e "$ENV_FILE" || -L "$ENV_FILE" || -e "$VERSION_FILE" || -L "$VERSION_FILE" || \
          -e "$PENDING_FILE" || -L "$PENDING_FILE" ]]; then
        control_files_present=true
    elif project_from_backend_label >/dev/null 2>&1; then
        # Control files can be deleted; a running deployment cannot be treated
        # as an empty directory just because none of them survived.
        stack_running=true
    fi

    if [[ "$control_files_present" == "true" || "$stack_running" == "true" ]]; then
        [[ "$UPGRADE_REQUESTED" == "true" ]] || report_existing_deployment
        if [[ "$stack_running" == "true" ]]; then
            info "Docker reports a running DFIRe deployment although no installer control file is present"
        fi
        download_bundle
        upgrade_or_adopt
    else
        download_bundle
        install_fresh
    fi
}

main "$@"
