#!/bin/bash
################################################################################
# NVR Docker Installation Script
#
# Installs Docker-based NVR on a fresh CentOS 6+, CentOS Stream 9, or Ubuntu system.
# Downloads compose files from files.dividia.net, pulls images, and
# configures a host cron for daily automatic updates via `nvr update`.
#
# Usage:
#   sudo ./install-nvr.sh [options]
#
# Options:
#   --channel <name>        Version channel (e.g., 6.2), "dev", or "dev-<suffix>"
#                           for per-branch test images [default: 6.2]
#   --registry <url>        Docker registry URL [default: docker.io]
#   --data-dir <path>       Data directory [default: /opt/dividia/data]
#   --video-device <path>   Block device for VideoStore (e.g., /dev/sdb)
#   --no-video-device       Acknowledge no dedicated VideoStore drive (dev/test)
#   --help                  Show this help message
#
# URL: http://files.dividia.net/software/nvr-docker/install-nvr.sh
################################################################################

# set -eE: errexit + errtrace. The -E (errtrace) is load-bearing for the
# rollback contract in migrate_flow. Without it, the ERR trap installed at
# line 2712 fires only on commands at migrate_flow's own scope; failures
# inside the 14+ functions it calls (restore_from_backup, verify_migration,
# wait_for_backend_healthy, etc.) silently abort the shell without firing
# rollback_rpm_services. Empirically verified: bare `false` inside a nested
# function aborts the script under `set -e` alone but does NOT fire the
# parent's ERR trap; under `set -eE` it does. Pass-3 review converted
# `exit 1` → `return 1` in trap-window functions to fix one half of this;
# `set -E` is the other half.
set -eE

# Ensure /usr/local/bin is in PATH (Docker static binaries on CentOS 6)
export PATH="/usr/local/bin:$PATH"

# Default configuration
CHANNEL="6.2"
CHANNEL_EXPLICIT=false
REGISTRY="docker.io"
DATA_DIR="/opt/dividia/data"
INSTALL_DIR="/opt/dividia"
VIDEO_DEVICE=""
NO_VIDEO_DEVICE=false
DETECTED_VS_LABELS=""  # Set by check_video_device_or_fail if pre-labeled vs[N] partitions exist
NVR_ID=""
WATCHTOWER_SCHEDULE="0 0 7 * * *"  # 07:00 UTC daily = 02:00 CDT / 01:00 CST (quiet window for US Central NVRs)
BASE_URL="${NVR_BASE_URL:-http://files.dividia.net/software/nvr-docker}"
UPGRADE_MODE=false
UPGRADE_BACKUP_DIR=""
MIGRATE_MODE=false
ASSUME_YES=false
RPM_SEED=""
KEEP_LEGACY=false

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log_info()  { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# WINDOWS-INSTALLER-CONTRACT: log_step emits a machine-readable "NVR-STEP:" line
# that the Windows Hyper-V installer (windows/scripts/Install-NVR.ps1, step 14)
# parses to show sub-step progress in the InnoSetup UI. Keep this line format
# stable. If you change it, update Install-NVR.ps1's NVR-STEP regex and the
# contract test at docker/tests/test_install_nvr_contract.sh.
log_step()  { echo "NVR-STEP: $1"; echo -e "\n${BLUE}==>${NC} $1"; }

################################################################################
# Logging — tee all output to a persistent log file for post-mortem
################################################################################

LOG_FILE=""

setup_logging() {
    local timestamp
    timestamp=$(date +%Y%m%d-%H%M%S)
    LOG_FILE="/var/log/nvr-install-${timestamp}.log"

    # Fall back to /tmp if /var/log is not writable (shouldn't happen as root,
    # but better to keep some log than none).
    if ! { mkdir -p /var/log 2>/dev/null && touch "$LOG_FILE" 2>/dev/null; }; then
        LOG_FILE="/tmp/nvr-install-${timestamp}.log"
        if ! touch "$LOG_FILE" 2>/dev/null; then
            log_warn "Cannot create install log file — proceeding without"
            LOG_FILE=""
            return
        fi
    fi
    chmod 0644 "$LOG_FILE" 2>/dev/null || true

    # Keep a stable "latest" symlink so post-mortem is `cat /var/log/nvr-install.log`.
    local log_dir="${LOG_FILE%/*}"
    ln -sf "$(basename "$LOG_FILE")" "$log_dir/nvr-install.log" 2>/dev/null || true

    # Tee both stdout and stderr; strip ANSI color escapes from the file copy
    # so it greps cleanly later. Terminal output stays colored.
    exec > >(tee >(sed -u 's/\x1b\[[0-9;]*m//g' >> "$LOG_FILE"))
    exec 2> >(tee >(sed -u 's/\x1b\[[0-9;]*m//g' >> "$LOG_FILE") >&2)

    log_info "Install log: $LOG_FILE"
}

################################################################################
# Parse Arguments
################################################################################

parse_args() {
    while [[ $# -gt 0 ]]; do
        case $1 in
            --channel)       CHANNEL="$2"; CHANNEL_EXPLICIT=true; shift 2 ;;
            --registry)      REGISTRY="$2";     shift 2 ;;
            --data-dir)      DATA_DIR="$2";     shift 2 ;;
            --video-device)  VIDEO_DEVICE="$2"; shift 2 ;;
            --no-video-device) NO_VIDEO_DEVICE=true; shift ;;
            --id)            NVR_ID="$2";      shift 2 ;;
            --upgrade)       UPGRADE_MODE=true; shift ;;
            --migrate)       MIGRATE_MODE=true; shift ;;
            -y|--yes)        ASSUME_YES=true; shift ;;
            --keep-legacy)   KEEP_LEGACY=true; shift ;;
            --help)          show_help; exit 0 ;;
            *)               log_error "Unknown option: $1"; show_help; exit 1 ;;
        esac
    done
}

show_help() {
    cat <<EOF
NVR Docker Installation Script

Usage: sudo $0 [options]

Options:
  --channel <name>        Version channel (e.g., 6.2), "dev", or "dev-<suffix>"
                          for per-branch test images [default: 6.2]
  --registry <url>        Docker registry URL [default: docker.io]
  --data-dir <path>       Data directory [default: /opt/dividia/data]
  --video-device <path>   Block device for VideoStore (e.g., /dev/sdb).
                          Script partitions, formats ext4, labels vs1.
  --no-video-device       Acknowledge no dedicated VideoStore drive.
                          Recordings go to \$DATA_DIR/videostore/vs1 (will
                          fill the root partition under sustained motion).
                          Dev/test only, NOT for production.
  --upgrade               Upgrade mode: restore from VideoStore backup after install
  --migrate               Migrate from RPM install to Docker
  -y, --yes               Skip confirmation prompts
  --keep-legacy           Skip post-migration cleanup of RPM-era artifacts
                          (firefox, httpd, yum cache, old logs, /var/lib/mysql).
                          Useful for debugging or if you need to roll back.
  --help                  Show this help message

Channels:
  X.Y       Version channel (e.g., 6.2). Auto-receives patches within this
            major.minor version line.
  dev       Shared development builds (latest features)
  dev-<sfx> Per-branch test channel (e.g., dev-smartrec). The update cron
            polls this exact tag, so a system on dev-smartrec stays on the
            feature-branch image and never auto-updates to plain :dev.

Example:
  sudo $0 --channel 6.2
  sudo $0 --channel dev --data-dir /data/nvr
  sudo $0 --channel dev-smartrec     # install per-branch test build
  sudo $0 --video-device /dev/sdb
  sudo $0 --upgrade --channel 6.2
  sudo $0 --migrate --channel dev

Migrating from a 2014 Windows Scale Watcher install? Import via
\`nvr migrate-scalewatcher <zip>\` on the new NVR after this script completes.
See docs/runbooks/migrate-scalewatcher.md in the repo for the full flow.

EOF
}

################################################################################
# System Checks
################################################################################

check_root() {
    if [[ $EUID -ne 0 ]]; then
        log_error "This script must be run as root"
        exit 1
    fi
}

validate_channel() {
    # Channel: "dev", "dev-<suffix>" (per-branch test channel), or major.minor (e.g., 6.2, 7.0).
    if [[ ! "$CHANNEL" =~ ^(dev(-[a-z0-9-]+)?|[0-9]+\.[0-9]+)$ ]]; then
        log_error "Invalid channel: $CHANNEL (must be 'dev', 'dev-<suffix>', or a version like 6.2)"
        exit 1
    fi
}

detect_os() {
    if [[ -f /etc/os-release ]]; then
        . /etc/os-release
        OS=$ID
        OS_VERSION=$VERSION_ID
        log_info "Detected OS: $PRETTY_NAME"
    elif [[ -f /etc/redhat-release ]]; then
        # CentOS 6 (no /etc/os-release): "CentOS release 6.10 (Final)"
        OS="centos"
        OS_VERSION=$(sed 's/.*release \([0-9]*\).*/\1/' /etc/redhat-release)
        PRETTY_NAME=$(cat /etc/redhat-release)
        log_info "Detected OS: $PRETTY_NAME"
    else
        log_error "Cannot detect OS. /etc/os-release not found."
        exit 1
    fi

    case "$OS" in
        centos|rhel|rocky|almalinux)
            OS_FAMILY="rhel"
            ;;
        ubuntu|debian)
            OS_FAMILY="debian"
            ;;
        *)
            log_error "Unsupported OS: $OS"
            exit 1
            ;;
    esac

    # Detect Windows deployment (WSL2 or Hyper-V VM with --windows flag)
    IS_WINDOWS=false
    if grep -qi microsoft /proc/version 2>/dev/null; then
        IS_WINDOWS=true
        log_info "Running inside WSL2 (Windows host detected)"
    elif [[ "${NVR_PLATFORM:-}" == "windows" ]]; then
        IS_WINDOWS=true
        log_info "Windows platform flag set (Hyper-V VM deployment)"
    fi
}

################################################################################
# Init System Helpers
#
# CentOS 6 uses SysV init (no systemd). These helpers abstract service
# management so the rest of the script works on both init systems.
################################################################################

has_systemd() { command -v systemctl &>/dev/null; }

svc_start()   { if has_systemd; then systemctl start "$1";  else service "$1" start;  fi; }
svc_stop()    { if has_systemd; then systemctl stop "$1";   else service "$1" stop;   fi; }
svc_enable()  { if has_systemd; then systemctl enable "$1"; else chkconfig "$1" on 2>/dev/null; fi; }
svc_disable() { if has_systemd; then systemctl disable "$1"; else chkconfig "$1" off 2>/dev/null; fi; }
svc_active()  { if has_systemd; then systemctl is-active --quiet "$1"; else service "$1" status &>/dev/null; fi; }

# Refuse to proceed if the NVR's current version is below 6.0. The current
# rda-db image ships only the 37 post-6.0 update scripts (the 206 pre-6.0
# scripts were purged — see PR1 of feature/rda-db-py3-cleanup). A <6.0
# customer running update.py against this tree would skip directly from
# whatever pre-6.0 schema they're on to the 6.0+ scripts without running
# the intermediate migrations, producing a subtly broken DB.
#
# Bypasses on fresh-install signals (missing dvs.conf, VERSION=0, -1, empty)
# so a fresh install path is never gated. Only fires on an actual upgrade
# or migration from a pre-6.0 system.
#
# Called from migrate_flow (reads /etc/dvs.conf on the RPM host) and
# upgrade_flow (reads the restored /data/config/dvs.conf post-restore).
check_version_6_0_or_fail() {
    local conf="$1"
    if [[ ! -f "$conf" ]]; then
        return 0  # Fresh install or missing config — nothing to gate
    fi
    local version
    version=$(grep -E '^VERSION=' "$conf" | head -1 | cut -d= -f2 | tr -d '\r\n ')
    case "$version" in
        ''|'0'|'-1')
            return 0  # Fresh-install sentinel values — bypass
            ;;
    esac
    local major
    major=$(echo "$version" | cut -d. -f1)
    if ! [[ "$major" =~ ^[0-9]+$ ]]; then
        log_error "Cannot parse VERSION=$version from $conf."
        log_error "Expected format: X.Y or X.Y.Z  (e.g. 6.0, 6.2.1)"
        # return 1 (not exit 1): two of the three callers run inside
        # migrate_flow's trap window (line 2388 in restore_from_backup,
        # line 2692 at migrate_flow entry). exit 1 bypasses the ERR
        # trap regardless of set -E; return 1 propagates via set -e
        # so the rollback fires. Non-trap callers (line 2623 in
        # upgrade_flow) also still abort because set -e propagates the
        # non-zero return to the call site.
        return 1
    fi
    if [[ $major -lt 6 ]]; then
        log_error "This NVR is running version $version, which is below 6.0."
        log_error ""
        log_error "The Docker release only supports migration from 6.0 or newer."
        log_error "The 206 pre-6.0 database migration scripts were removed from"
        log_error "the current image. Running it against a pre-6.0 database would"
        log_error "skip intermediate migrations and leave the DB in a broken state."
        log_error ""
        log_error "Stepping-stone path:"
        log_error "  1. Install the 6.0 RPM release from the legacy repo"
        log_error "     (files.dividia.net) and run yum update to bring this NVR"
        log_error "     up to 6.0 or newer."
        log_error "  2. Verify /etc/dvs.conf shows VERSION=6.0 (or higher)."
        log_error "  3. Re-run this installer."
        return 1
    fi
}

check_virtualbox_graphics() {
    if [[ -f /sys/class/dmi/id/product_name ]] && grep -qi "virtualbox" /sys/class/dmi/id/product_name; then
        if command -v lspci &> /dev/null && lspci | grep -qi "vmware svga"; then
            log_warn "VirtualBox detected with VMSVGA graphics controller"
            log_warn "The NVR viewer requires VBoxVGA for Java OpenGL compatibility"
            log_warn "Fix: VBoxManage modifyvm <VM> --graphicscontroller vboxvga --vram 128"
        fi
    fi
}

################################################################################
# Docker Installation
################################################################################

check_docker() {
    if command -v docker &> /dev/null; then
        # Verify docker compose v2 is available (Docker 18.x only has v1)
        if docker compose version &>/dev/null; then
            log_info "Docker is already installed: $(docker --version)"
            return 0
        fi
        log_warn "Docker found but too old (no compose v2 support) — upgrading"
        return 1
    fi
    return 1
}

install_docker_rhel() {
    log_step "Installing Docker on RHEL-based system"

    if command -v dnf &>/dev/null; then
        dnf remove -y docker docker-client docker-client-latest docker-common \
            docker-latest docker-latest-logrotate docker-logrotate docker-engine \
            podman runc 2>/dev/null || true

        dnf install -y dnf-plugins-core
        dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
        dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    else
        # CentOS 7: yum-based Docker install
        yum remove -y docker docker-client docker-client-latest docker-common \
            docker-latest docker-latest-logrotate docker-logrotate docker-engine 2>/dev/null || true

        yum install -y yum-utils
        yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

        # Docker deps (container-selinux, slirp4netns, fuse-overlayfs) are shipped in
        # the NVR's rda repo under os-updates/.  Install deps first, then Docker CE.
        yum install -y container-selinux slirp4netns fuse-overlayfs fuse3-libs 2>/dev/null || true
        yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
    fi

    svc_enable docker
    svc_start docker
    log_info "Docker installed successfully"
}

install_docker_static() {
    # Install Docker CE from static binaries — works on any Linux x86_64.
    # Avoids yum/dnf dependency hell. On CentOS 6 (no systemd), creates a
    # SysV init script. On CentOS 7+/systemd, creates a systemd unit.
    log_step "Installing Docker via static binaries"

    local docker_ver="27.5.1"
    local compose_ver="v2.32.4"
    local url="https://download.docker.com/linux/static/stable/x86_64/docker-${docker_ver}.tgz"

    # Stop old Docker and containerd if running (e.g., Docker 1.7 from EPEL,
    # docker-ce RPM, or old containerd RPM). The old containerd must be stopped
    # because dockerd will connect to a running containerd socket — if the old
    # v1.2 containerd is running, the new Docker 27 fails with "unknown method
    # AddResource: not implemented". Don't remove RPMs — they may have dependents.
    if has_systemd; then
        systemctl stop docker 2>/dev/null || true
        systemctl disable docker 2>/dev/null || true
        systemctl stop containerd 2>/dev/null || true
        systemctl disable containerd 2>/dev/null || true
        # Kill any lingering containerd — if an old version (e.g., 1.2.x from
        # docker-ce RPM) is running, the new dockerd connects to it and fails.
        pkill -9 containerd 2>/dev/null || true
        sleep 1
    else
        service docker stop 2>/dev/null || true
    fi

    # Install Docker CE static binaries
    log_info "Downloading Docker CE ${docker_ver}..."
    curl -fsSL "$url" | tar xz -C /usr/local/bin --strip-components=1
    log_info "Docker binaries installed to /usr/local/bin"

    # Install Docker Compose v2 plugin
    log_info "Downloading Docker Compose ${compose_ver}..."
    mkdir -p /usr/local/lib/docker/cli-plugins
    curl -fsSL "https://github.com/docker/compose/releases/download/${compose_ver}/docker-compose-linux-x86_64" \
        -o /usr/local/lib/docker/cli-plugins/docker-compose
    chmod +x /usr/local/lib/docker/cli-plugins/docker-compose

    # Create docker group and data directories
    groupadd -f docker
    mkdir -p /var/run/docker /var/lib/docker /var/lib/containerd /etc/docker

    # Docker 27+ requires explicit userland-proxy-path when using static binaries
    cat > /etc/docker/daemon.json <<'DAEMONJSON'
{
    "userland-proxy-path": "/usr/local/bin/docker-proxy"
}
DAEMONJSON

    if has_systemd; then
        # Systemd unit for containerd (matched to the new static /usr/local/bin
        # binary). MUST be installed alongside docker.service — without it,
        # systemd keeps the EPEL-installed /usr/lib/systemd/system/containerd.service
        # which has ExecStart=/usr/bin/containerd (the OLD v1.2 binary). On the
        # next boot, the old containerd starts under that unit and the new
        # dockerd 27 talks to its socket — fails with "Unimplemented: unknown
        # method AddResource: not implemented" on every image pull. Discovered
        # on cs50 ACS-Odessa migration 2026-05-15. The override in
        # /etc/systemd/system/ takes precedence over /usr/lib/systemd/system/.
        cat > /etc/systemd/system/containerd.service <<'CTRDUNIT'
[Unit]
Description=containerd container runtime
Documentation=https://containerd.io
After=network.target

[Service]
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd
KillMode=process
Delegate=yes
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity

[Install]
WantedBy=multi-user.target
CTRDUNIT

        # Systemd unit for Docker daemon (CentOS 7+, CentOS 9, Ubuntu).
        # Requires=/After= containerd so systemd starts containerd first on
        # boot — without these directives, docker.service starts before
        # containerd.service and dockerd briefly fails before retry.
        cat > /etc/systemd/system/docker.service <<'UNITEOF'
[Unit]
Description=Docker Application Container Engine
After=network-online.target containerd.service
Wants=network-online.target
Requires=containerd.service

[Service]
Type=notify
ExecStart=/usr/local/bin/dockerd
ExecReload=/bin/kill -s HUP $MAINPID
TimeoutStartSec=0
RestartSec=2
Restart=always
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
Delegate=yes
KillMode=process
OOMScoreAdjust=-500

[Install]
WantedBy=multi-user.target
UNITEOF
        systemctl daemon-reload
        systemctl enable containerd docker
        systemctl start containerd
        systemctl start docker
    else
        # SysV init script for Docker daemon (CentOS 6)
        cat > /etc/init.d/docker <<'INITEOF'
#!/bin/bash
# chkconfig: 2345 95 05
# description: Docker daemon

### BEGIN INIT INFO
# Provides:       docker
# Required-Start: $network $syslog
# Required-Stop:  $network $syslog
# Default-Start:  2 3 4 5
# Default-Stop:   0 1 6
# Description:    Docker daemon
### END INIT INFO

DOCKERD=/usr/local/bin/dockerd
PIDFILE=/var/run/docker.pid
LOGFILE=/var/log/docker.log
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

mount_cgroups() {
    # CentOS 6 does not auto-mount cgroups; Docker requires them.
    if mountpoint -q /sys/fs/cgroup 2>/dev/null; then return 0; fi
    mount -t tmpfs cgroup_root /sys/fs/cgroup 2>/dev/null || true
    for s in memory cpuset cpuacct devices freezer blkio perf_event pids net_cls net_prio; do
        mkdir -p /sys/fs/cgroup/$s
        mount -t cgroup -o $s cgroup_$s /sys/fs/cgroup/$s 2>/dev/null || true
    done
    mkdir -p /sys/fs/cgroup/systemd
    mount -t cgroup -o none,name=systemd cgroup_systemd /sys/fs/cgroup/systemd 2>/dev/null || true
    # Enable shared mount propagation so bind mounts with :slave work
    mount --make-rshared / 2>/dev/null || true
}

start() {
    echo -n "Starting Docker daemon: "
    if [ -f "$PIDFILE" ] && kill -0 $(cat "$PIDFILE") 2>/dev/null; then
        echo "already running"
        return 0
    fi
    mount_cgroups
    $DOCKERD --pidfile "$PIDFILE" >> "$LOGFILE" 2>&1 &
    local i=0
    while [ $i -lt 30 ]; do
        if docker info &>/dev/null; then
            echo "OK"
            return 0
        fi
        sleep 1
        i=$((i + 1))
    done
    echo "FAILED (timeout)"
    return 1
}

stop() {
    echo -n "Stopping Docker daemon: "
    if [ ! -f "$PIDFILE" ] || ! kill -0 $(cat "$PIDFILE") 2>/dev/null; then
        echo "not running"
        return 0
    fi
    kill $(cat "$PIDFILE")
    local i=0
    while [ $i -lt 30 ] && kill -0 $(cat "$PIDFILE") 2>/dev/null; do
        sleep 1
        i=$((i + 1))
    done
    echo "OK"
}

status() {
    if [ -f "$PIDFILE" ] && kill -0 $(cat "$PIDFILE") 2>/dev/null; then
        echo "Docker daemon is running (PID $(cat $PIDFILE))"
        return 0
    else
        echo "Docker daemon is not running"
        return 3
    fi
}

case "$1" in
    start)   start ;;
    stop)    stop ;;
    restart) stop; start ;;
    status)  status ;;
    *)       echo "Usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
INITEOF
        chmod 755 /etc/init.d/docker
        chkconfig docker on
        service docker start
    fi

    hash -r  # Clear bash's cached binary paths after install
    log_info "Docker installed successfully (static binaries)"
}

install_docker_debian() {
    log_step "Installing Docker on Debian-based system"

    apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
    apt-get update
    apt-get install -y ca-certificates curl gnupg lsb-release

    mkdir -p /etc/apt/keyrings
    curl -fsSL "https://download.docker.com/linux/$OS/gpg" | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg

    echo \
        "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$OS \
        $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null

    apt-get update
    apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

    svc_enable docker
    svc_start docker
    log_info "Docker installed successfully"
}

install_docker() {
    if check_docker; then
        return 0
    fi

    case "$OS_FAMILY" in
        rhel)
            local os_major="${OS_VERSION%%.*}"
            if [[ "$os_major" -le 7 ]]; then
                # CentOS 6/7: static binaries (yum dependency hell, old containerd)
                install_docker_static
            else
                # CentOS 8+/Stream 9/RHEL 9+: docker-ce repo works fine
                install_docker_rhel
            fi
            ;;
        debian) install_docker_debian ;;
    esac

    docker --version
    docker compose version
}

################################################################################
# Log Rotation (Docker + journald)
#
# Docker's default json-file driver has NO log rotation — a chatty container
# can fill the disk. Cap each container at 10MB × 3 files = 30MB max.
# journald defaults to ~4GB on a 40GB disk; cap at 200MB for scale-watcher VMs.
# Called after install_docker so it applies to all install paths.
################################################################################
configure_docker_logging() {
    log_step "Configuring log rotation (Docker + journald)"

    # --- Docker log rotation ---
    mkdir -p /etc/docker
    if [[ -f /etc/docker/daemon.json ]] && command -v python3 &>/dev/null; then
        python3 <<'PYEOF'
import json
try:
    with open('/etc/docker/daemon.json') as f:
        cfg = json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
    cfg = {}
cfg['log-driver'] = 'json-file'
cfg['log-opts'] = {'max-size': '10m', 'max-file': '3'}
with open('/etc/docker/daemon.json', 'w') as f:
    json.dump(cfg, f, indent=2)
PYEOF
    else
        cat > /etc/docker/daemon.json <<'DAEMONJSON'
{
    "log-driver": "json-file",
    "log-opts": {
        "max-size": "10m",
        "max-file": "3"
    }
}
DAEMONJSON
    fi

    svc_stop docker 2>/dev/null || true
    svc_start docker
    log_info "Docker log rotation: 10MB × 3 files per container (30MB max each)"

    # --- journald cap (systemd systems only) ---
    if has_systemd && [[ -d /etc/systemd/journald.conf.d ]] || mkdir -p /etc/systemd/journald.conf.d 2>/dev/null; then
        cat > /etc/systemd/journald.conf.d/nvr-cap.conf <<'JOURNALD'
[Journal]
SystemMaxUse=200M
SystemKeepFree=500M
JOURNALD
        systemctl restart systemd-journald 2>/dev/null || true
        log_info "journald cap: 200MB max, 500MB min free"
    fi
}

################################################################################
# Docker Storage Configuration
#
# NVR images are large (~8GB total). Many NVR appliances have small root
# partitions (10-20GB) with a large /opt partition for data. Docker and
# containerd both default to /var/lib which lives on root. This function
# detects low root space and relocates both to /opt before any images are
# pulled.
################################################################################

configure_docker_storage() {
    log_step "Checking Docker storage location"

    local root_avail
    # CentOS 6 df lacks --output; fall back to awk parsing
    root_avail=$(df -P / | awk 'NR==2{print $4}')  # in 1K blocks

    local docker_root
    docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)

    # If Docker data is already on /opt, nothing to do
    if [[ "$docker_root" == /opt/* ]]; then
        log_info "Docker data already on /opt ($docker_root)"
        return 0
    fi

    # Thresholds re-tuned 2026-05-15 after further image-shrink work that
    # the original 15G/10G numbers predated. Actual sizing today:
    #
    #   Fresh-install image footprint (sum of pulled tags on cs256):
    #     viewer 957M + backend 601M + connector 313M + engine 195M +
    #     playback 185M + mariadb 395M + autoheal/watchtower ~50M = ~2.7G
    #   Pull-time scratch:          ~500M transient
    #   Watchtower update overlap:  up to one image-set extra until the
    #                               daily 03:30 prune drops the old layers
    #                               (worst case +1G for ~24h)
    #   Sustainable floor:          ~5G on /opt; ~8G on / if Docker stays
    #                               on the root partition.
    #
    # 5G/8G is the floor the Windows installer also recommends. Headroom
    # above that comes from /opt sizing on real-disk NVRs (cs256/cs2427
    # have ~50G /opt today) and from the EBS-grow path for cloud NVRs
    # (cs1018 root grew 10→20G during 2026-05-15 migration).
    local ROOT_MIN_GB_KB=$(( 8 * 1024 * 1024 ))   # 8 GB
    local OPT_MIN_GB_KB=$(( 5 * 1024 * 1024 ))    # 5 GB

    if [[ $root_avail -gt $ROOT_MIN_GB_KB ]]; then
        log_info "Root filesystem has sufficient space ($(( root_avail / 1048576 ))GB), using default locations"
        return 0
    fi

    # Check /opt is a separate mount with enough space
    local opt_avail
    opt_avail=$(df -P /opt 2>/dev/null | awk 'NR==2{print $4}')

    if [[ -z "$opt_avail" ]] || [[ $opt_avail -lt $OPT_MIN_GB_KB ]]; then
        log_warn "Root has only $(( root_avail / 1048576 ))GB free but /opt doesn't have enough space either"
        log_warn "NVR images need ~3GB resting + headroom for daily nvr update pulls. Installation may fail."
        return 0
    fi

    log_info "Root filesystem has only $(( root_avail / 1048576 ))GB free — relocating Docker storage to /opt"

    # Stop Docker and containerd
    svc_stop docker 2>/dev/null || true
    svc_stop containerd 2>/dev/null || true

    # Configure Docker data-root
    mkdir -p /opt/docker
    mkdir -p /etc/docker
    if [[ -f /etc/docker/daemon.json ]]; then
        # Merge data-root into existing config
        if command -v python3 &> /dev/null; then
            python3 -c "
import json
with open('/etc/docker/daemon.json') as f:
    cfg = json.load(f)
cfg['data-root'] = '/opt/docker'
with open('/etc/docker/daemon.json', 'w') as f:
    json.dump(cfg, f, indent=2)
"
        else
            echo '{"data-root": "/opt/docker"}' > /etc/docker/daemon.json
        fi
    else
        echo '{"data-root": "/opt/docker"}' > /etc/docker/daemon.json
    fi
    log_info "Docker data-root set to /opt/docker"

    # Configure containerd root (Docker 29+ uses containerd for snapshots)
    mkdir -p /opt/containerd
    mkdir -p /etc/containerd
    cat > /etc/containerd/config.toml <<TOML
version = 2
root = "/opt/containerd"
TOML
    log_info "Containerd root set to /opt/containerd"

    # Clean up old data from root partition
    rm -rf /var/lib/docker /var/lib/containerd

    # Restart services
    svc_start containerd 2>/dev/null || true
    svc_start docker
    log_info "Docker storage relocated to /opt ($(( opt_avail / 1048576 ))GB available)"
}

################################################################################
# Docker Hub Authentication
################################################################################

docker_login() {
    log_step "Docker Hub Authentication"

    local DEFAULT_DOCKER_USER="nvrservice"
    local DEFAULT_DOCKER_PASS="dckr_pat_LHFmDIppHGAOH9llE3hKl1ZTu8o"  # nvr-pull read-only PAT

    # Priority: env vars > existing config > built-in defaults
    if [[ -n "$DOCKER_USER" ]] && [[ -n "$DOCKER_PASS" ]]; then
        log_info "Using credentials from environment variables"
    elif grep -q '"auths"' ~/.docker/config.json 2>/dev/null && \
         grep -q 'docker.io\|index.docker' ~/.docker/config.json 2>/dev/null; then
        log_info "Docker Hub credentials already configured"
        return
    else
        log_info "Using built-in service credentials"
        DOCKER_USER="$DEFAULT_DOCKER_USER"
        DOCKER_PASS="$DEFAULT_DOCKER_PASS"
    fi

    echo "$DOCKER_PASS" | docker login --username "$DOCKER_USER" --password-stdin "$REGISTRY"
    log_info "Docker Hub authentication configured"
}

################################################################################
# NVR Installation
################################################################################

create_directory_structure() {
    log_step "Creating directory structure"

    # Ensure dividia user and docker group exist before any chown
    if ! getent group docker &>/dev/null; then
        groupadd docker
        log_info "Created docker group"
    fi
    if ! id "dividia" &>/dev/null; then
        useradd -r -m -G docker -s /bin/bash dividia
        log_info "Created dividia user"
    fi

    mkdir -p "$INSTALL_DIR"
    chown dividia:docker "$INSTALL_DIR"
    mkdir -p "$DATA_DIR"
    mkdir -p "$DATA_DIR/db_data"
    mkdir -p "$DATA_DIR/config"
    # Shared Apache Listen overrides dir. Backend writes dvs-extra.conf here
    # when Server.bPort != 80; viewer reads via Include in httpd.conf. Empty by
    # default — rda-backend at startup syncs from DB and populates if needed.
    mkdir -p "$DATA_DIR/apache-extra"

    # VideoStore data-dir layout decisions:
    #   1. --video-device  → prepare_video_device handles the partition; we
    #      just need $DATA_DIR/videostore to exist as the bind-mount source.
    #   2. Auto-detected pre-labeled vs[N] (kickstart path) → same; Phase 1
    #      in backend's docker-start mounts the real partition over
    #      /videostore/vs<N> via nsenter on first container start.
    #   3. --no-video-device → operator explicitly accepted the directory
    #      fallback. Create $DATA_DIR/videostore/vs1/dividia/tickets so
    #      Phase 1's directory-mode discovery has somewhere to record to.
    #      Warn loudly that recordings will fill root.
    #   4. Neither flag and no auto-detect: only reachable from upgrade_flow
    #      or migrate_flow (fresh_install_flow has been gated by
    #      check_video_device_or_fail above). The prior VideoStore config
    #      lives in the DB; Phase 1+2 will mount it. Create the fallback dir
    #      as backward-compat (Phase 2 mount shadows it on success).
    if [[ -n "$VIDEO_DEVICE" ]]; then
        mkdir -p "$DATA_DIR/videostore"
    elif [[ -n "$DETECTED_VS_LABELS" ]]; then
        mkdir -p "$DATA_DIR/videostore"
    elif [[ "$NO_VIDEO_DEVICE" == "true" ]]; then
        log_warn "Creating directory-based VideoStore at $DATA_DIR/videostore/vs1"
        log_warn "Recordings will go to root partition — dev/test only"
        mkdir -p "$DATA_DIR/videostore/vs1/dividia/tickets"
    else
        # upgrade_flow / migrate_flow path: VideoStore comes from preserved DB
        mkdir -p "$DATA_DIR/videostore/vs1/dividia/tickets"
    fi

    # Seed timezone files — must exist before docker compose up
    # (Docker creates empty files for missing bind-mount sources, causing UTC fallback)
    mkdir -p "$DATA_DIR/timezone"
    if [[ ! -s "$DATA_DIR/timezone/localtime" ]]; then
        cp /usr/share/zoneinfo/America/Chicago "$DATA_DIR/timezone/localtime"
        # Also set host timezone to match
        if command -v timedatectl &>/dev/null; then
            timedatectl set-timezone America/Chicago
        else
            cp /usr/share/zoneinfo/America/Chicago /etc/localtime
        fi
        log_info "Seeded timezone: America/Chicago"
    fi
    if [[ ! -s "$DATA_DIR/timezone/clock" ]]; then
        cat > "$DATA_DIR/timezone/clock" <<TZEOF
ZONE="America/Chicago"
UTC=false
ARC=false
TZEOF
    fi

    log_info "Created directories:"
    log_info "  Install: $INSTALL_DIR"
    log_info "  Data:    $DATA_DIR"
}

download_compose_files() {
    log_step "Downloading compose files"

    curl -fsSL "$BASE_URL/docker-compose.yml" -o "$INSTALL_DIR/docker-compose.yml"
    log_info "Downloaded docker-compose.yml"

    curl -fsSL "$BASE_URL/docker-compose.prod.yml" -o "$INSTALL_DIR/docker-compose.prod.yml"
    log_info "Downloaded docker-compose.prod.yml"

    # CentOS 6 overlay (headless prod without start_interval)
    if [[ "$OS" == "centos" && "${OS_VERSION%%.*}" == "6" ]]; then
        curl -fsSL "$BASE_URL/docker-compose.co6.yml" -o "$INSTALL_DIR/docker-compose.co6.yml"
        log_info "Downloaded docker-compose.co6.yml"
    fi

    # Windows overlay (no privileged mode, directory VideoStore, no host mounts)
    # Used for both WSL2 and Hyper-V VM Windows deployments
    if [[ "$IS_WINDOWS" == "true" ]]; then
        curl -fsSL "$BASE_URL/docker-compose.windows.yml" -o "$INSTALL_DIR/docker-compose.windows.yml"
        log_info "Downloaded docker-compose.windows.yml"
    fi
}

create_env_file() {
    log_step "Creating environment configuration"

    # Preserve an existing DB root password from a prior .env. The MariaDB
    # bind-mount at data/db_data initializes once and keeps whatever password
    # was generated on that first run; if we wrote a fresh random password
    # here, the backend would get "Access denied" against the existing data.
    # Regenerate only when .env is absent or has no MYSQL_ROOT_PASSWORD line.
    local db_pass
    if [[ -f "$INSTALL_DIR/.env" ]]; then
        db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d '\r\n')
    fi
    if [[ -z "$db_pass" ]]; then
        db_pass=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)
        log_info "Generated new MYSQL_ROOT_PASSWORD"
    else
        log_info "Preserving existing MYSQL_ROOT_PASSWORD from .env"
    fi
    # Detect Docker API version for the .env DOCKER_API_VERSION field.
    # Historical: required by watchtower; kept after watchtower removal
    # to avoid breaking customer .env files that already reference it.
    local api_ver
    api_ver=$(docker version --format '{{.Server.APIVersion}}' 2>/dev/null || echo "1.44")

    cat > "$INSTALL_DIR/.env" <<EOF
# NVR Docker Environment Configuration
# Compose overlay: base + production (videostore + headless)
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
CHANNEL=$CHANNEL
REGISTRY=$REGISTRY
WATCHTOWER_SCHEDULE="$WATCHTOWER_SCHEDULE"
DOCKER_API_VERSION=$api_ver
MYSQL_ROOT_PASSWORD=$db_pass
# NOTE: To change the DB password on a running system, you must first run:
#   nvr db -e "ALTER USER 'root'@'localhost' IDENTIFIED BY 'new-password';"
#   nvr db -e "ALTER USER 'root'@'%' IDENTIFIED BY 'new-password';"
# Then update this value and restart: docker compose down && docker compose up -d
EOF

    # CentOS 6/7: force headless mode (kernel too old for container Xorg/DRM)
    local os_major="${OS_VERSION%%.*}"
    if [[ "$OS" == "centos" && ("$os_major" == "6" || "$os_major" == "7") ]]; then
        echo "NVR_HEADLESS=true" >> "$INSTALL_DIR/.env"
        log_warn "CentOS ${os_major} detected - viewer will run in web-only mode (no local display)"
    fi

    # CentOS 6: use CO6 compose overlay (no start_interval, headless prod settings)
    if [[ "$OS" == "centos" && "$os_major" == "6" ]]; then
        sed -i 's|^COMPOSE_FILE=.*|COMPOSE_FILE=docker-compose.yml:docker-compose.co6.yml|' "$INSTALL_DIR/.env"
        log_info "Using CentOS 6 compose overlay"
    fi

    # Windows: use Windows compose overlay (no privileged, directory VideoStore)
    if [[ "$IS_WINDOWS" == "true" ]]; then
        sed -i 's|^COMPOSE_FILE=.*|COMPOSE_FILE=docker-compose.yml:docker-compose.windows.yml|' "$INSTALL_DIR/.env"
        cat >> "$INSTALL_DIR/.env" <<'WINENV'
NVR_PLATFORM=windows
NVR_LOCAL_NET_SERVICES=0
NVR_HOST_MOUNTS=0
NVR_SVIEW_MODE=1
WINENV
        log_info "Using Windows compose overlay"
    fi

    chown dividia:docker "$INSTALL_DIR/.env"
    chmod 640 "$INSTALL_DIR/.env"
    log_info "Created .env file"
}

################################################################################
# Start Services
################################################################################

# Backfill VideoStore table rows with sDevice / sLabel / sUUID / sFSType
# from blkid — so docker-start's mount logic (Phase 2) can re-mount every
# drive cleanly after a reboot.
#
# Why: RPM NVRs running rda-autofs stored the mount key (often just sUUID,
# sometimes only sMountPoint) in VideoStore. migrate_flow removes
# rda-autofs, but docker/backend/docker-start keeps doing the mount at
# backend-container start via nsenter. Its Phase 2 query needs at least
# one of sDevice / sLabel / sUUID populated to mount the right device.
#
# If the migrated row has only sMountPoint (cs256 2026-04-24), Phase 2
# skips it. First reboot: backend mounts nothing, mpengine writes to
# /videostore/vs1 on the root partition, root fills in ~1.5 hours,
# pbserver rejects every /videostore/vs1/* path because the files are
# on the unmounted drive.
#
# No fstab / autofs manipulation: docker-compose bind is `propagation:
# slave`, so nsenter mount in the backend container propagates to all
# writer containers. Writers (engine/connector/playback) all have
# `depends_on: backend: service_healthy`, so they never start before
# the mount lands.
#
# Idempotent: updates only rows whose fields are empty — preserves any
# values an operator set explicitly.
backfill_videostore_fields() {
    log_step "Backfilling VideoStore device info from blkid"

    if [[ "$IS_WINDOWS" == "true" ]]; then
        log_info "Windows deployment: VideoStore is a container bind; no device to backfill"
        return 0
    fi

    cd "$INSTALL_DIR"

    # Pull mountpoint + whatever's already filled in so we can refuse to
    # overwrite operator-set values.
    local vs_rows
    vs_rows=$(docker compose exec -T db sh -c 'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" dtech -e "SELECT sName, sMountPoint, COALESCE(sDevice,\"\"), COALESCE(sLabel,\"\"), COALESCE(sUUID,\"\"), COALESCE(sFSType,\"\") FROM VideoStore WHERE fEnable=1"' 2>/dev/null) || {
        # Return 0 even on DB-query failure: we run inside migrate_flow under
        # `set -e` + `trap rollback_rpm_services ERR`, and a transient docker
        # compose exec failure must NOT trigger RPM resurrection on a host
        # whose Docker stack is already up with restored data. Backfill
        # failures degrade gracefully — docker-start still has the best-effort
        # mount logic for rows whose fields are already populated.
        log_warn "Could not query VideoStore from DB — skipping backfill"
        log_warn "docker-start will only mount rows whose sDevice/sLabel/sUUID are set."
        return 0
    }

    if [[ -z "$vs_rows" ]]; then
        log_info "No enabled VideoStore entries in DB — nothing to backfill"
        return 0
    fi

    local updated=0
    while IFS=$'\t' read -r name mount cur_device cur_label cur_uuid cur_fstype; do
        [[ -z "$mount" ]] && continue
        # Expect /videostore/<label>. Skip if mountpoint doesn't fit the pattern.
        local expected_label="${mount##*/}"
        if [[ -z "$expected_label" ]]; then
            log_warn "VideoStore '$name' has no parseable mount point — skipping"
            continue
        fi

        # Reject operator-set names that contain anything other than
        # alphanumerics, underscore, dash. VideoStore names in practice are
        # always 'vs1', 'vs2', ... — anything weirder is either operator
        # error or an injection attempt. We splice $name into a SQL WHERE
        # clause below; a lone apostrophe breaks the statement.
        if ! [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then
            log_warn "VideoStore sName '$name' contains unsafe characters — skipping backfill for this row"
            continue
        fi

        # blkid lookup by the label we derived from the mountpoint.
        local blkid_out
        blkid_out=$(blkid -L "$expected_label" 2>/dev/null) || blkid_out=""
        if [[ -z "$blkid_out" ]]; then
            # FALLBACK PATH for RPM-era NVRs.
            #
            # rda-autofs mounted /videostore/vs1 by sDevice or sUUID and never
            # set a LABEL on the ext4 filesystem. So `blkid -L vs1` returns
            # nothing on a freshly-migrated RPM NVR even though the videostore
            # disk is right there in the DB at sDevice. Without this branch,
            # backfill silently no-ops, and on the next host reboot
            # docker-start Phase 2 races with LVM activation / udev — Phase 2
            # tries to mount via sDevice before /dev/mapper/<vg>-<lv> exists,
            # fails silently, mpengine writes mp4s to /videostore/vs1 on the
            # ROOT partition, root fills in ~3 hours.
            #
            # Surfaced by cs999 (Lawn Mower Sales) 2026-05-15 — ISP outage
            # triggered a host reboot, came back with /videostore/vs1 NOT
            # mounted, root climbed from 76% to 97% in ~2 hours before manual
            # remount. cs50 + cs1129 had identical empty rows but hadn't yet
            # rebooted.
            #
            # Fallback: if the DB row already has sDevice populated, blkid
            # that device directly to read UUID/FSType. Also try e2label to
            # add the missing label for future Phase 1 auto-discovery — only
            # when the FS is ext2/3/4 (other FS types need different label
            # tools that may not be installed).
            if [[ -z "$cur_device" || ! -b "$cur_device" ]]; then
                log_warn "No block device labeled '$expected_label' and no usable DB sDevice — '$name' must be hand-fixed"
                log_warn "  Operator: 'e2label <device> $expected_label' + re-run --migrate"
                continue
            fi

            log_info "No '$expected_label' label found; falling back to DB sDevice=$cur_device"
            blkid_out="$cur_device"

            # Best-effort label add (ext only). Failure is non-fatal — UUID
            # in the DB is what Phase 2 actually mounts by.
            # `|| true`: blkid returns non-zero on "no such device" or "no such
            # field"; under set -eE inside migrate_flow's trap window this
            # would fire rollback_rpm_services on a benign probe failure.
            # Empty fs_for_label falls through to the "not ext" branch which
            # is the correct skip-e2label path.
            local fs_for_label
            fs_for_label=$(blkid -s TYPE -o value "$cur_device" 2>/dev/null || true)
            if [[ "$fs_for_label" =~ ^ext[234]$ ]]; then
                if e2label "$cur_device" "$expected_label" 2>/dev/null; then
                    log_info "  e2label set '$expected_label' on $cur_device (Phase 1 auto-discovery enabled)"
                else
                    log_warn "  e2label failed (root needed? read-only FS?) — Phase 1 will skip, Phase 2 still works via UUID"
                fi
            else
                log_info "  $cur_device is $fs_for_label, not ext — skipping e2label; Phase 2 still works via UUID"
            fi
        fi

        # Refuse ambiguous labels — if multiple partitions claim the same
        # label (USB hot-swap, drive clone, leftover partition), blkid -L
        # returns whichever the kernel registered first. Mounting the wrong
        # disk silently loses customer recordings. Bail loudly and name
        # both devices so the operator can unlabel one with e2label.
        local blkid_all
        blkid_all=$(blkid | awk -v lbl="$expected_label" -F: '
            $0 ~ "LABEL=\""lbl"\"" {print $1}
        ')
        local blkid_count
        # `grep -c .` exits 1 on empty stdin (no matching lines). When
        # blkid_all is empty (the cs999 fallthrough case where awk found
        # no LABEL-tagged devices), bare assignment under set -e would
        # abort the script — and now under set -eE that abort would
        # fire the migrate_flow ERR trap on a non-error condition.
        # The intent here is "count lines, treat empty as zero".
        blkid_count=$(echo "$blkid_all" | grep -c . || true)
        if [[ "$blkid_count" -gt 1 ]]; then
            log_warn "Multiple devices labeled '$expected_label' — refusing to backfill '$name':"
            while IFS= read -r dev; do log_warn "    $dev"; done <<< "$blkid_all"
            log_warn "  Remove or re-label one with 'e2label <dev> <new-label>' and re-run migration"
            continue
        fi

        local new_device new_uuid new_fstype
        new_device="$blkid_out"
        # `|| true`: blkid can return non-zero on transient kernel state (FS
        # not yet probable, device just attached). Under set -eE inside the
        # trap window this would fire rollback on a benign probe. The
        # validation block immediately below already handles empty / malformed
        # outputs by skipping the row.
        new_uuid=$(blkid -s UUID -o value "$new_device" 2>/dev/null || true)
        new_fstype=$(blkid -s TYPE -o value "$new_device" 2>/dev/null || true)

        # Defense in depth: validate blkid outputs match the expected shapes
        # before we splice them into SQL. Anything that doesn't match is
        # either pathological or hostile — skip the row rather than risk
        # running arbitrary SQL as DB root.
        if ! [[ "$new_device" =~ ^/dev/[A-Za-z0-9/_-]+$ ]]; then
            log_warn "blkid returned unexpected device path '$new_device' — skipping '$name'"
            continue
        fi
        if [[ -n "$new_uuid" ]] && ! [[ "$new_uuid" =~ ^[0-9A-Fa-f-]+$ ]]; then
            log_warn "blkid returned unexpected UUID '$new_uuid' — skipping '$name'"
            continue
        fi
        if [[ -n "$new_fstype" ]] && ! [[ "$new_fstype" =~ ^[a-z0-9_]+$ ]]; then
            log_warn "blkid returned unexpected fstype '$new_fstype' — skipping '$name'"
            continue
        fi

        # Only overwrite empty fields — operator explicit values are sacred.
        local set_clauses=""
        [[ -z "$cur_device" ]] && set_clauses+="sDevice='$new_device',"
        [[ -z "$cur_label"  ]] && set_clauses+="sLabel='$expected_label',"
        [[ -z "$cur_uuid"   ]] && set_clauses+="sUUID='$new_uuid',"
        [[ -z "$cur_fstype" ]] && set_clauses+="sFSType='${new_fstype:-ext4}',"
        set_clauses="${set_clauses%,}"

        if [[ -z "$set_clauses" ]]; then
            log_info "VideoStore '$name' already populated — leaving"
            continue
        fi

        if docker compose exec -T db sh -c "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" dtech -e \"UPDATE VideoStore SET $set_clauses WHERE sName='$name'\"" >/dev/null 2>&1; then
            log_info "VideoStore '$name' -> device=$new_device uuid=$new_uuid label=$expected_label"
            updated=$((updated + 1))
        else
            log_warn "Failed to update VideoStore '$name'"
        fi
    done <<< "$vs_rows"

    log_info "Backfill complete ($updated rows updated)"
}

disable_host_services() {
    # Windows deployments (WSL2/Hyper-V) have no host services to conflict with
    if [[ "$IS_WINDOWS" == "true" ]]; then
        log_info "Windows deployment: skipping host service checks"
        return
    fi

    log_step "Disabling conflicting host services"

    # Stop, disable, and mask host services that conflict with Docker containers
    # using host networking: httpd (port 80), smbd/nmbd (port 445).
    #
    # Mask unconditionally, not just when currently active. A boot-time race —
    # systemd transitioning, service not yet active when install-nvr.sh hits
    # this step — can slip past an active-only check and let systemd start
    # the service moments later, stealing the port before `docker compose up`
    # brings the container online. Surfaced on the macbuilder CentOS 9
    # claude-centos9-stream VM 2026-04-24: viewer container restart-looped
    # with "Address already in use: 0.0.0.0:80" because host httpd.service
    # came up after this step logged empty.
    #
    # autofs is handled separately in ensure_videostore_writable — masking it
    # unconditionally would unmount /videostore on real-disk NVRs (cs256 /
    # cs2427 / cs50) during the migration window, before docker-start Phase 2
    # remounts. The host-service mask here stays scoped to port-conflict
    # services only.
    #
    # Mask is idempotent and reversible via `systemctl unmask`; no harm on
    # hosts that never had the services installed.
    for svc in httpd smbd nmbd; do
        svc_stop "$svc" 2>/dev/null || true
        svc_disable "$svc" 2>/dev/null || true
        if has_systemd; then
            systemctl mask "$svc" 2>/dev/null || true
        fi
    done
    # Belt and suspenders: pkill after mask. On CentOS 7, systemctl stop
    # can return before the process fully exits; on any host, systemd may
    # have respawned the service in the window between stop and mask.
    pkill -9 httpd 2>/dev/null || true
    pkill -9 smbd 2>/dev/null || true
    pkill -9 nmbd 2>/dev/null || true
}

ensure_videostore_writable() {
    # Make sure /videostore is a directory the docker viewer container can write
    # to when it bind-mounts /videostore on first start. Without this, the
    # viewer crash-loops on `mkdir /videostore/vs1: EACCES` and the migration
    # leaves a broken NVR.
    #
    # Two host shapes seen in the field:
    #
    #   1. Real-disk NVRs (cs256 / cs2427 / cs50): rda-autofs binds /videostore
    #      to the videostore disk. vs1 already exists on the disk with content.
    #      The mkdir below is a no-op — the disk stays mounted, recording
    #      continues, and docker-start Phase 2 later assumes ownership of the
    #      mount via nsenter.
    #
    #   2. Cloud-shape NVRs (cs1018): the autofs daemon is still running but
    #      its map is empty (rda-autofs config gone, or never had a real disk).
    #      mkdir fails with EACCES because autofs refuses to materialize an
    #      entry the map doesn't know about. We detect that, stop+mask autofs
    #      to release the binding, then create vs1 on the root partition so
    #      the viewer can write.
    #
    # Probe-then-act is more reliable than file-presence detection — we react
    # to the actual symptom (mkdir EACCES), not a proxy that could mis-classify
    # an unusual host (e.g., a CloudNVR that historically had a disk and so
    # has both a /etc/auto.videostore file AND no real volume to point at).
    if [[ "$IS_WINDOWS" == "true" ]]; then
        log_info "Windows deployment: skipping /videostore writability check"
        return 0
    fi

    log_step "Ensuring /videostore is writable for viewer bind-mount"

    # Probe by attempting the actual mkdir we need. Use `if` so the
    # failure path doesn't trip the script-wide `set -e` (a bare
    # assignment from a failing $(...) substitution would abort here).
    if mkdir -p /videostore/vs1 2>/dev/null; then
        # mkdir -p returns 0 even when /videostore/vs1 pre-exists with
        # restrictive ownership (e.g., root:root 700 left by an earlier
        # bootstrap that failed before the dividia user was created).
        # Force ownership now so the viewer container's bind-mount user
        # can write at runtime instead of crashing with EACCES.
        chown -R dividia:docker /videostore/vs1 2>/dev/null \
            || chown -R root:root /videostore/vs1 2>/dev/null || true
        log_info "/videostore/vs1 ready (writable real disk or empty host directory)"
        return 0
    fi

    # mkdir failed. Distinguish "autofs has stale/empty map at /videostore"
    # (the cs1018 case we built this function for) from "something else is
    # broken — read-only FS, EIO, ENOSPC, SELinux denial, missing parent".
    # Masking autofs on a non-autofs failure would redirect writes to the
    # root partition and hide the underlying fault.
    #
    # Discriminate by querying the filesystem type at /videostore, not by
    # mkdir's stderr text: stderr is locale-dependent ("Keine Berechtigung"
    # on a German host) and SELinux denials surface as "Permission denied"
    # but aren't autofs-related. findmnt -o FSTYPE returns exactly the
    # mount type for the target, so a real-disk ext4/xfs at /videostore
    # with EROFS won't be misclassified as autofs.
    local autofs_at_videostore=0
    if has_systemd && systemctl is-active --quiet autofs 2>/dev/null; then
        local vs_fstype
        vs_fstype=$(findmnt -n -o FSTYPE /videostore 2>/dev/null || true)
        if [[ "$vs_fstype" == "autofs" ]]; then
            autofs_at_videostore=1
        fi
    fi

    if [[ "$autofs_at_videostore" != "1" ]]; then
        log_error "/videostore not writable AND autofs is not bound there — underlying fault not the stale-map case"
        log_error "  mount: $(mount 2>/dev/null | grep -E 'videostore' || echo '(no /videostore mount)')"
        log_error "Refusing to mask autofs — would redirect writes to root partition and hide the real fault."
        log_error "Operator: investigate the underlying mount/disk/SELinux state at /videostore before re-running."
        # Use return 1 (not exit 1): in migrate_flow, this function runs
        # inside the rollback-trap window (line 2686 `trap ERR ... ` →
        # line 2742 `trap - ERR`). exit 1 would bypass the trap, leaving
        # the host with RPM services stopped and Docker half-installed.
        # return 1 propagates via set -e to migrate_flow's call site and
        # fires rollback_rpm_services.
        return 1
    fi

    log_warn "/videostore not writable AND autofs is bound there — stale/empty map case"
    log_info "Masking autofs and re-seeding /videostore"
    svc_stop autofs    2>/dev/null || true
    svc_disable autofs 2>/dev/null || true
    if has_systemd; then
        systemctl mask autofs 2>/dev/null || true
    fi
    pkill -9 automount 2>/dev/null || true

    mkdir -p /videostore/vs1
    chown -R dividia:docker /videostore 2>/dev/null \
        || chown -R root:root /videostore 2>/dev/null || true
    log_info "/videostore/vs1 created on root partition"
}

install_host_firefox() {
    # Install firefox + fonts on host so the backend can launch Utilities → Support
    # → Open Browser via nsenter into the host, avoiding firefox in the viewer image.
    # Create bind-mount host dirs for sharing the X socket and Xauthority cookie
    # between the viewer container (writer) and host firefox (reader).
    #
    # On install failure, the flag file at $DATA_DIR/config/nvr-host-firefox-available
    # is left absent. Open Browser will silently no-op for that install until firefox
    # is manually installed and the flag is re-created (viewer image no longer ships
    # firefox as a fallback — saved 343 MB from the viewer image).
    log_step "Configuring host firefox for Open Browser feature"

    # Host dirs are required by docker-compose.yml bind mounts regardless of whether
    # host firefox is available — viewer's Xorg always writes the X socket to
    # /var/run/dividia/x11 (bind-mounted from /tmp/.X11-unix inside the container).
    mkdir -p /var/run/dividia/x11
    chmod 1777 /var/run/dividia/x11
    mkdir -p /var/run/dividia/xauth
    chmod 755 /var/run/dividia/xauth
    log_info "Created host X11 + xauth share dirs at /var/run/dividia/"

    local flag_file="$DATA_DIR/config/nvr-host-firefox-available"
    rm -f "$flag_file"

    local os_major="${OS_VERSION%%.*}"
    local rc=0

    # Run install in a non-fatal block (script runs under `set -e`).
    # Ubuntu server-minimal installs are missing fonts + a few X libs that
    # firefox (especially snap firefox on 24.04) needs for non-Latin text
    # rendering and shared-memory compositing. Add them defensively even
    # though the firefox snap bundles most of its own deps — host-side
    # fonts still matter for fontconfig resolution.
    case "$OS_FAMILY" in
        debian)
            # DEBIAN_FRONTEND=noninteractive is essential here, NOT cosmetic.
            # On Ubuntu 24.04, firefox apt is a transitional shim to firefox
            # snap. If snapd cannot reach api.snapcraft.io (slow network,
            # pre-NTP-sync VM, restricted egress) the snap preinst hits a
            # debconf "Retry / Abort / Skip" prompt that `-y` does NOT
            # auto-answer — bare `apt-get install -y` only handles apt's own
            # prompts, not preinst-script debconf dialogs. Without this env
            # the script hangs forever waiting on stdin that's not connected.
            #
            # The `timeout 300` wrapper is a second line of defense. Once
            # debconf auto-skips, the snap preinst falls into its OWN retry
            # loop ("Unable to contact the store, trying every minute for
            # the next 30 minutes"). That's wasted wall-clock time — if the
            # store wasn't reachable in 30s, it's not coming back in 30min.
            # 300s is generous enough for slow-but-working installs (snap
            # downloads firefox at ~250 MB) and bails fast on no-connectivity.
            # On timeout, exit code 124 falls through `|| rc=$?` into the
            # graceful-degradation warn — Open Browser feature no-ops.
            DEBIAN_FRONTEND=noninteractive timeout 300 apt-get install -y \
                firefox \
                fonts-liberation \
                fonts-dejavu \
                fonts-noto-core \
                libxshmfence1 \
                xauth \
                || rc=$?
            ;;
        rhel)
            # NVR server hosts (CO6/CO7/CO9+) run headless: viewer container only
            # starts apache2 + php-fpm, never Xorg + dview. Backend's nsenter→
            # host-firefox path needs a DISPLAY target that doesn't exist on
            # headless deployments, so installing firefox here just wastes
            # ~300 MB on / (verified empirically across cs999/cs1018/cs50/cs1129
            # 2026-05-15 — none of them had Xorg running on host or container).
            #
            # If a future deployment shape adds Xorg+dview to the viewer
            # container on RHEL (e.g., kiosk mode with local monitor), un-skip
            # this block. Open Browser will no-op gracefully on headless: the
            # flag file stays absent and the menu item silently degrades.
            log_info "RHEL ${os_major} server host runs headless — skipping host firefox install"
            return 0
            ;;
        *)
            log_warn "Unknown OS family '$OS_FAMILY' — skipping host firefox install"
            return 0
            ;;
    esac

    if [[ $rc -ne 0 ]] || ! command -v firefox &>/dev/null; then
        log_warn "host firefox not available; Utilities → Support → Open Browser will no-op until firefox is installed and /data/nvr-host-firefox-available is created"
        return 0
    fi

    # Ubuntu 24.04's firefox apt package is a transitional shim to the firefox snap.
    # `command -v firefox` resolves to the shim regardless of whether the snap itself
    # actually installed (snap download can fail silently on restricted networks).
    # Verify the snap binary exists before marking firefox "available" — otherwise the
    # shim prints "Command requires the firefox snap to be installed" at runtime and
    # Support → Open Browser silently no-ops forever.
    if [[ "$OS_FAMILY" == "debian" ]] && \
       command -v firefox &>/dev/null && \
       file "$(command -v firefox)" 2>/dev/null | grep -q 'shell script' && \
       [[ ! -x /snap/bin/firefox ]]; then
        log_warn "firefox apt shim is installed but /snap/bin/firefox is missing — snap install did not complete; skipping flag file"
        return 0
    fi

    touch "$flag_file"
    log_info "Host firefox installed; backend will launch via nsenter (flag: $flag_file)"
}

pull_and_start() {
    log_step "Pulling and starting NVR services"

    cd "$INSTALL_DIR"
    docker compose pull --quiet

    # up -d may return non-zero if dependent services time out waiting for
    # backend's health check (which can be slow on first run: DB seeding,
    # schema setup, etc). Dependent services end up in "Created" state.
    # Retry after backend is healthy to bring them up.
    if ! docker compose up -d --quiet-pull 2>&1; then
        log_warn "Some services failed to start (backend may still be initializing)"
        wait_for_backend_healthy
        log_info "Retrying service start..."
        docker compose up -d --quiet-pull 2>/dev/null || true
    fi

    log_info "NVR services started"
}

################################################################################
# Boot Service
################################################################################

create_boot_service() {
    if has_systemd; then
        create_systemd_unit
    else
        create_sysv_init_script
    fi
}

create_systemd_unit() {
    log_step "Creating systemd service"

    cat > /etc/systemd/system/nvr.service <<EOF
[Unit]
Description=NVR Docker Stack
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=$INSTALL_DIR
ExecStartPre=/bin/mkdir -p /videostore
ExecStart=$INSTALL_DIR/nvr start
ExecStop=$INSTALL_DIR/nvr stop
TimeoutStartSec=0

[Install]
WantedBy=multi-user.target
EOF

    systemctl daemon-reload
    systemctl enable nvr.service

    log_info "Created systemd service: nvr.service"
}

create_sysv_init_script() {
    log_step "Creating SysV init script"

    cat > /etc/init.d/nvr <<INITEOF
#!/bin/bash
# chkconfig: 2345 96 04
# description: NVR Docker Stack

### BEGIN INIT INFO
# Provides:       nvr
# Required-Start: docker
# Required-Stop:  docker
# Default-Start:  2 3 4 5
# Default-Stop:   0 1 6
# Description:    NVR Docker Stack
### END INIT INFO

PATH=/usr/local/bin:\$PATH
INSTALL_DIR="$INSTALL_DIR"

start() {
    echo -n "Starting NVR Docker Stack: "
    # Clean stale containerd/runc state from hard reboot (kernel 4.4 doesn't
    # always clean these up on shutdown)
    rm -rf /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/* 2>/dev/null
    rm -rf /run/containerd/runc/moby/* 2>/dev/null
    # Remove stale containers so compose up doesn't fail with "already exists"
    cd "\$INSTALL_DIR" && docker compose rm -f 2>/dev/null
    docker compose up -d --quiet-pull && echo "OK" || echo "FAILED"
}

stop() {
    echo -n "Stopping NVR Docker Stack: "
    cd "\$INSTALL_DIR" && docker compose down && echo "OK" || echo "FAILED"
}

status() {
    cd "\$INSTALL_DIR" && docker compose ps
}

case "\$1" in
    start)   start ;;
    stop)    stop ;;
    restart) stop; start ;;
    status)  status ;;
    *)       echo "Usage: \$0 {start|stop|restart|status}"; exit 1 ;;
esac
INITEOF
    chmod 755 /etc/init.d/nvr
    chkconfig nvr on

    log_info "Created SysV init script: /etc/init.d/nvr"
}

################################################################################
# Management Scripts
################################################################################

create_management_scripts() {
    log_step "Installing management CLI"

    # Extract nvr CLI, install-nvr.sh, and the RO forced-command wrapper from
    # the backend image. The wrapper lands at /usr/local/bin/nvr-ro-wrap on
    # the VM host filesystem so sshd's authorized_keys `command="..."` entry
    # can exec it when the customer-readable RO key connects.
    local image
    image=$(cd "$INSTALL_DIR" && docker compose config --images 2>/dev/null | grep backend | head -1)
    if [[ -n "$image" ]]; then
        local cid
        cid=$(docker create "$image" 2>/dev/null) || true
        if [[ -n "$cid" ]]; then
            docker cp "$cid:/usr/share/nvr/bin/nvr" "$INSTALL_DIR/nvr" 2>/dev/null || true
            docker cp "$cid:/usr/share/nvr/bin/install-nvr.sh" "$INSTALL_DIR/install-nvr.sh" 2>/dev/null || true
            docker cp "$cid:/usr/local/bin/nvr-ro-wrap" "/usr/local/bin/nvr-ro-wrap" 2>/dev/null || true
            docker rm "$cid" > /dev/null
        fi
    fi

    chmod 555 "$INSTALL_DIR/nvr" "$INSTALL_DIR/install-nvr.sh" 2>/dev/null || true
    chmod 555 /usr/local/bin/nvr-ro-wrap 2>/dev/null || true
    log_info "Installed nvr CLI to $INSTALL_DIR/nvr"
    [ -x /usr/local/bin/nvr-ro-wrap ] && log_info "Installed RO wrapper to /usr/local/bin/nvr-ro-wrap"
}

################################################################################
# Image-prune cron
################################################################################

install_prune_cron() {
    log_step "Installing daily Docker image prune cron"

    cat > /etc/cron.d/dividia-docker-prune <<'CRON'
# Daily Docker image prune for Dividia NVR.
#
# Reclaims disk from images that have been replaced (by watchtower auto-pull,
# by `nvr update`, by `nvr channel <new>` + manual `docker compose pull`, or
# by ad-hoc operator pulls). Two passes:
#   - dangling (no -a): immediately removes <none>:<none> images that were
#     replaced. Safe by definition; nothing references them.
#   - 168h time-filtered (-a): catches still-tagged images that haven't
#     been used in a week.
# Without the dangling pass, active dev iteration on dev/dev-* channels
# accumulates GB of dangling images that the time filter holds for a week.
#
# Owned by /usr/share/nvr/bin/nvr ensure_prune_cron; do not edit by hand.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
30 3 * * * root (/usr/bin/docker image prune -f; /usr/bin/docker image prune -a -f --filter "until=168h") >/var/log/dividia-docker-prune.log 2>&1
CRON
    chmod 0644 /etc/cron.d/dividia-docker-prune
    log_info "  Cron: /etc/cron.d/dividia-docker-prune (03:30 daily)"
}

################################################################################
# nvr update cron
################################################################################
#
# Replaces watchtower auto-pull (pitfall_watchtower_strips_compose_labels).
# Mirrors `ensure_update_cron` in docker/nvr — both scripts MUST write the
# same cron body or test_update_cron_contract.sh fails. Inline duplication
# matches the install_prune_cron / ensure_prune_cron precedent.

install_update_cron() {
    log_step "Installing daily nvr update cron"

    # Compute a stable per-host minute jitter in [0,59] so the fleet
    # doesn't all hit DockerHub at the same instant. Same algorithm as
    # nvr CLI's update_cron_jitter() — duplicated rather than shared
    # because install-nvr.sh runs before the nvr CLI is on disk.
    local jitter id=""
    local dvs_conf="$DATA_DIR/config/dvs.conf"
    if [[ -f "$dvs_conf" ]]; then
        id=$(grep -E '^ID=' "$dvs_conf" 2>/dev/null \
             | head -1 \
             | sed -E 's/^ID="?([^"]*)"?.*$/\1/' \
             | tr -d '[:space:]')
    fi
    if [[ "$id" =~ ^[0-9]+$ ]] && [[ "$id" -gt 0 ]]; then
        jitter=$(( id % 60 ))
    else
        jitter=$(hostname 2>/dev/null | cksum 2>/dev/null | awk '{print $1 % 60}')
        [[ -z "$jitter" ]] && jitter=0
    fi

    # Non-quoted heredoc so $jitter interpolates. Body MUST match nvr
    # CLI's ensure_update_cron output character-for-character (modulo the
    # jitter value, which the contract test ignores).
    cat > /etc/cron.d/dividia-nvr-update <<EOF
# Daily nvr update for Dividia NVR.
#
# Replaces watchtower auto-pull (containrrr/watchtower 1.7.1, unmaintained
# since 2024-01 and known to strip com.docker.compose.project labels
# during recreate — see [[pitfall-watchtower-strips-compose-labels]]).
#
# nvr update is the right loop: self-heals orphaned compose project
# labels, pulls images, extracts updated compose + CLI from the new
# backend image, calls compose up -d, prunes obsolete images, then
# removes the watchtower container at the tail of a successful run.
#
# Schedule: 02:NN host-local, where NN = bSerial % 60 from dvs.conf.
# Spreads the fleet across the 02:00-02:59 hour so DockerHub doesn't
# get a thundering herd. Concurrent operator-typed and cron-fired
# invocations serialize via flock inside cmd_update.
#
# Owned by /usr/share/nvr/bin/nvr ensure_update_cron; do not edit by hand.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$jitter 2 * * * root /opt/dividia/nvr update >/var/log/dividia-nvr-update.log 2>&1
EOF
    chmod 0644 /etc/cron.d/dividia-nvr-update
    log_info "  Cron: /etc/cron.d/dividia-nvr-update (02:${jitter} daily host-local)"
}

################################################################################
# User Access
################################################################################

configure_user_access() {
    log_step "Configuring user access"

    # Add /opt/dividia to PATH for all users via profile.d
    cat > /etc/profile.d/nvr.sh <<'PROFILE'
# NVR management CLI
if [ -d /opt/dividia ]; then
    PATH="/opt/dividia:$PATH"
fi
PROFILE
    chmod 644 /etc/profile.d/nvr.sh
    log_info "Added $INSTALL_DIR to PATH via /etc/profile.d/nvr.sh"

    # Add dividia user to docker group for passwordless docker access
    if id "dividia" &>/dev/null; then
        usermod -aG docker dividia
        log_info "Added dividia user to docker group"

        # Passwordless sudo for dividia user (NVR management)
        cat > /etc/sudoers.d/dividia <<'SUDOEOF'
dividia ALL=(ALL) NOPASSWD: ALL
SUDOEOF
        chmod 440 /etc/sudoers.d/dividia
        log_info "Added dividia to sudoers"

        # Merge root's SSH authorized_keys into dividia's (dedup by exact line).
        # CentOS/Ubuntu path: root has the develop tech support key — needed for
        # passwordless SSH to all NVRs. Windows Hyper-V path: cloud-init puts
        # the Windows host key in dividia's authorized_keys directly. Merging
        # preserves both so tech support can reach either install type.
        if [[ -f /root/.ssh/authorized_keys ]]; then
            mkdir -p /home/dividia/.ssh
            touch /home/dividia/.ssh/authorized_keys
            # Pass files directly to awk instead of piping through `cat`. When
            # one of the input files lacks a trailing newline, `cat` splices
            # its last line onto the next file's first line, producing a single
            # mashed key line that sshd rejects. awk treats each file's EOF as
            # a record boundary, so this is safe regardless of trailing-newline
            # state.
            awk 'NF && !seen[$0]++' \
                /home/dividia/.ssh/authorized_keys \
                /root/.ssh/authorized_keys \
                > /home/dividia/.ssh/authorized_keys.new
            mv /home/dividia/.ssh/authorized_keys.new /home/dividia/.ssh/authorized_keys
            chown -R dividia:dividia /home/dividia/.ssh
            chmod 700 /home/dividia/.ssh
            chmod 600 /home/dividia/.ssh/authorized_keys
            log_info "Merged root's SSH authorized_keys into dividia"
        fi

        # Sync dividia's password to root's. Migrations from old RPM NVRs
        # often inherit a pre-2024 dividia user with a password nobody
        # remembers; techs know root's password (shared) and expect dividia
        # to accept the same one.
        #
        # TRADEOFF: if a tech intentionally set a different dividia password
        # AFTER the initial install, re-running install-nvr.sh (e.g. for an
        # --upgrade or --migrate) will clobber it. install-nvr.sh isn't run
        # on steady-state (nvr update uses docker-compose only), so this is
        # bounded to explicit maintenance invocations. If you need a distinct
        # dividia password that survives reinstalls, set it AFTER the last
        # install-nvr.sh run in the NVR's lifecycle.
        local root_hash
        root_hash=$(getent shadow root | cut -d: -f2)
        if [[ -n "$root_hash" && "$root_hash" != "!"* && "$root_hash" != "*" ]]; then
            # Surface the clobber at runtime so an operator who set a distinct
            # dividia password post-install notices before they lose it.
            log_warn "Resetting dividia password to match root (any distinct password set earlier is lost)"
            usermod -p "$root_hash" dividia
            log_info "Copied root password hash to dividia"
        fi
    fi
}

################################################################################
# Upgrade Detection
################################################################################

detect_existing_install() {
    log_step "Checking for existing NVR backup on VideoStore"

    # Find vs1 partition
    local vs_dev
    vs_dev=$(blkid -l -t LABEL=vs1 -o device 2>/dev/null || true)
    if [[ -z "$vs_dev" ]]; then
        log_info "No VideoStore partition (vs1) found"
        return 1
    fi

    # Mount temporarily if not already mounted
    local vs_mount=""
    local did_mount=false
    vs_mount=$(findmnt -rn -o TARGET -S LABEL=vs1 2>/dev/null || true)
    if [[ -z "$vs_mount" ]]; then
        vs_mount="/tmp/vs1-detect"
        mkdir -p "$vs_mount"
        if ! mount -o ro "$vs_dev" "$vs_mount" 2>/dev/null; then
            log_info "Could not mount $vs_dev"
            rmdir "$vs_mount" 2>/dev/null || true
            return 1
        fi
        did_mount=true
    fi

    # Look for backup directories (not .restored)
    local backup_found=false
    if [[ -d "$vs_mount/backups" ]]; then
        for dir in "$vs_mount"/backups/[0-9]*; do
            [[ -d "$dir" ]] || continue
            # Skip .restored directories
            [[ "$dir" == *.restored ]] && continue
            # Verify it has a conf directory (valid backup)
            if [[ -d "$dir/conf" ]]; then
                UPGRADE_BACKUP_DIR="$dir"
                backup_found=true
                break
            fi
        done
    fi

    # Check for saved .env
    local env_saved=false
    if [[ -f "$vs_mount/backups/.env.save" ]]; then
        env_saved=true
    fi

    if $did_mount; then
        umount "$vs_mount" 2>/dev/null || true
        rmdir "$vs_mount" 2>/dev/null || true
    fi

    if $backup_found; then
        log_info "Found backup: $UPGRADE_BACKUP_DIR"
        [[ "$env_saved" == "true" ]] && log_info "Found saved .env file"
        return 0
    fi

    log_info "No backup found on VideoStore"
    return 1
}

restore_saved_env() {
    log_step "Restoring saved .env from VideoStore"

    local vs_mount
    vs_mount=$(findmnt -rn -o TARGET -S LABEL=vs1 2>/dev/null || true)
    if [[ -z "$vs_mount" ]]; then
        # VideoStore might not be mounted yet — try /videostore/vs1
        vs_mount="/videostore/vs1"
    fi

    if [[ -f "$vs_mount/backups/.env.save" ]]; then
        # Restore .env but preserve any channel override from command line
        local saved_env="$vs_mount/backups/.env.save"
        local current_channel="$CHANNEL"

        cp "$saved_env" "$INSTALL_DIR/.env"
        chown dividia:docker "$INSTALL_DIR/.env" 2>/dev/null || true
        chmod 640 "$INSTALL_DIR/.env"

        # If channel was explicitly passed, override what was saved
        if [[ "$CHANNEL_EXPLICIT" == "true" ]]; then
            sed -i "s/^CHANNEL=.*/CHANNEL=$current_channel/" "$INSTALL_DIR/.env"
        fi

        log_info "Restored .env from backup"
    else
        log_info "No saved .env found, using freshly created one"
    fi
}

wait_for_backend_healthy() {
    log_step "Waiting for backend to become healthy"

    local max_wait=120
    local waited=0
    while [[ $waited -lt $max_wait ]]; do
        local health
        health=$(docker compose -f "$INSTALL_DIR/docker-compose.yml" ps backend --format '{{.Health}}' 2>/dev/null || true)
        if [[ "$health" == "healthy" ]]; then
            log_info "Backend is healthy"
            return 0
        fi

        # Also check if the container is running at all
        local state
        state=$(docker compose -f "$INSTALL_DIR/docker-compose.yml" ps backend --format '{{.State}}' 2>/dev/null || true)
        if [[ "$state" == "exited" ]] || [[ "$state" == "dead" ]]; then
            log_error "Backend container exited unexpectedly"
            return 1
        fi

        sleep 5
        waited=$((waited + 5))
        [[ $((waited % 15)) -eq 0 ]] && log_info "Still waiting... ($waited/${max_wait}s)"
    done

    log_warn "Backend did not become healthy within ${max_wait}s (proceeding anyway)"
    return 0
}

################################################################################
# Video Device Preparation
################################################################################

# Scan blkid for any vs[N]-labeled partitions on the host. Returns 0 and sets
# DETECTED_VS_LABELS=" vs1 vs2 ..." if any are found; returns 1 (no labels)
# otherwise.
#
# Used by check_video_device_or_fail to allow kickstart-driven fresh installs
# (which pre-label vs1 at OS-install time via anaconda/curtin) to proceed
# without --video-device. Without this auto-detect bypass, the fail-closed
# gate below would break every kickstart install.
detect_existing_vs_labels() {
    local n found=""
    # Refresh blkid cache so labels written between OS install and this
    # script's invocation are visible.
    blkid -g 2>/dev/null || true
    for n in 1 2 3 4 5 6 7 8 9; do
        if blkid -L "vs$n" >/dev/null 2>&1; then
            found="$found vs$n"
        fi
    done
    DETECTED_VS_LABELS="${found# }"
    [[ -n "$DETECTED_VS_LABELS" ]]
}

# Fail-closed gate for fresh installs. ONLY called from fresh_install_flow —
# upgrade_flow and migrate_flow preserve the prior VideoStore configuration
# via the database and have no need for an install-time gate.
#
# Three paths are valid:
#   1. --video-device <path> — operator explicitly designates a drive
#   2. --no-video-device      — operator opts into directory fallback
#   3. Pre-labeled vs[N] partition exists (kickstart path) — auto-detect
#
# Anything else exits non-zero with an explicit error naming both flags.
# This closes the silent-root-fallback footgun where a manual operator
# forgets --video-device on a fresh disk with no pre-labeled vs[N] and
# recordings overflow the root partition within hours.
check_video_device_or_fail() {
    if [[ -n "$VIDEO_DEVICE" ]]; then
        return 0
    fi
    if [[ "$NO_VIDEO_DEVICE" == "true" ]]; then
        return 0
    fi
    if detect_existing_vs_labels; then
        log_info "Detected pre-labeled VideoStore partition(s): $DETECTED_VS_LABELS"
        log_info "Backend Phase 1 will discover and mount on first startup"
        return 0
    fi

    cat >&2 <<'EOF'

ERROR: No VideoStore configured.

  No vs[N]-labeled partitions found on this host (checked vs1..vs9
  via blkid). install-nvr.sh requires one of the following:

    --video-device /dev/sdX       Dedicated drive (production).
                                  Script will partition, format, label vs1.

    --no-video-device             Directory-only fallback (dev/test only).
                                  Recordings go to root; will fill within
                                  hours of sustained motion.

  If this hardware was provisioned via kickstart, the vs[N] label should
  already exist. If `blkid -L vs1` returns nothing, the kickstart
  partition step likely failed — check OS install logs before retrying.

EOF
    exit 2
}

prepare_video_device() {
    log_step "Preparing video device: $VIDEO_DEVICE"

    # Validate it's a block device
    if [[ ! -b "$VIDEO_DEVICE" ]]; then
        log_error "$VIDEO_DEVICE is not a block device"
        exit 1
    fi

    local PART_DEV="$VIDEO_DEVICE"

    # If raw disk (no trailing digit), check for existing partition first
    if [[ ! "$VIDEO_DEVICE" =~ [0-9]$ ]]; then
        PART_DEV="${VIDEO_DEVICE}1"
        if [[ -b "$PART_DEV" ]]; then
            log_info "Existing partition $PART_DEV found — skipping partitioning"
        else
            log_info "Partitioning $VIDEO_DEVICE..."
            if command -v parted &>/dev/null; then
                parted "$VIDEO_DEVICE" --script -- mktable gpt
                parted "$VIDEO_DEVICE" --script -- mkpart primary 0% 100%
            else
                # CentOS 6 may lack parted — fall back to fdisk (MBR)
                echo -e "n\np\n1\n\n\nw" | fdisk "$VIDEO_DEVICE" || true
            fi

            # Wait for partition to appear
            local WAIT=0
            while [[ ! -b "$PART_DEV" ]] && [[ $WAIT -lt 10 ]]; do
                sleep 1
                WAIT=$((WAIT + 1))
            done
            if [[ ! -b "$PART_DEV" ]]; then
                log_error "Partition $PART_DEV did not appear after partitioning"
                exit 1
            fi
        fi
    fi

    # Check existing label. Three cases:
    #   1. Already labeled vs1 — skip format (existing behavior; partition is
    #      already a VideoStore in the expected state).
    #   2. Labeled vs[N] for N != 1 — refuse. This disk holds another
    #      VideoStore's recordings. Operator typo against the wrong /dev/sdX
    #      would destroy that data. Operator must clear the label first
    #      (`e2label DEV ""`) to acknowledge they are intentionally
    #      repurposing the disk.
    #   3. Any other label or no label — proceed with format + vs1 label.
    local EXISTING_LABEL
    EXISTING_LABEL=$(blkid -s LABEL -o value "$PART_DEV" 2>/dev/null || true)
    if [[ "$EXISTING_LABEL" == "vs1" ]]; then
        log_info "$PART_DEV already labeled vs1 — skipping format"
    elif [[ "$EXISTING_LABEL" =~ ^vs[0-9]+$ ]]; then
        log_error "$PART_DEV is already a VideoStore (label: $EXISTING_LABEL)"
        log_error "Re-formatting would destroy any recordings on that disk."
        log_error "If this is intentional, clear the label first:"
        log_error "    e2label $PART_DEV \"\""
        log_error "then re-run this installer."
        exit 1
    else
        log_info "Formatting $PART_DEV with ext4 (label: vs1)..."
        mkfs.ext4 -L vs1 "$PART_DEV" -E lazy_itable_init 1>/dev/null 2>/dev/null
        tune2fs -c 0 -i 0 "$PART_DEV" 1>/dev/null 2>/dev/null
        log_info "Formatted and tuned $PART_DEV"
    fi

    # Ensure /videostore exists on the host — Docker bind mounts require the
    # source path to exist.  The backend container handles the actual drive
    # mount via nsenter on startup.
    mkdir -p /videostore

    log_info "Video device ready: $PART_DEV (label: vs1)"
    log_info "The backend container will auto-detect and mount it on startup"
}

seed_nvr_id() {
    local dvs_conf="$DATA_DIR/config/dvs.conf"
    local nvr_id="${NVR_ID:-0}"

    if [[ -f "$dvs_conf" ]]; then
        # Update existing dvs.conf with the requested ID
        if [[ "$nvr_id" != "0" ]]; then
            sed -i "s/^ID=.*/ID=$nvr_id/" "$dvs_conf"
            log_info "Updated NVR ID to $nvr_id in existing dvs.conf"
        fi
    else
        # Create dvs.conf with all required defaults
        cat > "$dvs_conf" <<EOF
ID=$nvr_id
INSTALLTYPE=default
CONF=/usr/local/etc
CAMSERV=/rda/work/camserv
DEVICE=VideoStore
NUMCAMS=4
VERSION=-1
KEY=0
POSLOCK=0
LPRLOCK=0
EOF
        log_info "Created dvs.conf with ID=$nvr_id"
    fi

    # Set hostname on the host to match the NVR ID
    if [[ "$nvr_id" != "0" ]]; then
        local new_hostname="cs${nvr_id}.dividia.net"
        if command -v hostnamectl &>/dev/null; then
            hostnamectl set-hostname "$new_hostname" 2>/dev/null || true
        else
            hostname "$new_hostname" 2>/dev/null || true
            echo "HOSTNAME=$new_hostname" >> /etc/sysconfig/network 2>/dev/null || true
        fi
        log_info "Set hostname to $new_hostname"
    fi
}

################################################################################
# RPM Migration
################################################################################

detect_rpm_install() {
    local count=0
    if has_systemd; then
        for svc in rda-backend.service mpengine.service recorder.service; do
            [[ -f "/etc/systemd/system/$svc" ]] || [[ -f "/usr/lib/systemd/system/$svc" ]] && count=$((count + 1))
        done
    else
        for svc in rda-backend mpengine recorder; do
            [[ -f "/etc/init.d/$svc" ]] && count=$((count + 1))
        done
    fi
    [[ $count -ge 2 ]]
}

# RPM NVR installs binaries to /usr/local/bin which may not be in sudo's
# secure_path. Ensure it's available for mpengine, rda-db, etc.
ensure_rpm_path() {
    case ":$PATH:" in
        *:/usr/local/bin:*) ;;
        *) export PATH="/usr/local/bin:$PATH" ;;
    esac
}

capture_rpm_seed() {
    log_step "Capturing RPM hardware seed"

    if command -v mpengine &>/dev/null; then
        RPM_SEED=$(mpengine -S 2>/dev/null | tr -d '[:space:]')
        if [[ -n "$RPM_SEED" ]]; then
            log_info "Captured hardware seed: $RPM_SEED"
            return 0
        fi
    fi

    log_warn "Could not capture hardware seed (mpengine not available)"
    log_warn "Product key will need to be re-entered after migration"
    return 0
}

rpm_backup() {
    log_step "Backing up RPM NVR"

    # Verify MariaDB is running (required for mysqldump)
    if ! svc_active mariadb 2>/dev/null && ! svc_active mysqld 2>/dev/null; then
        log_error "MariaDB is not running — cannot create backup"
        exit 1
    fi

    # rda-db --backup is the RPM-installed backup tool
    if ! rda-db --backup; then
        log_error "Backup failed — aborting migration"
        exit 1
    fi

    log_info "RPM backup completed successfully"
}

stop_rpm_services() {
    log_step "Stopping all RPM NVR services"

    # 1. Kill watchprog first — it monitors and auto-restarts all NVR services.
    #    On CentOS 6, watchprog is started by the Upstart dvs-up job.
    svc_stop watchprog 2>/dev/null || true
    svc_disable watchprog 2>/dev/null || true
    pkill -9 watchprog 2>/dev/null || true

    # Disable Upstart dvs-up job (CentOS 6) — starts watchprog on runlevel 4
    if [ -f /etc/init/dvs-up.conf ]; then
        initctl stop dvs-up 2>/dev/null || true
        mv /etc/init/dvs-up.conf /etc/init/dvs-up.conf.disabled 2>/dev/null || true
        log_info "Disabled Upstart dvs-up job"
    fi

    # Remove RPM NVR startup block from rc.local (CentOS 6 hardcodes
    # "service mysqld start" and "service rda-backend start" here)
    if grep -q '### dvs start ###' /etc/rc.local 2>/dev/null; then
        cp /etc/rc.local /etc/rc.local.pre-migration
        sed -i '/### dvs start ###/,/### dvs end ###/d' /etc/rc.local
        log_info "Removed DVS block from rc.local (saved backup as rc.local.pre-migration)"
    fi

    # 2. Stop all NVR services
    local services=(rda-backend recorder mpengine pbserver ptzd rdafw logmuxd optician dview ipsetup httpd)
    for svc in "${services[@]}"; do
        svc_stop "$svc" 2>/dev/null || true
        svc_disable "$svc" 2>/dev/null || true
    done

    # 3. Stop MariaDB (backup already completed) — CO6 uses "mysqld", CO7+ uses "mariadb"
    #    Kill mysqld_safe first (CentOS 6) — it auto-restarts mysqld.
    pkill -9 mysqld_safe 2>/dev/null || true
    for db_svc in mariadb mysqld; do
        svc_stop "$db_svc" 2>/dev/null || true
        svc_disable "$db_svc" 2>/dev/null || true
        if has_systemd; then
            systemctl mask "$db_svc" 2>/dev/null || true
        fi
    done
    # Final kill to catch anything mysqld_safe may have respawned
    pkill -9 mysqld 2>/dev/null || true

    # 4. Wait until port 3306 is free
    local wait=0
    while [[ $wait -lt 10 ]]; do
        if ! (ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null) | grep -q ':3306 '; then
            break
        fi
        sleep 1
        wait=$((wait + 1))
    done
    if [[ $wait -ge 10 ]]; then
        log_warn "Port 3306 still in use after 10s — proceeding anyway"
    fi

    # 5. Remove aiengine and docker-engine RPMs early — their %preun scripts
    #    try to stop containers and remove Docker images, which would interfere
    #    with the new Docker installation. Safe to remove now since services are stopped.
    rpm -e --nodeps aiengine docker-engine 2>/dev/null || true

    log_info "All RPM services stopped and disabled"
}

rollback_rpm_services() {
    log_error "Migration failed — rolling back to RPM services"

    # Stop Docker containers if any started AND drop the persistent dtech
    # state. `docker compose down -v` removes NAMED volumes (seed,
    # dstream-cache) and anonymous volumes — but NOT bind mounts.
    # The db container's dtech state lives at "$INSTALL_DIR/data/db_data"
    # as a bind mount (compose YAML: "./data/db_data:/var/lib/mysql").
    # We must explicitly rm -rf that directory to drop the
    # docker-start Phase 1 VideoStore INSERTs + any partial
    # rda-db --restore data. Without this, those orphan rows would
    # poison the next migration (Phase 1 skip-if-exists logic would
    # treat them as authoritative).
    #
    # Pre-fix the rollback trap was effectively dead code because
    # rda-db --restore silently returned 0; the loud-failure fix
    # 2026-05-28 made the trap fire for real, surfacing this gap.
    cd "$INSTALL_DIR" 2>/dev/null && docker compose down -v 2>/dev/null || true
    if [[ -n "${INSTALL_DIR:-}" && -d "$INSTALL_DIR/data/db_data" ]]; then
        rm -rf "$INSTALL_DIR/data/db_data" 2>/dev/null || true
        log_info "Dropped $INSTALL_DIR/data/db_data — next install will reseed dtech from scratch"
    fi

    # Unmount any /videostore/vs[0-9]+ mounts that docker-start's Phase 2
    # nsenter-mounted into PID 1's namespace. The Docker container that
    # ran nsenter is now stopped, but the mounts persist in the host
    # mount table and would clash with rda-autofs when it comes back up.
    # Lazy unmount handles cases where a process inside the (now-stopped)
    # container is still holding a reference.
    if mount | grep -qE 'on /videostore/vs[0-9]+ '; then
        while read -r vs_mount; do
            log_info "Unmounting docker-start nsenter mount: $vs_mount"
            umount -l "$vs_mount" 2>/dev/null || true
        done < <(mount | grep -oE 'on /videostore/vs[0-9]+ ' | awk '{print $2}')
    fi

    # Re-enable Upstart dvs-up job (CentOS 6)
    if [ -f /etc/init/dvs-up.conf.disabled ]; then
        mv /etc/init/dvs-up.conf.disabled /etc/init/dvs-up.conf 2>/dev/null || true
    fi

    # Restore rc.local if we backed it up
    if [ -f /etc/rc.local.pre-migration ]; then
        mv /etc/rc.local.pre-migration /etc/rc.local
    fi

    # Unmask and re-enable MariaDB
    for db_svc in mariadb mysqld; do
        if has_systemd; then
            systemctl unmask "$db_svc" 2>/dev/null || true
        fi
        svc_enable "$db_svc" 2>/dev/null || true
        svc_start "$db_svc" 2>/dev/null || true
    done

    # Restart rda-autofs BEFORE the NVR services. docker-start Phase 1.5
    # may have lazy-unmounted any leftover autofs at /videostore (the
    # "autofs hangover" cs50 2026-05-27 workaround); the rollback must
    # rebuild that automount before mpengine starts. mpengine writes
    # MP4 files to /videostore/vs1 on its first recording event — if
    # autofs isn't claiming /videostore yet, those writes go to a bare
    # /videostore/vs1 directory on the root partition (cs256 2026-04-24
    # root-fill failure shape). Idempotent: no-op on systems that
    # never had rda-autofs.
    svc_enable rda-autofs 2>/dev/null || true
    svc_start rda-autofs 2>/dev/null || true

    # Re-enable and start NVR services
    local services=(rda-backend recorder mpengine pbserver ptzd rdafw logmuxd optician dview ipsetup httpd)
    for svc in "${services[@]}"; do
        svc_enable "$svc" 2>/dev/null || true
        svc_start "$svc" 2>/dev/null || true
    done

    # Re-enable watchprog
    svc_enable watchprog 2>/dev/null || true
    svc_start watchprog 2>/dev/null || true

    log_info "RPM services restored — system should be back to pre-migration state"
    [[ -n "$LOG_FILE" ]] && log_error "Migration log saved to: $LOG_FILE"
}

write_rpm_seed_to_docker() {
    if [[ -z "$RPM_SEED" ]]; then
        log_info "No RPM seed captured — skipping seed write"
        return 0
    fi

    log_step "Writing RPM hardware seed to Docker"

    cd "$INSTALL_DIR"
    if ! docker compose exec -T backend nvr-check-key --write-seed /seed/device-id "$RPM_SEED"; then
        log_warn "Failed to write seed to Docker — product key will need re-entry"
        return 0
    fi

    # Verify the seed was written correctly
    local docker_seed
    docker_seed=$(docker compose exec -T backend nvr-check-key -S 2>/dev/null | tr -d '[:space:]')
    if [[ "$docker_seed" == "$RPM_SEED" ]]; then
        log_info "Seed verified: $docker_seed"
    else
        log_warn "Seed mismatch (wrote=$RPM_SEED, read=$docker_seed) — product key may need re-entry"
    fi
    return 0
}

ensure_backup_accessible() {
    log_step "Making RPM backup accessible to Docker"

    cd "$INSTALL_DIR"

    # Find the NEWEST backup on host (the freshly-created one from
    # rpm_backup, which writes a timestamp dir to either /rda/backups/
    # or /videostore/vs1/backups/ depending on legacy dvs.conf
    # CAMSERV setting). DO NOT short-circuit on "any backup is already
    # visible to container" — a stale /videostore/vs1/backups/<old-ts>
    # (years-old snapshot, previous failed migration, hand-copied dir)
    # would otherwise satisfy that gate and rda-db --restore would
    # silently use the stale snapshot instead of the fresh one.
    #
    # Walk both canonical layouts, sort by mtime descending, take the
    # most-recently-modified backup that has the expected /conf/
    # marker dir (the rda-db --backup output contract).
    local backup_dir=""
    backup_dir=$( {
        for search_path in /rda/backups /videostore/*/backups; do
            for dir in $search_path/[0-9]*; do
                [[ -d "$dir" ]] || continue
                [[ "$dir" == *.restored ]] && continue
                [[ -d "$dir/conf" ]] || continue
                stat -c '%Y %n' "$dir" 2>/dev/null || stat -f '%m %N' "$dir" 2>/dev/null
            done
        done | sort -rn | head -1 | awk '{print $2}'
    } )

    if [[ -z "$backup_dir" ]]; then
        log_warn "No backup found on host — restore will create a fresh configuration"
        return 0
    fi

    local container
    container=$(docker compose ps -q backend 2>/dev/null)
    if [[ -z "$container" ]]; then
        log_warn "Backend container not running — cannot copy backup"
        return 0
    fi

    # Stage backup where rda-db --restore looks for it.
    #
    # rda-db --restore inside the backend container defaults to
    # /videostore/vs1/backups. Pre-2022 NVRs (cs1681, cs2 era) have
    # dvs.conf with CAMSERV=/rda/work/camserv and rda-db --backup wrote
    # to /rda/backups; newer NVRs (cs1129, cs50, cs999, cs1018, cs2427)
    # use /videostore as the convention and write to /videostore/vs1/
    # backups. The container has /videostore bind-mounted from host but
    # NOT /rda, so a docker cp into /rda/backups inside the container
    # is invisible to /videostore-scoped tools.
    #
    # Bind-mount-first: if the backup is already under /videostore on
    # host, the container sees it via the existing bind mount — no copy
    # needed. Otherwise copy to host /videostore/vs1/backups/ so the
    # bind mount surfaces it inside the container. This also unifies
    # the canonical layout for both /rda and /videostore legacy paths.
    #
    # Silent restore failure was the worst-case outcome here: rda-db's
    # `Backup::doRestore` logs "could not find a backup archive to
    # restore from" but install-nvr.sh's wrapper logged "Restore
    # completed successfully" because the underlying tool exit-coded 0
    # on the empty-search-result path. Hit on cs1681 BCC Controller
    # 2026-05-26: 135-server enterprise sync registry restored as
    # empty, full manual recovery required.
    if [[ "$backup_dir" == /videostore/* ]]; then
        log_info "Backup at $backup_dir is already visible via /videostore bind mount"
    else
        log_info "Staging host backup $backup_dir → /videostore/vs1/backups/ (visible to container via bind mount)"
        mkdir -p /videostore/vs1/backups
        # No-clobber: if a previous failed install-nvr.sh run left the same
        # timestamp dir under /videostore/vs1/backups, don't silently merge
        # file-by-file (cp -a default) — that produces a hybrid snapshot
        # that rda-db --restore would happily consume. Bail loud instead so
        # the operator can investigate. -e + -L catches both real files and
        # dangling symlinks (an operator may have manually-symlinked the dir
        # during recovery).
        local dest="/videostore/vs1/backups/$(basename "$backup_dir")"
        if [[ -e "$dest" || -L "$dest" ]]; then
            log_warn "Destination $dest already exists — not overwriting"
            log_warn "  resolves to: $(readlink -f "$dest" 2>/dev/null || echo "(unknown)")"
            log_warn "  rda-db --restore will use whatever's already there. Investigate before re-running install-nvr.sh."
        else
            cp -a "$backup_dir" /videostore/vs1/backups/
        fi
    fi

    # Defensive belt: ALWAYS copy backup into container's /rda/backups in
    # addition to the bind-mounted /videostore path. The Backup class
    # heuristic in rda-db/src/backup/{backup,dvsbase}.py now picks
    # /videostore on Docker explicitly, but the belt guards against any
    # downstream tool that still hard-codes the legacy /rda layout.
    # Container overlay-FS only — wiped on container restart — but rda-db
    # --restore runs in the same container instance as docker-start, so
    # the belt is live during the migration restore call.
    #
    # Pre-fix-2026-05-28 this belt was only applied when source was on
    # /videostore (newer NVRs); pre-2022 NVRs with /rda/backups source
    # took an early return and skipped it, leaving silent-fail-on-/rda as
    # the worst case. Hit on cs1681 BCC Controller 2026-05-26 and cs999
    # ticking-bomb 2026-05-28.
    #
    # No-clobber: mirror the /videostore staging guard above. If a previous
    # failed install-nvr.sh run left the same timestamp dir at the destination
    # (operator retry with the same container instance still up), refuse to
    # silently merge into it. `docker cp` of a source DIR into an existing
    # destination DIR merges file-by-file, producing the same hybrid-snapshot
    # failure mode the /videostore branch explicitly bails on.
    local belt_dest_name
    belt_dest_name=$(basename "$backup_dir")
    docker exec "$container" mkdir -p /rda/backups
    if docker exec "$container" test -e "/rda/backups/$belt_dest_name"; then
        log_warn "Container's /rda/backups/$belt_dest_name already exists — skipping defensive belt"
        log_warn "  rda-db --restore will read from /videostore/vs1/backups via the Backup heuristic. Investigate before re-running install-nvr.sh if you wanted a clean belt."
    else
        docker cp "$backup_dir" "$container:/rda/backups/"
        log_info "Copied backup $belt_dest_name into container at /rda/backups/ (defensive belt)"
    fi
}

remove_rpm_packages() {
    log_step "Removing NVR RPM packages"

    # All NVR packages plus their database and Docker engine dependencies.
    # Include both mysql-server (CentOS 6) and mariadb-server (CentOS 7+).
    # Remove one-at-a-time: rpm -e with a list treats it as one transaction
    # and a single %preun failure aborts the entire removal.
    local packages=(rda-release rda-backend recorder mpengine rda-db rda-scripts rdafw
                    logmuxd pbserver ptzd optician dview dview-lib dvsconf ipsetup
                    ffmpeg darknet rda-autofs rda-desktop pelco aiengine rda-snmpd
                    mariadb-server mysql-server docker-engine)

    for pkg in "${packages[@]}"; do
        rpm -e --nodeps "$pkg" 2>/dev/null || true
    done

    log_info "RPM packages removed"
}

cleanup_legacy_artifacts() {
    # Post-migration cleanup of RPM-era artifacts that the migration leaves
    # behind on /. remove_rpm_packages uses `rpm -e --nodeps`, which does NOT
    # cascade to deps — orphan packages like firefox, httpd, php (pulled in
    # by rda-release sub-package Requires:) stay on disk. This function
    # reclaims them, plus yum metadata cache, RPM-era log files, and the
    # /var/lib/mysql data dir (gated on the DB sanity check from verify_migration).
    #
    # Verified empirically on cs999/cs1018/cs50/cs1129 2026-05-15:
    # 400-870 MB recoverable per host depending on what was installed.
    log_step "Cleaning up legacy artifacts"

    if $KEEP_LEGACY; then
        log_info "--keep-legacy set, skipping cleanup (firefox, httpd, yum cache, old logs all preserved)"
        return 0
    fi

    local before_kb after_kb
    before_kb=$(df -k / | tail -1 | awk '{print $3}')

    # 1. Orphan RPM packages. firefox is conditional on RPM presence (CO6 NVRs
    #    had it from RPM era; CO7 NVRs that ran the pre-RHEL-skip install-nvr
    #    also have it; new CO7 installs do not).
    log_info "Removing orphan RPM packages"
    local orphan_pkgs=(firefox
                       httpd-manual httpd-tools httpd
                       liberation-fonts dejavu-sans-fonts google-noto-sans-fonts
                       php php-cli php-common php-mysqlnd
                       traffic-shaper)
    for pkg in "${orphan_pkgs[@]}"; do
        if rpm -q "$pkg" &>/dev/null; then
            rpm -e --nodeps "$pkg" 2>/dev/null && log_info "  removed: $pkg"
        fi
    done

    # Firefox flag file: stale "yes, host firefox is available" claim. The
    # Open Browser menu in backend gates on this file; with firefox gone it
    # must be absent so the menu item correctly no-ops.
    rm -f "$DATA_DIR/config/nvr-host-firefox-available"

    # /root/.mozilla profile: RPM-era usage state, never touched by Docker stack.
    [[ -d /root/.mozilla ]] && rm -rf /root/.mozilla

    # 2. yum / dnf metadata cache.
    log_info "Cleaning package manager cache"
    if command -v yum &>/dev/null; then
        yum clean all >/dev/null 2>&1 || true
        # CO6 yum doesn't fully clean (leaves ~50 MB of repodata). Nuke residual.
        rm -rf /var/cache/yum/*/*/repodata /var/cache/yum/*/*/gen 2>/dev/null
    fi
    if command -v dnf &>/dev/null; then
        dnf clean all >/dev/null 2>&1 || true
    fi

    # 3. RPM-era log files. Docker stack logs to docker journal — these
    #    paths are not written to anymore. Wildcard catches both rotated
    #    (.log-YYYYMMDD) and current (.log) files. lsof check above confirms
    #    nothing has them open post-migration.
    log_info "Removing RPM-era log files"
    rm -f /var/log/rda-backend.log* \
          /var/log/recorder.log* \
          /var/log/pbserver.log* \
          /var/log/mpengine.log* \
          /var/log/logmuxd.log* \
          /var/log/optician.log* \
          /var/log/ptzd.log* \
          /var/log/dview.log* \
          /var/log/aiengine.log* 2>/dev/null

    # 4. /var/lib/mysql: orphan after rpm_backup → mysqldump → rda-db --restore
    #    moved everything to /opt/dividia/data/db inside the db container.
    #    The mariadb-server / mysql-server RPM was already removed by
    #    remove_rpm_packages, so the data dir is unowned and has no daemon.
    #
    #    Gated on DB_SANITY_PASSED from verify_migration, AND we re-verify
    #    the backup file is still readable + still has its mysqldump header
    #    immediately before the rm. The verify ran minutes ago and several
    #    rpm -e calls have run since (some have %preun scripts that could
    #    touch /videostore), so the gate-time check is not enough by itself.
    if [[ -d /var/lib/mysql ]]; then
        if [[ "${DB_SANITY_PASSED:-0}" == "1" ]] \
           && [[ -n "${BACKUP_SQL_PATH:-}" ]] \
           && [[ -f "$BACKUP_SQL_PATH" ]] \
           && head -c 200 "$BACKUP_SQL_PATH" 2>/dev/null | grep -qE '^-- (MySQL|MariaDB) dump'; then
            local mysql_kb
            mysql_kb=$(du -sk /var/lib/mysql 2>/dev/null | awk '{print $1}')
            rm -rf /var/lib/mysql
            log_info "Removed /var/lib/mysql (~${mysql_kb} KB; rollback dtech.sql verified at $BACKUP_SQL_PATH)"
        elif [[ "${DB_SANITY_PASSED:-0}" == "1" ]]; then
            # Gate passed at verify time but backup file is now gone/corrupt.
            log_warn "DB sanity gate passed but backup file ${BACKUP_SQL_PATH:-?} is missing/corrupt at cleanup time"
            log_warn "  keeping /var/lib/mysql; investigate before running cleanup again"
        else
            log_warn "DB sanity gate not set — keeping /var/lib/mysql for safety"
        fi
    fi

    # 5. Old kernels (yum-utils' package-cleanup). Keeps the running kernel
    #    plus N-1; clears anything older. No-op when only one kernel is
    #    installed.
    if command -v package-cleanup &>/dev/null; then
        local kernel_count
        kernel_count=$(rpm -q kernel 2>/dev/null | grep -c '^kernel-' || true)
        if [[ "$kernel_count" -gt 1 ]]; then
            package-cleanup --oldkernels --count=1 -y >/dev/null 2>&1 \
                && log_info "Pruned old kernels (kept running + 1 previous)"
        fi
    fi

    # 6. Stale autofs binding at /videostore from the now-removed rda-autofs RPM.
    #
    # rda-autofs (removed by remove_rpm_packages above) owned
    # /etc/auto.videostore and added a `/videostore /etc/auto.videostore
    # --timeout=300` line to /etc/auto.master. After the RPM is gone,
    # /etc/auto.videostore is deleted but the /etc/auto.master entry
    # survives. autofs then returns EACCES whenever anything (container
    # restart, watchtower update, manual docker compose recreate)
    # accesses /videostore — backend's recreate triggers /videostore/vs1
    # access during startup, the bind mount fails, backend exits 143,
    # dependent containers (engine, connector, playback, viewer) crash.
    #
    # Surfaced on cs1681 BCC Controller 2026-05-26 hours after the
    # migration completed cleanly — the dead-binding fault stayed
    # dormant until a backend recreate forced a /videostore access.
    # By the time the symptom hits, install-nvr.sh has long exited;
    # the operator just sees an inexplicable stack-down event.
    #
    # Remove the orphan entry here. autofs is reloaded so it forgets
    # the dead mountpoint (other entries like /var/autofs/removable
    # may still be in use — don't disable autofs entirely).
    if [[ -f /etc/auto.master ]] && grep -qE '^/videostore[[:space:]]' /etc/auto.master; then
        log_info "Removing orphan /videostore line from /etc/auto.master (rda-autofs RPM gone)"
        # Preserve the pre-cleanup snapshot only on first run — re-running
        # install-nvr.sh later must not clobber the original backup with a
        # post-cleanup copy.
        [[ -f /etc/auto.master.pre-migration-cleanup ]] \
            || cp /etc/auto.master /etc/auto.master.pre-migration-cleanup
        sed -i '\|^/videostore[[:space:]]|d' /etc/auto.master
        # Reload autofs so the orphan /videostore binding is purged.
        # `service autofs reload` is the standard cross-distro reload —
        # on CO6 SysV (autofs 5.0.5), the init script SIGHUPs the master
        # PID specifically; on CO7+ systemd, systemctl reload autofs
        # equivalent. Both re-read /etc/auto.master without unmounting
        # sub-mounts that came from external mechanisms (e.g., docker-
        # start Phase 2's nsenter-mounted /videostore/vs1 on recording
        # NVRs). Falling back to a stop+start cycle would briefly
        # collapse those sub-mounts and crash running containers.
        if svc_active autofs 2>/dev/null; then
            service autofs reload 2>/dev/null \
                || systemctl reload autofs 2>/dev/null \
                || pkill -HUP automount 2>/dev/null \
                || log_warn "  autofs reload signal could not be delivered; orphan /videostore binding persists until next reboot"

            # Verify the orphan binding actually went away. Some autofs
            # versions (notably CO6 5.0.5 per-mountpoint daemon model)
            # don't drop runtime mountpoints on SIGHUP — the file change
            # persists but the live binding survives until the next
            # reboot. If we still see autofs at /videostore, warn loudly
            # so the operator schedules a planned reboot before the next
            # docker compose recreate.
            sleep 1
            if findmnt -t autofs /videostore >/dev/null 2>&1; then
                log_warn "  autofs still bound at /videostore after reload — runtime state not cleared"
                log_warn "  the orphan binding will crash the stack on next backend recreate; schedule a reboot"
            else
                log_info "  autofs /videostore binding cleared"
            fi
        fi
    fi

    after_kb=$(df -k / | tail -1 | awk '{print $3}')
    local reclaimed_kb=$(( before_kb - after_kb ))
    log_info "Legacy cleanup complete: reclaimed ~$((reclaimed_kb / 1024)) MB on /"
}

verify_migration() {
    log_step "Verifying migration"

    cd "$INSTALL_DIR"

    # Block on backend health before any downstream steps — we're about to
    # return control to migrate_flow which clears the rollback trap and
    # runs remove_rpm_packages (the point of no return). A backend that
    # isn't actually healthy here means RPMs get removed on top of a broken
    # Docker stack, leaving the NVR with no running recorder.
    wait_for_backend_healthy

    local health
    health=$(docker compose ps backend --format '{{.Health}}' 2>/dev/null || true)
    if [[ "$health" == "healthy" ]]; then
        log_info "Backend container: healthy"
    else
        log_error "Backend container is not healthy after wait ($health) — refusing to remove RPMs"
        log_error "Migration is NOT safe to finalize. Inspect the backend logs and retry."
        # return 1 (not exit 1): verify_migration is called inside the
        # rollback-trap window in migrate_flow (line 2686 → 2742). exit 1
        # bypasses the trap; return 1 propagates via set -e and fires
        # rollback_rpm_services so the host returns to RPM operation.
        return 1
    fi

    # Validate product key if seed was preserved
    if [[ -n "$RPM_SEED" ]]; then
        local key
        key=$(docker compose exec -T backend bash -c 'grep "^KEY=" /etc/dvs.conf | cut -d= -f2' 2>/dev/null || true)
        if [[ -n "$key" ]] && [[ "$key" != "0" ]]; then
            local key_result
            key_result=$(docker compose exec -T backend nvr-check-key -V "$key" 2>/dev/null || true)
            if [[ "$key_result" == "pass" ]]; then
                log_info "Product key: valid"
            else
                log_warn "Product key validation failed — may need re-entry"
            fi
        fi
    fi

    # Report camera count
    local cam_count
    cam_count=$(docker compose exec -T backend python -c "
import dbutil
db = dbutil.DataAccess()
rows = db.execute('SELECT COUNT(*) FROM camera')
print(rows[0][0])
" 2>/dev/null || true)
    if [[ -n "$cam_count" ]] && [[ "$cam_count" != "0" ]]; then
        log_info "Cameras in database: $cam_count"
    fi

    # DB sanity gate for cleanup_legacy_artifacts: only remove the orphan
    # /var/lib/mysql if ALL of these hold:
    #   - the newest dtech.sql backup file lives at a canonical
    #     rda-db --backup output path (NOT just anywhere under /videostore)
    #   - the file is large enough to be a real dump (> DB_BACKUP_MIN_BYTES)
    #   - the file begins with a mysqldump/MariaDB header (NOT a customer
    #     export or stale junk that happens to be named dtech.sql)
    #   - the live container has ≥1 Camera row, confirming restore succeeded
    #
    # rda-db --backup writes to one of these canonical layouts:
    #   /videostore/vs1/backups/<ts>.restored/db/dtech.sql  (local-rdavol)
    #   /rda/backups/<ts>/db/dtech.sql                       (CloudNVR EBS)
    #
    # BACKUP_SQL_PATH is exported so cleanup_legacy_artifacts can
    # re-verify the file is still present and headered at delete-time
    # rather than trusting this gate from minutes ago.
    DB_SANITY_PASSED=0
    BACKUP_SQL_PATH=""
    local DB_BACKUP_MIN_BYTES=$((100 * 1024))   # 100 KB; real dump is multi-MB
    local backup_sql backup_size=0

    # Newest first across both canonical layouts. We constrain by path
    # (matching `*/db/dtech.sql` under each canonical root) and sort by
    # mtime descending so multiple snapshots don't yield a stale 2024 file.
    backup_sql=$( {
        find /videostore/vs1/backups -maxdepth 4 -path '*/db/dtech.sql' -type f 2>/dev/null
        find /rda/backups           -maxdepth 3 -path '*/db/dtech.sql' -type f 2>/dev/null
    } | xargs -I{} -r stat -c '%Y {}' {} 2>/dev/null | sort -rn | head -1 | awk '{print $2}')

    if [[ -n "$backup_sql" ]]; then
        backup_size=$(stat -c '%s' "$backup_sql" 2>/dev/null || stat -f '%z' "$backup_sql" 2>/dev/null || echo 0)
    fi

    # Header check: mysqldump (and mariadb-dump) write a recognizable banner
    # in the first ~120 bytes ("-- MySQL dump" or "-- MariaDB dump"). A
    # customer recording, junk file, or partial-restore stub will not have
    # this. Cheap to check; catches the "stale dtech.sql somewhere in
    # /videostore satisfies the gate" failure mode.
    local backup_is_mysqldump=0
    if [[ -n "$backup_sql" ]] && head -c 200 "$backup_sql" 2>/dev/null | grep -qE '^-- (MySQL|MariaDB) dump'; then
        backup_is_mysqldump=1
    fi

    if [[ -n "$backup_sql" ]] \
       && [[ "$backup_size" -gt "$DB_BACKUP_MIN_BYTES" ]] \
       && [[ "$backup_is_mysqldump" == "1" ]] \
       && [[ -n "$cam_count" ]] \
       && [[ "$cam_count" != "0" ]]; then
        DB_SANITY_PASSED=1
        BACKUP_SQL_PATH="$backup_sql"
        log_info "DB sanity check: PASS"
        log_info "  backup:   $backup_sql ($backup_size bytes, mysqldump header verified)"
        log_info "  cameras:  $cam_count restored"
    else
        log_warn "DB sanity check: SKIP cleanup"
        log_warn "  backup:        ${backup_sql:-NONE FOUND}"
        log_warn "  size:          ${backup_size}B (min ${DB_BACKUP_MIN_BYTES})"
        log_warn "  mysqldump hdr: $([[ "$backup_is_mysqldump" == "1" ]] && echo yes || echo no)"
        log_warn "  cameras:       ${cam_count:-?}"
    fi
}

################################################################################
# Shared Restore Helper
################################################################################

restore_from_backup() {
    cd "$INSTALL_DIR"
    wait_for_backend_healthy

    # Ensure all services are up (some may have failed during initial start
    # if backend took too long to become healthy)
    docker compose up -d --quiet-pull 2>/dev/null || true

    log_step "Restoring NVR from backup"
    # Gate the restore with `if !` so set -eE doesn't silently abort on a
    # non-zero exit before the diagnostic block prints. The bare-command
    # pattern this replaced was dead code under set -e (and now under
    # set -eE the rollback trap would fire but the operator never sees
    # the "rda-db --backup file is on /videostore" guidance). Mirror the
    # --update-only branch a few lines below that already uses if-then.
    #
    # `return 1` (NOT `exit 1`): bash's ERR trap fires on commands that
    # return non-zero when `set -e` is active, but does NOT fire on a
    # direct `exit` — `exit` terminates the shell, bypassing trap.
    # Using `return 1` lets `set -e` (line 23) propagate the failure
    # up to migrate_flow's `restore_from_backup` call, which fires the
    # ERR trap set at line 2674 and runs `rollback_rpm_services`.
    # set -E (line 23) inherits that trap into this function so it fires
    # at restore_from_backup's scope if a nested call fails too.
    #
    # upgrade_flow has no rollback trap; the return 1 just exits the
    # script via set -e with the failed restore exit code. Operator
    # can re-run `docker compose exec backend rda-db --restore` once
    # the underlying issue is fixed.
    # Capture exit code via `|| rc=$?` rather than `if ! cmd; then rc=$?`.
    # Bash gotcha: inside `if ! cmd; then ...`, `$?` reflects the inverted
    # test result (0 because `! failing-cmd` succeeds), NOT cmd's actual
    # exit code. PIPESTATUS[0] doesn't help either — the pipeline IS
    # `! cmd`, status 0. Verified empirically with /tmp/test-if-bang-rc.sh.
    # `|| rc=$?` correctly captures the underlying command's code and also
    # prevents set -eE from firing the trap before we print diagnostics.
    local RESTORE_RESULT=0
    docker compose exec backend rda-db --restore || RESTORE_RESULT=$?
    if [[ $RESTORE_RESULT -ne 0 ]]; then
        # FATAL: continuing past restore failure was the worst-case outcome
        # for migrate_flow — verify_migration only checks backend health
        # (which can be green against an EMPTY DB), then the script clears
        # the rollback trap and removes the RPM stack. Net result: working
        # RPM NVR → empty Docker NVR + RPMs gone, no way back without a
        # manual rebuild from /rda/backups/<ts>/db/dtech.sql.
        log_error "Restore failed (exit code: $RESTORE_RESULT) — aborting before RPM removal"
        log_error "Operator: rda-db --backup file is on /videostore or /rda/backups."
        log_error "  Migrate path: rollback_rpm_services ERR trap will restore RPM services."
        log_error "  Upgrade path: rerun 'docker compose exec backend rda-db --restore' after investigation."
        return 1
    fi
    log_info "Restore completed successfully"

    # Version gate AFTER restore: dvs.conf has been materialized from the
    # backup. Refuse to run --update-only against a pre-6.0 restore (the
    # image no longer ships the pre-6.0 migration scripts, so skipping
    # directly to post-6.0 scripts would corrupt the schema).
    check_version_6_0_or_fail "$DATA_DIR/config/dvs.conf"

    log_step "Running database migrations"
    # Fail-closed: a failed migration after restore leaves the DB in an
    # inconsistent state. Abort instead of restarting services against a
    # half-migrated schema. rda-db --update-only propagates script failures
    # correctly (see rda-db/src/setup/update.py).
    if ! docker compose exec backend rda-db --update-only; then
        log_error "Database migration failed after restore."
        log_error "The backend is running but the DB is partially migrated."
        log_error "Investigate the update script failure in the logs, fix the"
        log_error "root cause, then re-run: docker compose exec backend rda-db --update-only"
        # return 1 (not exit 1): restore_from_backup is called inside the
        # migrate_flow rollback-trap window (line 2686 → 2742). exit 1
        # bypasses the trap; return 1 propagates via set -e and fires
        # rollback_rpm_services so a half-migrated DB doesn't strand the
        # host with RPM services stopped and Docker running broken state.
        # Same rationale as the --restore branch above.
        return 1
    fi

    # Re-sync host hostname + yum repo csid to the restored NVR ID.
    # During pull_and_start, backend's docker-start ran rda-db --update-only
    # against an empty DB, which hit the fresh-install path and called
    # setSerial(0) — stamping /etc/hostname as cs0.dividia.net on the host
    # via nsenter. The restore above then populated the DB with the real ID,
    # but docker-start's first-boot set-serial block skips when DB_SERIAL is
    # no longer 0. Invoke set-serial explicitly here so the hostname matches
    # the restored NVR ID instead of staying stuck on cs0.
    #
    # Read the serial directly from the DB, NOT from dvs.conf. rda-db's
    # doRestore (backup.py) wraps the entire sequence in a single try/except
    # that swallows exceptions and returns normally. A partial restore
    # (config restored, DB half-restored) would leave dvs.conf with the real
    # serial but the DB in an inconsistent state. Passing the DB-resident
    # value means setSerial can't propagate a stale dvs.conf into the DB —
    # it's either a no-op (serials match → just hostname/yum side effects)
    # or skipped (DB serial is 0/missing → bail out, hostname stays whatever
    # it was; a broken restore has bigger problems than a stale hostname).
    local db_serial
    db_serial=$(docker compose exec -T db sh -c \
        'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT sValue FROM dtech.Misc WHERE sModule='"'"'general'"'"' AND sName='"'"'serial'"'"'" 2>/dev/null' \
        2>/dev/null | tr -d '\r\n ' || true)
    # Reject non-numeric serials before passing to rda-db. A carriage-return
    # or unexpected character from the mysql output makes the downstream
    # rda-db --set-serial choke with a ValueError that surfaces as a generic
    # log_warn, hiding the real cause. Requiring ^[0-9]+$ here gives us a
    # targeted error message and also plugs a theoretical SQL-injection
    # path via seed_apache_extra_from_db's bport SELECT that interpolates
    # $db_serial directly.
    if [[ -n "$db_serial" && "$db_serial" != "0" && "$db_serial" =~ ^[0-9]+$ ]]; then
        log_info "Syncing hostname + yum csid to DB-resident NVR ID ($db_serial)"
        docker compose exec backend rda-db --set-serial "$db_serial" \
            || log_warn "set-serial failed; hostname may still read cs0"
    elif [[ -n "$db_serial" && ! "$db_serial" =~ ^[0-9]+$ ]]; then
        log_warn "DB serial contains non-numeric characters [$db_serial] — rejecting to avoid corrupting downstream calls"
        log_warn "Investigate the restore; hostname will stay at its current value"
        db_serial=""
    else
        log_warn "DB serial is empty or 0 — skipping hostname sync (check restore completeness)"
    fi

    # Pre-seed apache Listen override before the final restart so viewer comes up
    # already configured for customer's PublicPort. rda-backend's startup sync
    # would do this too, but writing it here avoids a second viewer restart
    # (content-compare in rda-backend makes the startup pass a no-op).
    seed_apache_extra_from_db "$db_serial"

    # Do NOT restart backend — docker-start re-runs full init (DB seeding,
    # reallocate-devices) which overwrites the just-restored database.
    log_step "Restarting services with restored configuration"
    # Backend must restart to reload restored dvs.conf (product key, serial, etc.)
    # This is safe here because docker-start's init phase already completed during
    # pull_and_start — the restart just picks up the restored config file.
    # Tolerate a restart hiccup (image-pull race, daemon flake): the DB is
    # already migrated and we're still inside migrate_flow's trap window,
    # but rollback at this point would un-migrate a working DB. Better to
    # warn and let the operator finish with `nvr restart`.
    docker compose restart backend engine connector playback viewer \
        || log_warn "Service restart failed — DB is migrated; finish manually with: nvr restart"
}

# Write $DATA_DIR/apache-extra/dvs-extra.conf from DB Server.bPort so the viewer
# container's apache binds on the customer PublicPort (e.g. 8888) at startup.
# Called after restore when the DB holds the authoritative bPort value.
# Safe to call with empty serial (no-ops); safe when bPort == 80 (clears file).
seed_apache_extra_from_db() {
    local db_serial="$1"
    local conf_file="$DATA_DIR/apache-extra/dvs-extra.conf"
    mkdir -p "$DATA_DIR/apache-extra"

    if [[ -z "$db_serial" || "$db_serial" == "0" ]]; then
        log_warn "Apache extra seed skipped: no DB serial"
        return 0
    fi

    local bport
    bport=$(docker compose exec -T db sh -c \
        "mysql -N -B -uroot -p\"\$MYSQL_ROOT_PASSWORD\" dtech -e \"SELECT bPort FROM Server WHERE bSerial=$db_serial\" 2>/dev/null" \
        2>/dev/null | tr -d '\r\n ' || true)

    if [[ -z "$bport" ]]; then
        log_warn "Apache extra seed: no Server.bPort row for serial $db_serial"
        return 0
    fi

    if [[ "$bport" == "80" ]]; then
        rm -f "$conf_file"
        log_info "Apache extra seed: bPort=80, no override needed"
        return 0
    fi

    cat > "$conf_file" <<EOF
# Customer PublicPort — managed by rda-backend _changePublicPort
### dvs listen start ###
Listen $bport
### dvs listen end ###
EOF
    log_info "Apache extra seed: Listen $bport written to dvs-extra.conf"
}

################################################################################
# Main
################################################################################

fresh_install_flow() {
    check_root
    # Fail-closed VideoStore gate: refuses install unless --video-device,
    # --no-video-device, or a pre-labeled vs[N] partition is present
    # (kickstart path). Runs BEFORE any side-effecting work so an error
    # leaves the host unchanged. NOT called from upgrade_flow or
    # migrate_flow — both preserve the prior VideoStore via the DB.
    check_video_device_or_fail
    detect_os
    check_virtualbox_graphics
    install_docker
    configure_docker_storage
    configure_docker_logging
    docker_login
    create_directory_structure
    seed_nvr_id
    [[ -n "$VIDEO_DEVICE" ]] && prepare_video_device
    download_compose_files
    create_env_file
    disable_host_services
    ensure_videostore_writable
    install_host_firefox
    pull_and_start
    create_boot_service
    create_management_scripts
    configure_user_access
    install_prune_cron
    install_update_cron

    echo ""
    log_step "Installation Complete!"
    echo ""
    log_info "NVR installed to: $INSTALL_DIR"
    log_info "Data directory:   $DATA_DIR"
    log_info "Channel:          $CHANNEL"
    echo ""
    log_info "Management commands:"
    log_info "  nvr status          — Check status"
    log_info "  nvr logs [service]  — View logs"
    log_info "  nvr update          — Update to latest"
    log_info "  nvr channel <name>  — Switch channel"
    log_info "  nvr backup          — Backup to VideoStore"
    log_info "  nvr help            — Show all commands"
    echo ""
    log_info "Access the NVR:"
    local ip
    ip=$(hostname -I 2>/dev/null | awk '{print $1}')
    log_info "  Web interface: http://${ip:-YOUR-SERVER-IP}"
    log_info "  XML-RPC API:   http://${ip:-YOUR-SERVER-IP}:43204"
    echo ""
    log_info "Auto-updates: /etc/cron.d/dividia-nvr-update (host cron, 02:xx daily)"
    echo ""
    log_warn "Next steps:"
    log_warn "  1. Enter your product key in the web interface"
    log_warn "  2. Add cameras through Setup > Cameras"
    log_warn "  3. Configure VideoStores if needed"
    echo ""
    [[ -n "$LOG_FILE" ]] && log_info "Install log saved to: $LOG_FILE"
    echo ""
}

upgrade_flow() {
    log_step "UPGRADE MODE: Restoring from VideoStore backup"
    echo ""

    # Steps 1-8: same as fresh install (Docker, storage, dirs, compose, .env)
    check_root
    detect_os
    check_virtualbox_graphics
    install_docker
    configure_docker_storage
    configure_docker_logging
    docker_login
    create_directory_structure
    seed_nvr_id
    [[ -n "$VIDEO_DEVICE" ]] && prepare_video_device
    download_compose_files
    create_env_file

    # Step 9: Restore saved .env from VideoStore (preserves channel/registry)
    restore_saved_env

    # Step 10: Disable conflicting host services
    disable_host_services
    ensure_videostore_writable
    install_host_firefox

    # Step 11: Pull images, start DB only, restore before backend fully starts
    log_step "Pulling NVR images"
    cd "$INSTALL_DIR"
    docker compose pull --quiet

    log_step "Starting database for restore"
    docker compose up -d db
    # Wait for DB healthy
    local max_wait=60
    local waited=0
    while [[ $waited -lt $max_wait ]]; do
        local health
        health=$(docker compose ps db --format '{{.Health}}' 2>/dev/null || true)
        if [[ "$health" == "healthy" ]]; then
            break
        fi
        sleep 2
        waited=$((waited + 2))
    done

    # Run restore as a one-shot command using the backend image.
    # This runs rda-db --restore BEFORE docker-start, so the database,
    # dvs.conf, SSH keys, and network config are all in place before the
    # backend's full initialization (autoKey, seeding, etc.).
    log_step "Restoring NVR from backup"
    if docker compose run --rm --no-deps -T --entrypoint "" backend rda-db --restore; then
        log_info "Restore completed successfully"
    else
        log_warn "Restore failed — backend will start with fresh configuration"
    fi

    # Version gate AFTER restore: dvs.conf has been materialized from the
    # backup. If the backup was from a pre-6.0 NVR, refuse to run --update-only
    # against it (it would skip the 206 missing pre-6.0 scripts and produce
    # a subtly broken DB).
    check_version_6_0_or_fail "$DATA_DIR/config/dvs.conf"

    log_step "Running database migrations"
    # Fail-closed: a failed migration on the pre-start path leaves the
    # DB half-migrated. Starting services against that state corrupts
    # further. Abort with explicit recovery instructions instead.
    if ! docker compose run --rm --no-deps -T --entrypoint "" backend rda-db --update-only; then
        log_error "Database migration failed before services started."
        log_error "The restored DB is partially migrated. Services were NOT started."
        log_error "Investigate the update script failure above, fix the root cause,"
        log_error "then re-run: $0 --upgrade"
        exit 1
    fi

    # Now start all services — backend's docker-start will find the restored
    # database and dvs.conf already in place
    log_step "Starting all NVR services"
    if ! docker compose up -d --quiet-pull 2>&1; then
        log_warn "Some services failed to start (backend may still be initializing)"
        wait_for_backend_healthy
        log_info "Retrying service start..."
        docker compose up -d --quiet-pull 2>/dev/null || true
    fi

    # Create systemd service, management scripts, user access
    create_boot_service
    create_management_scripts
    configure_user_access
    install_prune_cron
    install_update_cron

    echo ""
    log_step "Upgrade Complete!"
    echo ""
    log_info "NVR upgraded and restored to: $INSTALL_DIR"
    log_info "Data directory:   $DATA_DIR"
    echo ""
    log_info "Management commands:"
    log_info "  nvr status          — Check status"
    log_info "  nvr logs [service]  — View logs"
    log_info "  nvr update          — Update to latest"
    log_info "  nvr backup          — Backup to VideoStore"
    log_info "  nvr help            — Show all commands"
    echo ""
    local ip
    ip=$(hostname -I 2>/dev/null | awk '{print $1}')
    log_info "Access the NVR:"
    log_info "  Web interface: http://${ip:-YOUR-SERVER-IP}"
    log_info "  XML-RPC API:   http://${ip:-YOUR-SERVER-IP}:43204"
    echo ""
    log_warn "Note: Product key may need re-entry if the device seed changed"
    echo ""
    [[ -n "$LOG_FILE" ]] && log_info "Install log saved to: $LOG_FILE"
    echo ""
}

migrate_flow() {
    log_step "RPM-TO-DOCKER MIGRATION"
    echo ""
    log_warn "This will migrate your RPM-based NVR installation to Docker."
    log_warn "The process will:"
    log_warn "  1. Back up your current NVR configuration and database"
    log_warn "  2. Stop all RPM NVR services"
    log_warn "  3. Install Docker and start NVR containers"
    log_warn "  4. Restore your configuration, database, and product key"
    log_warn "  5. Remove old RPM packages"
    echo ""

    # Version gate BEFORE we take the destructive user-confirmation prompt —
    # user shouldn't have to say yes to a migration we're about to refuse.
    check_version_6_0_or_fail /etc/dvs.conf

    if ! $ASSUME_YES; then
        read -r -p "Proceed with migration? [y/N] " response
        if [[ ! "$response" =~ ^[Yy]$ ]]; then
            log_info "Migration cancelled"
            exit 0
        fi
    fi
    echo ""

    # Phase 1: Pre-migration (RPM still running)
    check_root
    detect_os
    ensure_rpm_path
    capture_rpm_seed
    rpm_backup

    # Phase 2: Stop all RPM services (with rollback on failure)
    stop_rpm_services
    trap 'rollback_rpm_services' ERR

    # Phase 3: Docker install (reuses fresh install functions)
    install_docker
    configure_docker_storage
    configure_docker_logging
    docker_login

    # Pre-seed timezone from host before create_directory_structure
    mkdir -p "$DATA_DIR/timezone"
    if [[ -f /etc/localtime ]]; then
        cp /etc/localtime "$DATA_DIR/timezone/localtime"
    elif command -v timedatectl &>/dev/null; then
        local tz
        tz=$(timedatectl show --property=Timezone --value 2>/dev/null || true)
        if [[ -n "$tz" ]] && [[ -f "/usr/share/zoneinfo/$tz" ]]; then
            cp "/usr/share/zoneinfo/$tz" "$DATA_DIR/timezone/localtime"
        fi
    elif [[ -f /etc/sysconfig/clock ]]; then
        # CentOS 6: read ZONE from /etc/sysconfig/clock
        local tz
        tz=$(. /etc/sysconfig/clock 2>/dev/null && echo "$ZONE")
        if [[ -n "$tz" ]] && [[ -f "/usr/share/zoneinfo/$tz" ]]; then
            cp "/usr/share/zoneinfo/$tz" "$DATA_DIR/timezone/localtime"
        fi
    fi
    [[ -f /etc/sysconfig/clock ]] && cp /etc/sysconfig/clock "$DATA_DIR/timezone/clock"

    create_directory_structure
    # Skip seed_nvr_id — restore provides the real dvs.conf
    # Skip prepare_video_device — drive already has backup data and recordings
    mkdir -p /videostore
    download_compose_files
    create_env_file
    disable_host_services
    ensure_videostore_writable
    install_host_firefox
    pull_and_start

    # Phase 4: Restore
    write_rpm_seed_to_docker
    ensure_backup_accessible
    restore_from_backup

    # Phase 4.5: Make sure every enabled VideoStore row has the device
    # info docker-start needs to re-mount on next backend startup. RPM-era
    # rda-autofs sometimes only stored sUUID (or just sMountPoint);
    # docker-start's Phase 2 re-mount needs at least one of sDevice /
    # sLabel / sUUID. Without backfill, cs256-style disaster on reboot:
    # no mount, writes go to /, root fills, pbserver rejects every path.
    backfill_videostore_fields

    # Phase 5: Verify Docker is healthy, then remove RPMs
    verify_migration

    # Clear the rollback trap — Docker is running, safe to remove RPMs
    trap - ERR
    remove_rpm_packages

    # Reclaim ~400-870 MB of RPM-era cruft (firefox, httpd, yum cache, old
    # logs, /var/lib/mysql when DB sanity check passed). Skip with
    # --keep-legacy if debugging or planning a rollback.
    cleanup_legacy_artifacts

    create_boot_service
    create_management_scripts
    configure_user_access
    install_prune_cron
    install_update_cron

    echo ""
    log_step "Migration Complete!"
    echo ""
    log_info "NVR migrated from RPM to Docker: $INSTALL_DIR"
    log_info "Data directory:   $DATA_DIR"
    log_info "Channel:          $CHANNEL"
    echo ""
    log_info "Management commands:"
    log_info "  nvr status          — Check status"
    log_info "  nvr logs [service]  — View logs"
    log_info "  nvr update          — Update to latest"
    log_info "  nvr backup          — Backup to VideoStore"
    log_info "  nvr help            — Show all commands"
    echo ""
    local ip
    ip=$(hostname -I 2>/dev/null | awk '{print $1}')
    log_info "Access the NVR:"
    log_info "  Web interface: http://${ip:-YOUR-SERVER-IP}"
    log_info "  XML-RPC API:   http://${ip:-YOUR-SERVER-IP}:43204"
    echo ""
    if [[ -n "$RPM_SEED" ]]; then
        log_info "Product key preserved from RPM install"
    else
        log_warn "Product key needs re-entry — device seed could not be preserved"
    fi
    echo ""
    [[ -n "$LOG_FILE" ]] && log_info "Install log saved to: $LOG_FILE"
    echo ""
}

main() {
    echo ""
    echo "============================================"
    echo "  NVR Docker Installation Script"
    echo "============================================"
    echo ""

    parse_args "$@"
    validate_channel
    setup_logging

    # Auto-detect RPM install if not explicitly in migrate or upgrade mode
    if [[ "$MIGRATE_MODE" != "true" ]] && [[ "$UPGRADE_MODE" != "true" ]]; then
        if detect_rpm_install 2>/dev/null; then
            log_info "Existing RPM NVR installation detected — switching to migration mode"
            MIGRATE_MODE=true
        fi
    fi

    # Auto-detect upgrade mode if not migrating
    if [[ "$MIGRATE_MODE" != "true" ]]; then
        if [[ "$UPGRADE_MODE" != "true" ]]; then
            if detect_existing_install 2>/dev/null; then
                log_info "Existing backup detected on VideoStore — switching to upgrade mode"
                UPGRADE_MODE=true
            fi
        else
            # Verify backup exists when --upgrade is explicit
            if ! detect_existing_install 2>/dev/null; then
                log_error "No backup found on VideoStore"
                log_error "Run backup-nvr.sh on the existing system before upgrading"
                exit 1
            fi
        fi
    fi

    # Determine display mode
    local mode="FRESH"
    if $MIGRATE_MODE; then mode="MIGRATE"; elif $UPGRADE_MODE; then mode="UPGRADE"; fi

    log_info "Configuration:"
    log_info "  Mode:         $mode"
    log_info "  Channel:      $CHANNEL"
    log_info "  Registry:     $REGISTRY"
    log_info "  Data Dir:     $DATA_DIR"
    log_info "  Install:      $INSTALL_DIR"
    if [[ -n "$VIDEO_DEVICE" ]]; then
        log_info "  Video Device: $VIDEO_DEVICE"
    elif [[ "$NO_VIDEO_DEVICE" == "true" ]]; then
        log_info "  Video Device: (none — --no-video-device; directory fallback, dev/test only)"
    fi
    # Auto-detected vs[N] partitions are logged from check_video_device_or_fail
    # at fresh_install_flow start; no summary line here because DETECTED_VS_LABELS
    # isn't populated yet at this point.
    echo ""

    if $MIGRATE_MODE; then
        migrate_flow
    elif $UPGRADE_MODE; then
        upgrade_flow
    else
        fresh_install_flow
    fi
}

main "$@"
