#!/bin/bash
################################################################################
# nvr — Unified CLI for NVR Docker management
#
# Runs on the HOST. Shipped inside the backend image and synced to
# /opt/dividia/ on container startup, so it stays current with the
# running software version.
#
# Usage: nvr <command> [args...]
################################################################################

set -e

INSTALL_DIR="${NVR_INSTALL_DIR:-/opt/dividia}"
HOST_DVIEW_ROOT="$INSTALL_DIR/dview-host"
HOST_DVIEW_ENABLED="$HOST_DVIEW_ROOT/enabled"
HOST_DVIEW_SERVICE="dividia-host-dview"
HOST_DVIEW_CACHE_CONTAINER="dividia-host-dview-payload-cache"
HOST_DVIEW_CACHE_VIEWER_CONTAINER="dividia-host-dview-viewer-cache"
LPR_TEMPLATES_OVERLAY="docker-compose.lpr-templates.yml"
LPR_TEMPLATES_REFRESH_MARKER="${NVR_LPR_TEMPLATES_REFRESH_MARKER:-$INSTALL_DIR/data/config/.lpr-templates-refresh-required}"
CUSTOMER_ASSETS_BLOCK_OVERLAY="docker-compose.customer-assets-incomplete.yml"
CLOUDAPI_PROFILE_ROOT="$INSTALL_DIR/config/cloudapi-profiles"
CLOUDAPI_ACTIVE_LINK="$INSTALL_DIR/config/cloudapi-active"
CLOUDAPI_STAGING_MARKER="$INSTALL_DIR/config/cloudapi-staging-approved"
CLOUDAPI_PROFILE_HELPER="$INSTALL_DIR/cloudapi-profile-util.py"
NVR_BACKEND_XMLRPC_URL="${NVR_BACKEND_XMLRPC_URL:-http://127.0.0.1:43204/RDA/}"
MARIADB_DATADIR="${NVR_MARIADB_DATADIR:-$INSTALL_DIR/data/db_data}"
MARIADB_OPEN_ATTEMPT_MARKER="${NVR_MARIADB_OPEN_ATTEMPT_MARKER:-$MARIADB_DATADIR/.dividia-mariadb-10.11-open-attempt}"
NVR_ACTIVATION_PROTOCOL=1
NVR_ACTIVATION_DIR="${NVR_ACTIVATION_DIR:-/var/lib/dividia-nvr/update-activation}"
NVR_ACTIVATION_MANIFEST="${NVR_ACTIVATION_MANIFEST:-$NVR_ACTIVATION_DIR/manifest}"
NVR_ACTIVATION_JOURNAL="${NVR_ACTIVATION_JOURNAL:-$NVR_ACTIVATION_DIR/journal}"
NVR_ACTIVATION_LAST_UPDATE="${NVR_ACTIVATION_LAST_UPDATE:-$NVR_ACTIVATION_DIR/last-update}"
NVR_UPDATE_CONTINUATION_REQUEST="${NVR_UPDATE_CONTINUATION_REQUEST:-${NVR_ACTIVATION_CONTINUATION_REQUEST:-$NVR_ACTIVATION_DIR/continuation}}"
NVR_UPDATE_CONTINUATION_COMPLETED="${NVR_UPDATE_CONTINUATION_COMPLETED:-$NVR_ACTIVATION_DIR/continuation-completed}"
NVR_UPDATE_CONTINUATION_CRON="${NVR_UPDATE_CONTINUATION_CRON:-${NVR_ACTIVATION_CONTINUATION_CRON:-/etc/cron.d/dividia-nvr-activation-continuation}}"
NVR_UPDATE_CONTINUATION_LOCK="${NVR_UPDATE_CONTINUATION_LOCK:-${NVR_ACTIVATION_CONTINUATION_LOCK:-/var/lock/dividia-nvr-activation-continuation.lock}}"
NVR_UPDATE_CONTINUATION_MAX_AGE_SECONDS="${NVR_UPDATE_CONTINUATION_MAX_AGE_SECONDS:-${NVR_ACTIVATION_CONTINUATION_MAX_AGE_SECONDS:-7200}}"
NVR_ACTIVATION_OVERLAY="docker-compose.activation-10.5-to-10.11.yml"
NVR_ACTIVATION_BACKEND_MODE="${NVR_ACTIVATION_BACKEND_MODE:-$INSTALL_DIR/data/config/rda-db-activation-mode}"
NVR_LEGACY_MARIADB_DATADIR_MARKER="${NVR_LEGACY_MARIADB_DATADIR_MARKER:-$INSTALL_DIR/data/mariadb-guard/.dividia-mariadb-version}"
NVR_ACTIVATION_KEEPER_PREFIX="dividia-nvr-activation"
NVR_ACTIVATION_RECORDING_HEALTH_TIMEOUT="${NVR_ACTIVATION_RECORDING_HEALTH_TIMEOUT:-5}"
NVR_ENGINE_HTTP_PORT="${NVR_ENGINE_HTTP_PORT:-43209}"
NVR_ACTIVATION_BACKUP_RESERVE_EXTRA_KB="${NVR_ACTIVATION_BACKUP_RESERVE_EXTRA_KB:-65536}"
case "$NVR_ACTIVATION_RECORDING_HEALTH_TIMEOUT" in ''|*[!0-9]*) NVR_ACTIVATION_RECORDING_HEALTH_TIMEOUT=5 ;; esac
case "$NVR_ENGINE_HTTP_PORT" in ''|*[!0-9]*) NVR_ENGINE_HTTP_PORT=43209 ;; esac
case "$NVR_ACTIVATION_BACKUP_RESERVE_EXTRA_KB" in ''|*[!0-9]*) NVR_ACTIVATION_BACKUP_RESERVE_EXTRA_KB=65536 ;; esac
MARIADB_MARKER_DIR="${NVR_MARIADB_MARKER_DIR:-$NVR_ACTIVATION_DIR}"
MARIADB_DATADIR_MARKER="${NVR_MARIADB_DATADIR_MARKER:-$MARIADB_MARKER_DIR/.dividia-mariadb-version}"
NVR_UPDATE_BACKUP_ROOT="${NVR_UPDATE_BACKUP_ROOT:-/videostore/vs1/backups}"
NVR_UPDATE_BACKUP_FAILED_ROOT="${NVR_UPDATE_BACKUP_FAILED_ROOT:-/videostore/vs1/failed-update-backups}"
NVR_UPDATE_BACKUP_METADATA=".nvr-update-backup"
NVR_UPDATE_BACKUP_RETAIN=3
NVR_UPDATE_BACKUP_FAILED_RETAIN=3
NVR_UPDATE_SOURCE_STATE="${NVR_UPDATE_SOURCE_STATE:-$INSTALL_DIR/data/config/update-source-images}"
NVR_UPDATE_SOURCE_COMPOSE="${NVR_UPDATE_SOURCE_COMPOSE:-$INSTALL_DIR/data/config/update-source-compose.yml}"
NVR_UPDATE_SOURCE_OVERRIDE="${NVR_UPDATE_SOURCE_OVERRIDE:-$INSTALL_DIR/data/config/update-source-images.yml}"

# --- Optional aiengine add-on -------------------------------------------------
# Durable host intent, credential directory, and overlay for the local aiengine
# add-on (LPR / object detection on the NVR). See
# docs/plans/aiengine-docker-addon.md. All paths are overridable so the
# behavioral tests can point them at a temp dir; production uses the real tree.
#
# Intent is the long-term service switch (enabled | legacy-provisioned |
# disabled). An absent file means disabled. It is deliberately NOT database
# state: a local engine is a licensed, intentional host capability that must
# stay available while an operator changes camera configuration.
AIENGINE_ENV_FILE="${NVR_ENV_FILE:-$INSTALL_DIR/.env}"
AIENGINE_INTENT_DIR="${NVR_AIENGINE_INTENT_DIR:-$INSTALL_DIR/data/config/addons/aiengine}"
AIENGINE_INTENT_FILE="$AIENGINE_INTENT_DIR/intent"
AIENGINE_CONFIG_DIR="${NVR_AIENGINE_CONFIG_DIR:-$INSTALL_DIR/data/config/aiengine}"
AIENGINE_SUPPORT_RESTORE_JOURNAL="${NVR_AIENGINE_SUPPORT_RESTORE_JOURNAL:-$INSTALL_DIR/data/config/.aiengine-support-restore}"
AIENGINE_SUPPORT_RESTORE_COMMITTED="${AIENGINE_SUPPORT_RESTORE_JOURNAL}.committed"
AIENGINE_SUPPORT_RESTORE_ROLLED_BACK="${AIENGINE_SUPPORT_RESTORE_JOURNAL}.rolled-back"
AIENGINE_SUPPORT_RESTORE_LOCK="${NVR_AIENGINE_SUPPORT_RESTORE_LOCK:-/var/lock/nvr-aiengine-support.lock}"
AIENGINE_SUPPORT_LOCK_TIMEOUT="${NVR_AIENGINE_SUPPORT_LOCK_TIMEOUT:-1800}"
AIENGINE_OVERLAY="docker-compose.aiengine.yml"
AIENGINE_SERVICE="aiengine"

# HME drive-thru addon (see `nvr addon hme` + docker-compose.hme.yml). The
# marker holds durable operator intent (enabled/disabled/absent=auto); the
# overlay token in COMPOSE_FILE is what actually runs the container. Cron/lock
# are overridable so the behavioral test can point them at a temp dir.
HME_STATE_DIR="${NVR_HME_STATE_DIR:-$INSTALL_DIR/hme}"
HME_MARKER_FILE="$HME_STATE_DIR/marker"
HME_ABSENT_STREAK_FILE="$HME_STATE_DIR/absent-streak"
HME_LAST_ERROR_FILE="$HME_STATE_DIR/last-error"
HME_OVERLAY_FILE="docker-compose.hme.yml"
HME_CONF_PATH="${NVR_HME_CONF_PATH:-$INSTALL_DIR/data/config/hme-stream.conf}"
HME_RECONCILE_CRON="${NVR_HME_CRON:-/etc/cron.d/dividia-nvr-hme}"
HME_LOCK="${NVR_HME_LOCK:-/var/lock/nvr-hme.lock}"

# --- Lifecycle lock + native container healer (ADR-100) -----------------------
# ONE named constant for the shared serialization lock every lifecycle path
# takes (update/start/stop/restart), the aiengine add-on takes, the HME
# reconcile probes, and `nvr heal` takes non-blocking. Defined here at the top
# so every consumer references the same path and they cannot silently diverge.
# Overridable so the behavioral tests can point it at a temp file.
NVR_LIFECYCLE_LOCK="${NVR_LIFECYCLE_LOCK:-/var/lock/nvr-update.lock}"
NVR_LIFECYCLE_LOCK_TIMEOUT="${NVR_LIFECYCLE_LOCK_TIMEOUT:-1800}"

# `nvr heal`: host-level container watchdog that fully replaces the (now removed)
# autoheal sidecar.
# All thresholds are expressed in TIME (seconds), never tick counts, so the cron
# interval is freely tunable without changing their meaning. Every path is
# overridable so the behavioral tests can point them at a temp tree.
HEAL_INTERVAL_MIN="${NVR_HEAL_INTERVAL_MIN:-5}"          # cron cadence (*/N)
HEAL_UNHEALTHY_PERSIST="${NVR_HEAL_UNHEALTHY_PERSIST:-600}"  # 10 min unhealthy before Class 1 acts
HEAL_FLAP_WINDOW="${NVR_HEAL_FLAP_WINDOW:-1800}"        # 30 min rolling window for flap escalation
HEAL_FLAP_MAX="${NVR_HEAL_FLAP_MAX:-3}"                 # failed heals in the window before escalate
HEAL_COOLDOWN="${NVR_HEAL_COOLDOWN:-1800}"             # 30 min per-service daemon-wedge cooldown
# Explicit coverage sets, NOT core_services() (which includes db). A new service
# must be added deliberately; test_heal_coverage.sh enforces both sets.
HEAL_UNHEALTHY_SET="${NVR_HEAL_UNHEALTHY_SET:-backend engine connector playback viewer ptz remoted}"
HEAL_DOWN_SET="${NVR_HEAL_DOWN_SET:-db backend engine connector playback viewer ptz remoted}"
# Dependency processing order (db first, then backend, then engine, then the
# rest), so a dependency is recreated before its dependents within a tick.
HEAL_DEP_ORDER="${NVR_HEAL_DEP_ORDER:-db backend engine connector playback viewer ptz remoted}"
# Optional add-ons healed when enabled (present in the live compose config).
HEAL_ADDON_SET="${NVR_HEAL_ADDON_SET:-hme aiengine}"
# Ephemeral stop marker: written by `nvr stop`, cleared by any bring-up, and
# gone after a reboot because /run is a tmpfs. While present, heal no-ops.
HEAL_STOP_MARKER="${NVR_HEAL_STOP_MARKER:-/run/dividia-nvr/heal-suppressed}"
# Persistent heal state (cooldowns, flap counters, first-unhealthy stamps, and
# the heartbeat). /var/lib is variable state data, present on all four supported
# host OSes.
HEAL_STATE_DIR="${NVR_HEAL_STATE_DIR:-/var/lib/dividia-nvr/heal}"
# Monitor-facing proof-of-life: each tick records healthy/unavailable here. heal
# is now the SOLE watchdog, so the external monitor uses this to confirm it runs.
HEAL_HEARTBEAT_FILE="${NVR_HEAL_HEARTBEAT_FILE:-$HEAL_STATE_DIR/heartbeat}"
HEAL_DESIRED_CACHE="${NVR_HEAL_DESIRED_CACHE:-$HEAL_STATE_DIR/desired-services}"
# Per-tick "docker was partially degraded" marker. A FILE (not a shell global)
# because the per-service helpers that detect a failed docker query run inside
# `$(...)` command substitutions (subshells), where a global would be lost but a
# file write persists. Cleared at the start of each tick.
HEAL_DEGRADED_MARK="${NVR_HEAL_DEGRADED_MARK:-$HEAL_STATE_DIR/.degraded}"
HEAL_LOG="${NVR_HEAL_LOG:-/var/log/dividia-nvr-heal.log}"
HEAL_CRON="${NVR_HEAL_CRON:-/etc/cron.d/dividia-nvr-heal}"
HEAL_LOGROTATE="${NVR_HEAL_LOGROTATE:-/etc/logrotate.d/dividia-nvr-heal}"
# The corruption signatures a recreate (rm -f + up --no-deps) clears. The first
# four match repair_untracked_compose_containers today; the fifth is the cs183
# stale-containerd-task strand that motivated ADR-100.
HEAL_CORRUPT_SIGNATURES="${NVR_HEAL_CORRUPT_SIGNATURES:-unexpectedly nil|RW layer for container|failed to start shim|container with given ID already exists|could not delete stale containerd task object}"

# Host-config self-heal targets (FIX 4 / ensure_host_config). Overridable so
# the behavioral test can point them at a temp dir; production uses the real
# /etc paths that install-nvr.sh writes.
SUDOERS_FILE="${NVR_SUDOERS_FILE:-/etc/sudoers.d/dividia}"
DOCKER_DAEMON_JSON="${NVR_DOCKER_DAEMON_JSON:-/etc/docker/daemon.json}"
SYSCTL_RESERVED_PORTS_FILE="${NVR_SYSCTL_RESERVED_PORTS_FILE:-/etc/sysctl.d/90-dividia-nvr-ports.conf}"
NVR_SERVICE_PORT_RANGE="43202-43210"

# Where `nvr` is symlinked so it resolves on every user's PATH AND sudo's
# secure_path (which omits /opt/dividia). ensure_host_config keeps this current.
# Overridable so the behavioral test can point it at a temp path.
NVR_BIN_SYMLINK="${NVR_BIN_SYMLINK:-/usr/local/bin/nvr}"

# A root-resident helper owns any bind that Docker needs before /opt/dividia/nvr
# becomes reachable. The CLI only records a mapping after it verifies the live
# bind. Paths are overridable for host-script tests.
BOOT_STORAGE_CONFIG="${NVR_BOOT_STORAGE_CONFIG:-/etc/dividia-nvr/boot-storage.conf}"
BOOT_STORAGE_HELPER="${NVR_BOOT_STORAGE_HELPER:-/usr/local/sbin/dividia-nvr-boot-storage}"
BOOT_STORAGE_DOCKER_DROPIN="${NVR_DOCKER_BOOT_STORAGE_DROPIN:-/etc/systemd/system/docker.service.d/10-dividia-boot-storage.conf}"
BOOT_STORAGE_CONTAINERD_DROPIN="${NVR_CONTAINERD_BOOT_STORAGE_DROPIN:-/etc/systemd/system/containerd.service.d/10-dividia-boot-storage.conf}"
BOOT_STORAGE_DOCKER_INIT="${NVR_DOCKER_INIT:-/etc/init.d/docker}"
BOOT_STORAGE_VIDEOSTORE_ROOT="${NVR_VIDEOSTORE_ROOT:-/videostore}"
BOOT_STORAGE_FSTAB="${NVR_FSTAB:-/etc/fstab}"
BOOT_STORAGE_COMPOSE_OVERLAY="${NVR_BOOT_STORAGE_COMPOSE_OVERLAY:-/etc/dividia-nvr/docker-compose.boot-storage.yml}"
BOOT_STORAGE_CONTAINER_MASK="${NVR_BOOT_STORAGE_CONTAINER_MASK:-/etc/dividia-nvr/container-mask}"
BOOT_STORAGE_SHADOW_LIMIT_BYTES="${NVR_BOOT_STORAGE_SHADOW_LIMIT_BYTES:-67108864}"
case "$BOOT_STORAGE_SHADOW_LIMIT_BYTES" in ''|*[!0-9]*) BOOT_STORAGE_SHADOW_LIMIT_BYTES=67108864 ;; esac

# CO6/CO7 hosts install docker at /usr/local/bin/docker, which sudo's
# secure_path omits by default. Without this export, the `docker compose
# version` detection below fails when nvr is invoked via non-interactive
# sudo (e.g. cron, remote ssh exec) and the script exits with "Docker
# Compose not found". Prepending these dirs is safe on CO9/UB24 hosts
# too, where docker is at /usr/bin/docker.
#
# Default fallback (the :- branch) avoids a trailing colon when PATH is
# unset / empty, which bash would interpret as "current directory in
# PATH". Since this script then `cd "$INSTALL_DIR"` and INSTALL_DIR is
# /opt/dividia (writable by the dividia user per install-nvr.sh), a
# trailing-colon PATH would let any local dividia-user plant a malicious
# tar/grep/mv at /opt/dividia/<bin> and have it executed by root on the
# next `sudo nvr update` or scheduled cron run.
export PATH="/usr/local/bin:/usr/local/sbin:${PATH:-/usr/sbin:/usr/bin:/sbin:/bin}"

# Detect compose command: prefer v2 plugin, fall back to standalone v1. A
# bounded backup child reuses the command that its parent already validated.
if [[ "${NVR_BACKUP_OUTER_TIMEOUT_ACTIVE:-0}" == 1 && -n "${NVR_BACKUP_COMPOSE_COMMAND:-}" ]]; then
    COMPOSE="$NVR_BACKUP_COMPOSE_COMMAND"
elif docker compose version &>/dev/null 2>&1; then
    COMPOSE="docker compose"
elif command -v docker-compose &>/dev/null; then
    COMPOSE="docker-compose"
else
    echo "ERROR: Docker Compose not found"
    exit 1
fi

cd "$INSTALL_DIR"

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

################################################################################
# Commands
#
# Windows-side access to `db / shell / channel <set> / vm-shell /
# migrate-scalewatcher` is gated at the SSH layer: the ro-key in
# ~/.ssh/authorized_keys uses a forced-command wrapper that only lets a
# subset of subcommands through, and the admin key is passphrase-protected
# on the Windows host. On Linux-host Docker installs there is no
# script-level gate — the tech is already authenticated as `dividia` via
# SSH public-key or console login before they ever type `nvr`.
################################################################################

cmd_status() {
    echo -e "${BLUE}=== NVR Docker Stack ===${NC}"
    $COMPOSE ps
    echo ""
    echo -e "${BLUE}=== Identity ===${NC}"
    # Serial + hostname are read offline (dvs.conf / hostname) so they always
    # render; only the name comes from the db and degrades if it's down. Each
    # assignment is guarded (declare `local` separately, then `|| ...`) so a
    # failed read never trips `set -e` and aborts the rest of status. Serial
    # uses the same dvs.conf path/grep as update_cron_jitter().
    local _name _serial _host
    _name=$(nvr_name) || _name=""
    [[ -z "$_name" ]] && _name="(unavailable - db not running?)"
    _serial=$(grep '^ID=' data/config/dvs.conf 2>/dev/null | cut -d= -f2-) || true
    _host=$(hostname 2>/dev/null) || true
    echo "Name:     ${_name}"
    echo "Serial:   ${_serial:-<unknown>}"
    echo "Hostname: ${_host:-<unknown>}"
    echo ""
    echo -e "${BLUE}=== Configuration ===${NC}"
    grep -E '^(CHANNEL|REGISTRY)=' .env 2>/dev/null || true
    echo ""
    echo -e "${BLUE}=== Update cron ===${NC}"
    if [[ -f /etc/cron.d/dividia-nvr-update ]]; then
        # `|| true` keeps cmd_status surviving a future `set -o pipefail`
        # roll-out: head closes early → grep gets SIGPIPE → exit 141.
        grep -E '^[0-9]+ ' /etc/cron.d/dividia-nvr-update 2>/dev/null | head -1 || true
    else
        echo "(update cron not installed; will land on next nvr update)"
    fi
    echo ""
    echo -e "${BLUE}=== Staged activation ===${NC}"
    activation_print_status
    echo ""
    cmd_display status
}

cmd_logs() {
    $COMPOSE logs "$@"
}

cloudapi_profile_python() {
    if command -v python3 >/dev/null 2>&1; then
        echo python3
    elif command -v python >/dev/null 2>&1; then
        echo python
    else
        echo "ERROR: Python is required for cloudapi profile validation" >&2
        return 1
    fi
}

cloudapi_profile_describe() {
    local profile_dir="$1"
    local expected="${2:-}"
    local python_cmd

    [[ -f "$CLOUDAPI_PROFILE_HELPER" ]] || {
        echo "ERROR: $CLOUDAPI_PROFILE_HELPER is missing; run nvr update first" >&2
        return 1
    }
    python_cmd=$(cloudapi_profile_python) || return 1
    if [[ -n "$expected" ]]; then
        "$python_cmd" "$CLOUDAPI_PROFILE_HELPER" "$profile_dir" "$expected"
    else
        "$python_cmd" "$CLOUDAPI_PROFILE_HELPER" "$profile_dir"
    fi
}

cloudapi_profile_require_root() {
    if should_reexec_root; then nvr_try_elevate; return $?; fi
    [[ $EUID -eq 0 ]] && return 0
    echo "ERROR: run 'sudo nvr cloudapi-profile $1'" >&2
    return 1
}

cloudapi_profile_channel() {
    sed -n 's/^CHANNEL=//p' "$INSTALL_DIR/.env" 2>/dev/null | head -1
}

cloudapi_profile_staging_allowed() {
    local channel
    channel=$(cloudapi_profile_channel)
    case "$channel" in
        dev|dev-*) ;;
        *)
            echo "ERROR: staging requires a dev or dev-* update channel" >&2
            return 1
            ;;
    esac
    [[ -f "$CLOUDAPI_STAGING_MARKER" ]] || {
        echo "ERROR: staging requires $CLOUDAPI_STAGING_MARKER" >&2
        return 1
    }
}

cloudapi_profile_set_link() {
    local target="$1"
    local python_cmd

    mkdir -p "$(dirname "$CLOUDAPI_ACTIVE_LINK")"
    if [[ -d "$CLOUDAPI_ACTIVE_LINK" && ! -L "$CLOUDAPI_ACTIVE_LINK" ]]; then
        rmdir "$CLOUDAPI_ACTIVE_LINK" 2>/dev/null || {
            echo "ERROR: $CLOUDAPI_ACTIVE_LINK is a non-empty directory" >&2
            return 1
        }
    elif [[ -e "$CLOUDAPI_ACTIVE_LINK" && ! -L "$CLOUDAPI_ACTIVE_LINK" ]]; then
        echo "ERROR: $CLOUDAPI_ACTIVE_LINK is not a managed symlink" >&2
        return 1
    fi

    python_cmd=$(cloudapi_profile_python) || return 1
    "$python_cmd" "$CLOUDAPI_PROFILE_HELPER" \
        activate "$target" "$CLOUDAPI_ACTIVE_LINK"
}

cloudapi_profile_wait_backend() {
    local container=""
    local status=""
    local attempt=0

    while [[ $attempt -lt 60 ]]; do
        container=$($COMPOSE ps -q backend 2>/dev/null || true)
        if [[ -n "$container" ]]; then
            status=$(docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null || true)
            [[ "$status" == "healthy" ]] && return 0
            [[ "$status" == "unhealthy" ]] && break
        fi
        attempt=$((attempt + 1))
        sleep 2
    done
    echo "ERROR: backend did not become healthy" >&2
    return 1
}

cloudapi_profile_apply_backend() {
    $COMPOSE up -d --force-recreate backend
    cloudapi_profile_wait_backend
    local probe_rc=0
    $COMPOSE exec -T backend /usr/local/bin/cloudapi-profile-probe || probe_rc=$?
    if [[ $probe_rc -eq 0 ]]; then
        # Repoint the appliance web viewer's Apache /cloudapi/ reverse proxy at
        # the newly-selected endpoint. viewer-start renders that target from the
        # mounted profile (config/cloudapi-active), so a recreate is required —
        # a switch that only recreates the backend would leave the web viewer's
        # OAuth calls proxying to the previous environment. Non-fatal: a viewer
        # hiccup must not fail an otherwise-good profile switch or trip rollback,
        # but surface it so an operator knows the web viewer may be stale/down.
        $COMPOSE up -d --force-recreate viewer \
            || echo "WARNING: viewer recreate failed; web viewer may still proxy OAuth to the previous cloudapi endpoint" >&2
    fi
    return $probe_rc
}

cmd_cloudapi_profile_show() {
    if [[ ! -L "$CLOUDAPI_ACTIVE_LINK" ]]; then
        echo "Cloudapi profile: unconfigured"
        return 0
    fi

    local target
    local metadata
    target=$(readlink "$CLOUDAPI_ACTIVE_LINK")
    metadata=$(cloudapi_profile_describe "$target") || return 1
    echo "Cloudapi profile: ${metadata%%|*}"
    metadata="${metadata#*|}"
    echo "Endpoint: ${metadata%%|*}"
    echo "Trusted kids: ${metadata#*|}"
    echo "Selected at: $(stat -c '%y' "$CLOUDAPI_ACTIVE_LINK" 2>/dev/null || echo unknown)"
}

cmd_cloudapi_profile_select() {
    local environment="$1"
    local candidate="$CLOUDAPI_PROFILE_ROOT/$environment"
    local previous=""

    cloudapi_profile_require_root "$environment" || return 1
    [[ "$environment" == "prod" || "$environment" == "staging" ]] || {
        echo "ERROR: profile must be prod or staging" >&2
        return 1
    }
    if [[ "$environment" == "staging" ]]; then
        cloudapi_profile_staging_allowed || return 1
    fi
    cloudapi_profile_describe "$candidate" "$environment" >/dev/null || return 1

    if [[ -L "$CLOUDAPI_ACTIVE_LINK" ]]; then
        previous=$(readlink "$CLOUDAPI_ACTIVE_LINK")
        if [[ "$previous" == "$candidate" ]]; then
            echo "Cloudapi profile is already $environment"
            return 0
        fi
    fi

    cloudapi_profile_set_link "$candidate" || return 1
    if cloudapi_profile_apply_backend; then
        echo "Cloudapi profile switched to $environment"
        cmd_cloudapi_profile_show
        return 0
    fi

    echo "ERROR: cloudapi profile probe failed; restoring prior profile" >&2
    if [[ -n "$previous" ]]; then
        cloudapi_profile_set_link "$previous"
        cloudapi_profile_apply_backend || {
            echo "ERROR: prior cloudapi profile did not recover" >&2
            return 1
        }
    else
        rm -f "$CLOUDAPI_ACTIVE_LINK"
        $COMPOSE up -d --force-recreate backend || true
    fi
    return 1
}

remoted_require_root() {
    [[ $EUID -eq 0 ]] && return 0
    echo "ERROR: run sudo nvr remoted enroll" >&2
    return 1
}

cmd_remoted() {
    if [[ "${1:-status}" == status ]]; then
        cmd_remoted_impl "$@"
        return
    fi
    remoted_require_root || return 1
    take_lifecycle_lock_nonblocking || {
        echo "ERROR: NVR update or another enrollment is running" >&2
        return 1
    }
    local rc=0
    cmd_remoted_impl "$@" || rc=$?
    release_lifecycle_lock
    return "$rc"
}

cmd_remoted_impl() {
    local action=${1:-status} environment=prod relay token extra result
    shift || true
    if [[ "$action" == status && $# -eq 0 ]]; then
        [[ -n "$($COMPOSE ps -q remoted)" ]] || return 1
        $COMPOSE exec -T remoted python3 /usr/local/libexec/remoted-bootstrap.py status 2>/dev/null || true
        return
    fi
    [[ "$action" == enroll && $# -eq 3 && "$1" == --environment && "$3" == --token-stdin ]] || {
        echo "Usage: nvr remoted enroll --environment prod|staging --token-stdin" >&2
        return 1
    }
    remoted_require_root || return 1
    environment=$2
    case "$environment" in
        staging)
            [[ -f "$INSTALL_DIR/config/remoted-staging-approved" ]] || {
                echo "ERROR: staging requires remoted-staging-approved" >&2; return 1;
            }
            grep -Eq '^CHANNEL=dev(-[a-zA-Z0-9_-]+)?$' "$INSTALL_DIR/.env" || {
                echo "ERROR: staging requires a development image channel" >&2; return 1;
            }
            relay=relay-staging.cloud.dividia.net ;;
        prod) relay=relay.cloud.dividia.net ;;
        *) echo "ERROR: invalid enrollment environment" >&2; return 1 ;;
    esac
    # Tokens never enter command arguments, persistent files, or traced output.
    set +x
    IFS= read -r -n 65 token || [[ ${#token} -eq 64 ]] || return 1
    [[ ${#token} -eq 64 && "$token" != *[!0-9a-f]* ]] || return 1
    if IFS= read -r -n 1 extra; then token=; return 1; fi
    result=$(printf '%s\n' "$token" | $COMPOSE exec -T remoted /usr/local/bin/remoted enroll \
        --host "$relay" --token-file /dev/stdin) || { token=; return 1; }
    token=
    [[ "$result" == 'Relay enrollment complete' ]] || return 1
    $COMPOSE exec -T remoted test -s /root/.ssh/remoted-relay.conf || return 1
    $COMPOSE restart remoted >/dev/null || return 1
    echo "Remote support enrollment complete"
}

cmd_cloudapi_profile() {
    case "${1:-show}" in
        show) cmd_cloudapi_profile_show ;;
        prod|staging) cmd_cloudapi_profile_select "$1" ;;
        *)
            echo "Usage: nvr cloudapi-profile show|prod|staging" >&2
            return 1
            ;;
    esac
}

host_display_os_major() {
    local major=""
    if command -v rpm >/dev/null 2>&1; then
        major=$(rpm -E '%{rhel}' 2>/dev/null || true)
    fi
    if [[ ! "$major" =~ ^[0-9]+$ ]] && [[ -r /etc/redhat-release ]]; then
        major=$(sed -nE 's/.*release[[:space:]]+([0-9]+).*/\1/p' /etc/redhat-release | head -1)
    fi
    echo "$major"
}

host_display_supported() {
    local major
    major=$(host_display_os_major)
    [[ "$major" == "6" || "$major" == "7" ]]
}

host_display_is_enabled() {
    [[ -f "$HOST_DVIEW_ENABLED" ]]
}

host_display_require_root() {
    if should_reexec_root; then nvr_try_elevate; return $?; fi
    [[ $EUID -eq 0 ]] && return 0
    echo "ERROR: run 'sudo nvr display $1'" >&2
    return 1
}

host_display_service_active() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl is-active --quiet "$HOST_DVIEW_SERVICE.service"
    else
        service "$HOST_DVIEW_SERVICE" status >/dev/null 2>&1
    fi
}

host_display_stop_disable_service() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl stop "$HOST_DVIEW_SERVICE.service" 2>/dev/null || true
        systemctl disable "$HOST_DVIEW_SERVICE.service" 2>/dev/null || true
    else
        service "$HOST_DVIEW_SERVICE" stop 2>/dev/null || true
        chkconfig "$HOST_DVIEW_SERVICE" off 2>/dev/null || true
    fi
}

host_display_install_service() {
    [[ -x "$INSTALL_DIR/host-dview" ]] || {
        echo "ERROR: $INSTALL_DIR/host-dview is missing; run nvr update first" >&2
        return 1
    }

    if command -v systemctl >/dev/null 2>&1; then
        cat > "/etc/systemd/system/$HOST_DVIEW_SERVICE.service" <<EOF
[Unit]
Description=Dividia host desktop viewer (CO6/CO7 Docker deployment)
Requires=docker.service
Wants=network-online.target
After=docker.service network-online.target

[Service]
Type=simple
# CO7's systemd (219) predates StandardOutput=append:. Keep the redirection
# in a shell so the stable host log works on both CO7 and newer systemd.
ExecStart=/bin/bash -c 'exec $INSTALL_DIR/host-dview >>/var/log/dividia-host-dview.log 2>&1'
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
        systemctl daemon-reload
    else
        cat > "/etc/init.d/$HOST_DVIEW_SERVICE" <<EOF
#!/bin/bash
# chkconfig: 2345 97 03
# description: Dividia host desktop viewer
### BEGIN INIT INFO
# Provides:          $HOST_DVIEW_SERVICE
# Required-Start:    \$network \$docker
# Required-Stop:     \$network \$docker
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Dividia host desktop viewer
### END INIT INFO

PIDFILE=/var/run/$HOST_DVIEW_SERVICE.pid
LOGFILE=/var/log/dividia-host-dview.log
LAUNCHER=$INSTALL_DIR/host-dview

start() {
    if [[ -f "\$PIDFILE" ]] && kill -0 "\$(cat "\$PIDFILE")" 2>/dev/null; then
        return 0
    fi
    mkdir -p "\$(dirname "\$LOGFILE")"
    nohup "\$LAUNCHER" >>"\$LOGFILE" 2>&1 < /dev/null &
    echo \$! > "\$PIDFILE"
}

stop() {
    if [[ -f "\$PIDFILE" ]]; then
        kill "\$(cat "\$PIDFILE")" 2>/dev/null || true
        rm -f "\$PIDFILE"
    fi
}

status() {
    [[ -f "\$PIDFILE" ]] && kill -0 "\$(cat "\$PIDFILE")" 2>/dev/null
}

case "\${1:-}" in
    start) start ;;
    stop) stop ;;
    restart) stop; start ;;
    status) status ;;
    *) echo "Usage: \$0 {start|stop|restart|status}"; exit 2 ;;
esac
EOF
        chmod 755 "/etc/init.d/$HOST_DVIEW_SERVICE"
    fi
}

host_display_enable_start_service() {
    if command -v systemctl >/dev/null 2>&1; then
        systemctl enable "$HOST_DVIEW_SERVICE.service"
        systemctl restart "$HOST_DVIEW_SERVICE.service"
    else
        chkconfig "$HOST_DVIEW_SERVICE" on
        service "$HOST_DVIEW_SERVICE" restart
    fi

    # A Java process can fail after the service manager accepts the start.
    # Give its native library/Xorg initialization a short grace period before
    # declaring the staged payload healthy.
    sleep 3
    host_display_service_active
}

host_display_preflight() {
    local failed=0 pkg
    echo "Checking host dview prerequisites..."

    if ! host_display_supported; then
        echo "  FAIL: host must be CentOS 6 or 7 (detected: $(host_display_os_major || true))" >&2
        failed=1
    fi
    if [[ "$(uname -m)" != "x86_64" ]]; then
        echo "  FAIL: host architecture must be x86_64" >&2
        failed=1
    fi
    for pkg in Xorg xinit metacity unzip strings sha256sum; do
        if ! command -v "$pkg" >/dev/null 2>&1; then
            echo "  FAIL: required host command missing: $pkg" >&2
            failed=1
        fi
    done
    if ! rpm -q xorg-x11-server-Xorg xorg-x11-drv-fbdev xorg-x11-drv-evdev metacity >/dev/null 2>&1; then
        echo "  FAIL: legacy Xorg/video/input packages are not all installed" >&2
        failed=1
    fi
    if [[ ! -c /dev/tty1 ]]; then
        echo "  FAIL: local virtual console /dev/tty1 is unavailable" >&2
        failed=1
    fi
    if [[ ! -e /dev/fb0 && ! -d /dev/dri ]]; then
        echo "  FAIL: no framebuffer or DRM device is available" >&2
        failed=1
    fi
    [[ $failed -eq 0 ]] || return 1
    echo "  OK: host is eligible for the optional local viewer"
}

host_display_payload_image() {
    $COMPOSE config --images 2>/dev/null | awk '/(^|\/)nvr-viewer:/{ print; exit }'
}

host_display_payload_ref() {
    local image="$1" ref
    ref=$(docker image inspect --format '{{index .Config.Labels "io.dividia.host-dview.payload"}}' "$image" 2>/dev/null || true)
    [[ "$ref" != "<no value>" ]] || ref=""
    [[ "$ref" =~ ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ ]] || {
        echo "ERROR: viewer image does not name an immutable nvr-dview-host payload" >&2
        return 1
    }
    echo "$ref"
}

# Use the immutable host payload from the local Docker cache when present.
# `docker pull` contacts the registry even for an already-cached digest, which
# makes `nvr display enable` fail at the exact time a tech needs it offline.
host_display_ensure_payload_image() {
    local host_ref="$1"
    if docker image inspect "$host_ref" >/dev/null 2>&1; then
        echo "Using cached host dview payload: $host_ref"
    else
        echo "Pulling host dview payload: $host_ref"
        docker pull "$host_ref" >/dev/null || {
            echo "ERROR: host dview payload is not cached and the registry is unavailable" >&2
            return 1
        }
    fi
}

# Pin a matched viewer/host pair. The viewer image is required because its Java
# libraries must match the host JNI payload. Prepare both new pins first, then
# swap their names with rollback so a failed refresh retains the prior pair.
host_display_pin_payload_pair() {
    local viewer_image="$1" host_ref="$2"
    local viewer_id host_id current_viewer_id current_host_id
    local viewer_tmp host_tmp viewer_old host_old
    local have_viewer=0 have_host=0 installed_viewer=0
    viewer_id=$(docker image inspect --format '{{.Id}}' "$viewer_image" 2>/dev/null || true)
    host_id=$(docker image inspect --format '{{.Id}}' "$host_ref" 2>/dev/null || true)
    [[ -n "$viewer_id" && -n "$host_id" ]] || return 1
    current_viewer_id=$(docker inspect --format '{{.Image}}' "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" 2>/dev/null || true)
    current_host_id=$(docker inspect --format '{{.Image}}' "$HOST_DVIEW_CACHE_CONTAINER" 2>/dev/null || true)
    [[ "$current_viewer_id" == "$viewer_id" && "$current_host_id" == "$host_id" ]] && return 0

    viewer_tmp="${HOST_DVIEW_CACHE_VIEWER_CONTAINER}.new.$$"
    host_tmp="${HOST_DVIEW_CACHE_CONTAINER}.new.$$"
    viewer_old="${HOST_DVIEW_CACHE_VIEWER_CONTAINER}.old.$$"
    host_old="${HOST_DVIEW_CACHE_CONTAINER}.old.$$"
    docker rm -f "$viewer_tmp" "$host_tmp" "$viewer_old" "$host_old" >/dev/null 2>&1 || true
    docker create --name "$viewer_tmp" \
        --label io.dividia.host-dview.viewer-cache=true \
        "$viewer_id" >/dev/null || return 1
    docker create --name "$host_tmp" \
        --label io.dividia.host-dview.payload-cache=true \
        "$host_id" >/dev/null || {
        docker rm -f "$viewer_tmp" >/dev/null 2>&1 || true
        return 1
    }

    if docker inspect "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null 2>&1; then
        docker rename "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" "$viewer_old" >/dev/null || {
            docker rm -f "$viewer_tmp" "$host_tmp" >/dev/null 2>&1 || true
            return 1
        }
        have_viewer=1
    fi
    if docker inspect "$HOST_DVIEW_CACHE_CONTAINER" >/dev/null 2>&1; then
        if ! docker rename "$HOST_DVIEW_CACHE_CONTAINER" "$host_old" >/dev/null; then
            [[ $have_viewer -eq 1 ]] && docker rename "$viewer_old" "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null 2>&1 || true
            docker rm -f "$viewer_tmp" "$host_tmp" >/dev/null 2>&1 || true
            return 1
        fi
        have_host=1
    fi
    if docker rename "$viewer_tmp" "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null; then
        installed_viewer=1
    else
        [[ $have_viewer -eq 1 ]] && docker rename "$viewer_old" "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null 2>&1 || true
        [[ $have_host -eq 1 ]] && docker rename "$host_old" "$HOST_DVIEW_CACHE_CONTAINER" >/dev/null 2>&1 || true
        docker rm -f "$viewer_tmp" "$host_tmp" >/dev/null 2>&1 || true
        return 1
    fi
    if ! docker rename "$host_tmp" "$HOST_DVIEW_CACHE_CONTAINER" >/dev/null; then
        # Delete only a canonical viewer installed by this transaction. A
        # failed inspect can hide an older canonical pin that we do not own.
        [[ $installed_viewer -eq 1 ]] \
            && docker rm -f "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null 2>&1 || true
        [[ $have_viewer -eq 1 ]] && docker rename "$viewer_old" "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" >/dev/null 2>&1 || true
        [[ $have_host -eq 1 ]] && docker rename "$host_old" "$HOST_DVIEW_CACHE_CONTAINER" >/dev/null 2>&1 || true
        docker rm -f "$viewer_tmp" "$host_tmp" >/dev/null 2>&1 || true
        return 1
    fi
    docker rm -f "$viewer_old" "$host_old" >/dev/null 2>&1 || true
}

host_display_cached_payload_pair() {
    local viewer_id host_id
    viewer_id=$(docker inspect --format '{{.Image}}' "$HOST_DVIEW_CACHE_VIEWER_CONTAINER" 2>/dev/null || true)
    host_id=$(docker inspect --format '{{.Image}}' "$HOST_DVIEW_CACHE_CONTAINER" 2>/dev/null || true)
    [[ -n "$viewer_id" && -n "$host_id" ]] || return 1
    docker image inspect "$viewer_id" "$host_id" >/dev/null 2>&1 || return 1
    printf '%s %s\n' "$viewer_id" "$host_id"
}

# Best-effort cache fill after every core image update. This stays inert on
# unsupported hosts and does not enable or stage the optional local viewer.
host_display_prefetch_payload() {
    host_display_supported || return 0
    local image host_ref expected_native_sha host_native_sha
    image=$(host_display_payload_image)
    [[ -n "$image" ]] || { echo "WARN: viewer image is not configured; host display payload was not cached" >&2; return 1; }
    host_ref=$(host_display_payload_ref "$image") || return 1
    host_display_ensure_payload_image "$host_ref" || return 1
    expected_native_sha=$(host_display_native_sha256_label "$image" "$(host_display_os_major)") || return 1
    host_native_sha=$(host_display_native_sha256_label "$host_ref" "$(host_display_os_major)") || return 1
    [[ "$host_native_sha" == "$expected_native_sha" ]] || return 1
    host_display_pin_payload_pair "$image" "$host_ref"
}

# Repair only an already-cached payload pin during boot. Never contact the
# registry from this path because core startup must remain available offline.
host_display_repair_cached_payload_pin() {
    host_display_supported || return 0
    local image host_ref
    image=$(host_display_payload_image)
    [[ -n "$image" ]] || return 0
    host_ref=$(host_display_payload_ref "$image") || return 0
    docker image inspect "$image" "$host_ref" >/dev/null 2>&1 || return 0
    local expected_native_sha host_native_sha
    expected_native_sha=$(host_display_native_sha256_label "$image" "$(host_display_os_major)") || return 0
    host_native_sha=$(host_display_native_sha256_label "$host_ref" "$(host_display_os_major)") || return 0
    [[ "$host_native_sha" == "$expected_native_sha" ]] || return 0
    host_display_pin_payload_pair "$image" "$host_ref"
}

host_display_native_sha256_label() {
    local image="$1" major="$2" label sha
    label="io.dividia.host-dview.native.co${major}.sha256"
    sha=$(docker image inspect --format "{{index .Config.Labels \"$label\"}}" "$image" 2>/dev/null || true)
    [[ "$sha" != "<no value>" && "$sha" =~ ^[a-f0-9]{64}$ ]] || {
        echo "ERROR: image lacks a valid $label label" >&2
        return 1
    }
    echo "$sha"
}

host_display_sha256() {
    sha256sum "$1" | awk '{print $1}'
}

host_display_validate_payload() {
    local payload="$1" expected_native_sha="${2:-}" major lib host native java_bin java_version actual_native_sha
    major=$(host_display_os_major)
    [[ "$major" == "6" || "$major" == "7" ]] || return 1
    lib="$payload/lib"
    host="$payload/host"
    native="$host/native/ffmpeg-arch-x86_64-pc-linux-gnu-co${major}.jar"
    java_bin="$host/jre/bin/java"
    [[ -s "$lib/dview.jar" ]] || return 1
    [[ -s "$lib/logging-server.properties" ]] || return 1
    [[ -s "$lib/ffmpeg-noarch.jar" ]] || return 1
    [[ ! -e "$lib/ffmpeg-arch-x86_64-pc-linux-gnu-co6.jar" ]] || return 1
    [[ ! -e "$lib/ffmpeg-arch-x86_64-pc-linux-gnu-co7.jar" ]] || return 1
    [[ -s "$native" ]] || return 1
    [[ -f "$host/co6-java-ok" ]] || return 1
    if [[ -n "$expected_native_sha" ]]; then
        actual_native_sha=$(host_display_sha256 "$native") || return 1
        [[ "$actual_native_sha" == "$expected_native_sha" ]] || return 1
    fi
    grep -qx 'format=1' "$host/manifest.properties" || return 1
    grep -qx 'java=21' "$host/manifest.properties" || return 1
    [[ -x "$java_bin" ]] || return 1
    java_version=$("$java_bin" -version 2>&1 | head -1 || true)
    [[ "$java_version" == *'version "21.'* ]] || return 1
    unzip -tqq "$native" >/dev/null
    # The currently published legacy JAR predates cooperative cancellation
    # and the cloud-precache guard. Reject it rather than running a current
    # Java viewer against a stale JNI library (which can throw later during
    # playback shutdown). This also makes release publication verifiable on
    # the actual host without trusting a mutable CDN path.
    unzip -p "$native" libFFmpeg.so \
        | strings \
        | grep -qw 'Java_net_dividia_ffmpeg_FFmpeg_requestCancel'
    unzip -p "$native" libFFmpeg.so \
        | strings \
        | grep -qw 'Java_net_dividia_ffmpeg_FFmpeg_hasPrecache'
}

host_display_activate_payload() {
    local target="$1" previous="" link_tmp
    [[ -L "$HOST_DVIEW_ROOT/current" ]] && previous=$(readlink "$HOST_DVIEW_ROOT/current")
    link_tmp="$HOST_DVIEW_ROOT/.current.$$"
    ln -s "versions/$target" "$link_tmp"
    mv -Tf "$link_tmp" "$HOST_DVIEW_ROOT/current"
    HOST_DVIEW_PREVIOUS="$previous"
}

host_display_restore_previous() {
    local link_tmp
    if [[ -n "${HOST_DVIEW_PREVIOUS:-}" ]]; then
        link_tmp="$HOST_DVIEW_ROOT/.current.rollback.$$"
        ln -s "$HOST_DVIEW_PREVIOUS" "$link_tmp"
        mv -Tf "$link_tmp" "$HOST_DVIEW_ROOT/current"
    else
        rm -f "$HOST_DVIEW_ROOT/current"
    fi
}

# Pull the private runtime/JNI payload named by the exact locally pulled viewer
# image. The CO6/CO7 viewer container stays web-only; the separate OCI payload
# is downloaded only for a host that explicitly enabled local dview. The pair
# of immutable image IDs names the staged directory.
# Sets HOST_DVIEW_CHANGED=1 only after the active symlink changed.
host_display_stage_payload() {
    local image viewer_id host_ref host_id payload_id target_name target stage viewer_cid="" host_cid="" expected_native_sha host_native_sha
    local current_target cached_pair selected=0
    HOST_DVIEW_CHANGED=0
    HOST_DVIEW_PREVIOUS=""
    image=$(host_display_payload_image)
    [[ -n "$image" ]] || { echo "ERROR: viewer image is not configured" >&2; return 1; }
    viewer_id=$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || true)
    viewer_id=${viewer_id#sha256:}
    if [[ "$viewer_id" =~ ^[a-f0-9]{32,}$ ]]; then
        expected_native_sha=$(host_display_native_sha256_label "$image" "$(host_display_os_major)" 2>/dev/null || true)
    else
        expected_native_sha=""
    fi
    # A viewer config/image ID includes the payload label. If it has not
    # changed, neither has the immutable payload reference; avoid even pulling
    # the host artifact (and, critically, avoid restarting dview) on a no-op
    # `nvr update`.
    current_target=$(readlink "$HOST_DVIEW_ROOT/current" 2>/dev/null || true)
    if [[ -n "$expected_native_sha" && "$current_target" == "versions/${viewer_id}-"* ]] && \
       host_display_validate_payload "$HOST_DVIEW_ROOT/$current_target" "$expected_native_sha"; then
        return 0
    fi
    host_ref=$(host_display_payload_ref "$image" 2>/dev/null || true)
    if [[ -n "$expected_native_sha" && -n "$host_ref" ]] \
            && host_display_ensure_payload_image "$host_ref"; then
        host_native_sha=$(host_display_native_sha256_label "$host_ref" "$(host_display_os_major)" 2>/dev/null || true)
        if [[ "$host_native_sha" == "$expected_native_sha" ]]; then
            selected=1
            host_display_pin_payload_pair "$image" "$host_ref" || \
                echo "WARN: could not retain the current host display pair against image pruning" >&2
        fi
    fi
    if [[ $selected -eq 0 ]]; then
        cached_pair=$(host_display_cached_payload_pair 2>/dev/null || true)
        if [[ -z "$cached_pair" ]]; then
            echo "ERROR: no matching host display payload is cached and the current payload is unavailable" >&2
            return 1
        fi
        read -r image host_ref <<< "$cached_pair"
        viewer_id=$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || true)
        viewer_id=${viewer_id#sha256:}
        [[ "$viewer_id" =~ ^[a-f0-9]{32,}$ ]] || return 1
        expected_native_sha=$(host_display_native_sha256_label "$image" "$(host_display_os_major)") || return 1
        host_native_sha=$(host_display_native_sha256_label "$host_ref" "$(host_display_os_major)") || return 1
        [[ "$host_native_sha" == "$expected_native_sha" ]] || {
            echo "ERROR: cached viewer and host display payload do not match" >&2
            return 1
        }
        echo "WARN: using the prior cached host display pair because the current payload is unavailable" >&2
    fi
    host_id=$(docker image inspect --format '{{.Id}}' "$host_ref" 2>/dev/null || true)
    host_id=${host_id#sha256:}
    [[ "$host_id" =~ ^[a-f0-9]{32,}$ ]] || { echo "ERROR: cannot determine immutable host payload ID" >&2; return 1; }
    payload_id="${viewer_id}-${host_id}"

    mkdir -p "$HOST_DVIEW_ROOT/versions"
    target_name="$payload_id"
    target="$HOST_DVIEW_ROOT/versions/$target_name"
    if [[ -d "$target" ]] && host_display_validate_payload "$target" "$expected_native_sha"; then
        if [[ "$(readlink "$HOST_DVIEW_ROOT/current" 2>/dev/null || true)" == "versions/$payload_id" ]]; then
            return 0
        fi
        host_display_activate_payload "$payload_id"
        HOST_DVIEW_CHANGED=1
        return 0
    fi

    # Never replace an invalid directory in place: it may still be the active
    # payload of the running service. Stage a repaired sibling and switch the
    # symlink only after validation, preserving the previous bytes for the
    # required update rollback path.
    if [[ -e "$target" ]]; then
        target_name="${payload_id}.repair.$(date +%s).$$"
        target="$HOST_DVIEW_ROOT/versions/$target_name"
    fi

    stage=$(mktemp -d "$HOST_DVIEW_ROOT/.stage.XXXXXX") || return 1
    viewer_cid=$(docker create "$image" 2>/dev/null || true)
    host_cid=$(docker create "$host_ref" 2>/dev/null || true)
    if [[ -z "$viewer_cid" || -z "$host_cid" ]] || \
       ! docker cp "$viewer_cid:/usr/share/dview/lib" "$stage/lib" 2>/dev/null || \
       ! docker cp "$host_cid:/usr/share/nvr-dview-host" "$stage/host" 2>/dev/null; then
        [[ -n "$viewer_cid" ]] && docker rm "$viewer_cid" >/dev/null 2>&1 || true
        [[ -n "$host_cid" ]] && docker rm "$host_cid" >/dev/null 2>&1 || true
        rm -rf "$stage"
        echo "ERROR: unable to extract the matching host dview payload" >&2
        return 1
    fi
    docker rm "$viewer_cid" >/dev/null 2>&1 || true
    docker rm "$host_cid" >/dev/null 2>&1 || true
    if ! host_display_validate_payload "$stage" "$expected_native_sha"; then
        rm -rf "$stage"
        echo "ERROR: viewer/host image pair lacks a valid CO$(host_display_os_major) dview payload" >&2
        return 1
    fi
    mv "$stage" "$target"
    host_display_activate_payload "$target_name"
    HOST_DVIEW_CHANGED=1
}

host_display_refresh_after_update() {
    host_display_install_service || return 1
    host_display_stage_payload || return 1
    [[ "$HOST_DVIEW_CHANGED" -eq 1 ]] || return 0
    if host_display_enable_start_service; then
        return 0
    fi

    echo "WARN: new host dview payload did not stay running; restoring prior payload" >&2
    host_display_restore_previous
    host_display_enable_start_service >/dev/null 2>&1 || true
    return 1
}

cmd_display() {
    local action="${1:-status}"
    case "$action" in
        status)
            echo -e "${BLUE}=== Optional Host dview ===${NC}"
            if host_display_is_enabled; then
                echo "enabled: yes"
            else
                echo "enabled: no (default; CO6/CO7 viewer container remains web-only)"
            fi
            echo "eligible host: $(host_display_supported && echo yes || echo no)"
            echo "active payload: $(readlink "$HOST_DVIEW_ROOT/current" 2>/dev/null || echo none)"
            if host_display_service_active; then
                echo "service: running"
            else
                echo "service: stopped"
            fi
            ;;
        enable|disable|refresh)
            host_display_require_root "$action" || return 1
            take_lifecycle_lock || {
                echo "ERROR: cannot take lifecycle lock for display $action" >&2
                return 1
            }
            local status=0
            cmd_display_locked "$action" || status=$?
            release_lifecycle_lock
            return "$status"
            ;;
        *)
            echo "Usage: nvr display [enable|disable|status]" >&2
            return 1
            ;;
    esac
}

cmd_display_locked() {
    local action="$1"
    case "$action" in
        enable)
            host_display_preflight || return 1
            host_display_stage_payload || return 1
            host_display_install_service || return 1
            touch "$HOST_DVIEW_ENABLED"
            if ! host_display_enable_start_service; then
                echo "ERROR: host dview failed startup; leaving display disabled" >&2
                rm -f "$HOST_DVIEW_ENABLED"
                host_display_stop_disable_service
                if [[ "${HOST_DVIEW_CHANGED:-0}" -eq 1 ]]; then
                    host_display_restore_previous
                fi
                return 1
            fi
            echo "Host dview enabled. Future successful 'nvr update' runs will stage and restart the matching viewer payload."
            ;;
        disable)
            rm -f "$HOST_DVIEW_ENABLED"
            host_display_stop_disable_service
            echo "Host dview disabled; cached payloads were preserved."
            ;;
        # Internal handoff used by a non-root operator's `nvr update`.
        # Container updates are intentionally usable by the `dividia` user,
        # while payload activation writes /opt/dividia and systemd state.
        # Re-exec just this small privileged portion instead of making the
        # entire update (or its Docker commands) require root.
        refresh)
            host_display_is_enabled || return 0
            host_display_refresh_after_update
            ;;
    esac
}

validate_extracted_host_tool() {
    local dest="$1"
    local staged="$2"

    case "$dest" in
		"$INSTALL_DIR/nvr"|"$INSTALL_DIR/install-nvr.sh"|"$INSTALL_DIR/host-dview"|\
		"$INSTALL_DIR/nvr-db-engine-controller"|"$INSTALL_DIR/nvr-db-test-restore"|\
		"$INSTALL_DIR/nvr-db-auto-migrate")
            bash -n "$staged" >/dev/null 2>&1
            ;;
        "$INSTALL_DIR/removable-videostore-config.py")
            local python_cmd
            python_cmd=$(cloudapi_profile_python) || return 1
            "$python_cmd" -m py_compile "$staged" >/dev/null 2>&1
            ;;
        *)
            return 0
            ;;
    esac
}

# --- ADR-110 staged activation records --------------------------------------

activation_sha_file() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | awk '{print $1}'
    else
        shasum -a 256 "$1" | awk '{print $1}'
    fi
}

activation_sha_stream() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum | awk '{print $1}'
    else
        shasum -a 256 | awk '{print $1}'
    fi
}

activation_expected_uid() {
    if [[ $EUID -eq 0 ]]; then
        printf '%s\n' 0
    else
        id -u
    fi
}

activation_ensure_root() {
    local uid identity
    uid=$(activation_expected_uid) || return 1
    if [[ -L "$NVR_ACTIVATION_DIR" ]]; then
        echo "ERROR: activation state directory is a symlink: $NVR_ACTIVATION_DIR" >&2
        return 1
    fi
    if [[ ! -d "$NVR_ACTIVATION_DIR" ]]; then
        ( umask 077; mkdir -p "$NVR_ACTIVATION_DIR" ) || return 1
    fi
    chown "$uid" "$NVR_ACTIVATION_DIR" 2>/dev/null || true
    chmod 700 "$NVR_ACTIVATION_DIR" || return 1
    identity=$(mariadb_stat_identity "$NVR_ACTIVATION_DIR") || return 1
    case "$identity" in *:"$uid":700) ;; *) return 1 ;; esac
}

activation_file_identity_valid() { # <path> <mode>
    local path="$1" mode="$2" uid identity
    [[ -f "$path" && ! -L "$path" ]] || return 1
    uid=$(activation_expected_uid) || return 1
    identity=$(mariadb_stat_identity "$path") || return 1
    case "$identity" in *:"$uid":"$mode") return 0 ;; *) return 1 ;; esac
}

activation_value_token_safe() {
    [[ -n "$1" && "$1" != *$'\n'* && "$1" != *$'\r'* ]]
}

activation_digest_valid() {
    [[ "$1" =~ ^sha256:[0-9a-f]{64}$ ]]
}

activation_image_digest_ref_valid() {
    [[ "$1" =~ ^[A-Za-z0-9./_-]+(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$ ]]
}

activation_timestamp_valid() {
    [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
}

activation_manifest_reset() {
    AM_FORMAT_VERSION=""; AM_EDGE_ID=""; AM_MINIMUM_PROTOCOL=""
    AM_SOURCE_DATABASE_SERIES=""; AM_SOURCE_DATABASE_DIGESTS=""
    AM_SOURCE_DATABASE_VERSION=""; AM_TARGET_DATABASE_SERIES=""
    AM_TARGET_DATABASE_DIGEST=""; AM_COMPATIBILITY_COMPOSE_FILES=""
    AM_OVERLAY=""; AM_COMPOSE_BUNDLE_SHA256=""; AM_TARGET_BACKEND_REVISION=""
    AM_MIGRATION_GATE_VERSION=""; AM_BACKUP_POLICY=""; AM_HEALTH_CONTRACT=""
    AM_MANIFEST_SHA256=""
}

activation_manifest_read() { # [path]
    local path="${1:-$NVR_ACTIVATION_MANIFEST}" before after line key value lines=0 actual_hash
    activation_manifest_reset
    activation_file_identity_valid "$path" 444 || {
        echo "ERROR: activation manifest is missing, linked, or has unsafe ownership or mode" >&2
        return 1
    }
    before=$(mariadb_stat_identity "$path") || return 1
    while IFS= read -r line || [[ -n "$line" ]]; do
        lines=$((lines + 1))
        [[ "$line" == *=* && "$line" != *$'\r'* ]] || return 1
        key=${line%%=*}
        value=${line#*=}
        activation_value_token_safe "$value" || return 1
        case "$key" in
            format_version) [[ -z "$AM_FORMAT_VERSION" ]] || return 1; AM_FORMAT_VERSION="$value" ;;
            activation_id) [[ -z "$AM_EDGE_ID" ]] || return 1; AM_EDGE_ID="$value" ;;
            minimum_updater_protocol) [[ -z "$AM_MINIMUM_PROTOCOL" ]] || return 1; AM_MINIMUM_PROTOCOL="$value" ;;
            source_database_series) [[ -z "$AM_SOURCE_DATABASE_SERIES" ]] || return 1; AM_SOURCE_DATABASE_SERIES="$value" ;;
            source_database_image_digests) [[ -z "$AM_SOURCE_DATABASE_DIGESTS" ]] || return 1; AM_SOURCE_DATABASE_DIGESTS="$value" ;;
            source_database_live_version) [[ -z "$AM_SOURCE_DATABASE_VERSION" ]] || return 1; AM_SOURCE_DATABASE_VERSION="$value" ;;
            target_database_series) [[ -z "$AM_TARGET_DATABASE_SERIES" ]] || return 1; AM_TARGET_DATABASE_SERIES="$value" ;;
            target_database_image_digest) [[ -z "$AM_TARGET_DATABASE_DIGEST" ]] || return 1; AM_TARGET_DATABASE_DIGEST="$value" ;;
            compatibility_compose_files) [[ -z "$AM_COMPATIBILITY_COMPOSE_FILES" ]] || return 1; AM_COMPATIBILITY_COMPOSE_FILES="$value" ;;
            activation_overlay) [[ -z "$AM_OVERLAY" ]] || return 1; AM_OVERLAY="$value" ;;
            compose_bundle_sha256) [[ -z "$AM_COMPOSE_BUNDLE_SHA256" ]] || return 1; AM_COMPOSE_BUNDLE_SHA256="$value" ;;
            target_backend_revision) [[ -z "$AM_TARGET_BACKEND_REVISION" ]] || return 1; AM_TARGET_BACKEND_REVISION="$value" ;;
            database_migration_gate_version) [[ -z "$AM_MIGRATION_GATE_VERSION" ]] || return 1; AM_MIGRATION_GATE_VERSION="$value" ;;
            required_backup_policy) [[ -z "$AM_BACKUP_POLICY" ]] || return 1; AM_BACKUP_POLICY="$value" ;;
            post_activation_health_contract) [[ -z "$AM_HEALTH_CONTRACT" ]] || return 1; AM_HEALTH_CONTRACT="$value" ;;
            activation_manifest_sha256) [[ -z "$AM_MANIFEST_SHA256" ]] || return 1; AM_MANIFEST_SHA256="$value" ;;
            *) echo "ERROR: unknown activation manifest key: $key" >&2; return 1 ;;
        esac
    done < "$path"
    after=$(mariadb_stat_identity "$path") || return 1
    [[ "$before" == "$after" && "$lines" -eq 16 ]] || return 1

    [[ "$AM_FORMAT_VERSION" == 1 && "$AM_MINIMUM_PROTOCOL" =~ ^[0-9]+$ \
        && "$AM_MINIMUM_PROTOCOL" -le "$NVR_ACTIVATION_PROTOCOL" ]] || return 1
    [[ "$AM_EDGE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || return 1
    [[ "$AM_SOURCE_DATABASE_SERIES" == 10.5 && "$AM_SOURCE_DATABASE_VERSION" == 10.5 ]] || return 1
    [[ "$AM_TARGET_DATABASE_SERIES" == 10.11 ]] || return 1
    activation_image_digest_ref_valid "$AM_TARGET_DATABASE_DIGEST" || return 1
    [[ "$AM_SOURCE_DATABASE_DIGESTS" == "mariadb:10.5.29@sha256:a530aeeefd82f4fa5150f391b6c75462140904780338766f6b03acecb1cca3ce" ]] || return 1
    [[ "$AM_COMPATIBILITY_COMPOSE_FILES" == "docker-compose.yml,docker-compose.prod.yml" ]] || return 1
    [[ "$AM_OVERLAY" == "$NVR_ACTIVATION_OVERLAY" ]] || return 1
    [[ "$AM_TARGET_BACKEND_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ ]] || return 1
    [[ "$AM_MIGRATION_GATE_VERSION" =~ ^[1-9][0-9]*$ ]] || return 1
    [[ "$AM_BACKUP_POLICY" == automatic-update-v2 ]] || return 1
    [[ "$AM_HEALTH_CONTRACT" == core-recording-playback-v1 ]] || return 1
    [[ "$AM_COMPOSE_BUNDLE_SHA256" =~ ^[0-9a-f]{64}$ \
        && "$AM_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] || return 1
    actual_hash=$(sed '/^activation_manifest_sha256=/d' "$path" | activation_sha_stream) || return 1
    [[ "$actual_hash" == "$AM_MANIFEST_SHA256" ]] || {
        echo "ERROR: activation manifest hash mismatch" >&2
        return 1
    }
}

activation_manifest_bundle_hash() {
    local file
    for file in \
        "$INSTALL_DIR/docker-compose.yml" \
        "$INSTALL_DIR/docker-compose.prod.yml" \
        "$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY"; do
        [[ -f "$file" && ! -L "$file" ]] || return 1
        printf '%s=%s\n' "${file##*/}" "$(activation_sha_file "$file")"
    done | activation_sha_stream
}

activation_manifest_validate_bundle() {
    local actual
    actual=$(activation_manifest_bundle_hash) || return 1
    [[ "$actual" == "$AM_COMPOSE_BUNDLE_SHA256" ]] || {
        echo "ERROR: staged activation Compose bundle does not match its manifest" >&2
        return 1
    }
}

activation_compound_id() {
    [[ "$#" -eq 8 ]] || return 1
    printf '%s\n' \
        "source_backend_image_id=$1" \
        "target_backend_image_id=$2" \
        "source_database_image_id=$3" \
        "source_database_live_version=$4" \
        "target_database_digest=$5" \
        "compose_bundle_sha256=$6" \
        "migration_gate_version=$7" \
        "activation_manifest_sha256=$8" \
        | activation_sha_stream
}

activation_journal_reset() {
    AJ_FORMAT_VERSION=""; AJ_PROTOCOL_VERSION=""; AJ_STATE=""; AJ_ACTIVATION_ID=""
    AJ_EDGE_ID=""; AJ_MANIFEST_SHA256=""; AJ_SOURCE_BACKEND_ID=""
    AJ_SOURCE_BACKEND_REVISION=""; AJ_TARGET_BACKEND_ID=""; AJ_TARGET_BACKEND_REVISION=""
    AJ_SOURCE_DATABASE_ID=""; AJ_SOURCE_DATABASE_VERSION=""; AJ_TARGET_DATABASE_DIGEST=""
    AJ_COMPOSE_BUNDLE_SHA256=""; AJ_MIGRATION_GATE_VERSION=""; AJ_BACKUP_PATH=""
    AJ_BACKUP_BYTES=""; AJ_BACKUP_SHA256=""; AJ_FAILURE_COUNT=""
    AJ_FAILURE_FINGERPRINT=""; AJ_BLOCKED_REASON=""; AJ_CREATED_AT=""; AJ_UPDATED_AT=""
}

activation_journal_read() { # [path]
    local path="${1:-$NVR_ACTIVATION_JOURNAL}" before after line key value lines=0 expected
    activation_journal_reset
    activation_file_identity_valid "$path" 600 || return 1
    before=$(mariadb_stat_identity "$path") || return 1
    while IFS= read -r line || [[ -n "$line" ]]; do
        lines=$((lines + 1))
        [[ "$line" == *=* && "$line" != *$'\r'* ]] || return 1
        key=${line%%=*}; value=${line#*=}
        activation_value_token_safe "$value" || return 1
        case "$key" in
            format_version) [[ -z "$AJ_FORMAT_VERSION" ]] || return 1; AJ_FORMAT_VERSION="$value" ;;
            protocol_version) [[ -z "$AJ_PROTOCOL_VERSION" ]] || return 1; AJ_PROTOCOL_VERSION="$value" ;;
            state) [[ -z "$AJ_STATE" ]] || return 1; AJ_STATE="$value" ;;
            activation_id) [[ -z "$AJ_ACTIVATION_ID" ]] || return 1; AJ_ACTIVATION_ID="$value" ;;
            edge_id) [[ -z "$AJ_EDGE_ID" ]] || return 1; AJ_EDGE_ID="$value" ;;
            manifest_sha256) [[ -z "$AJ_MANIFEST_SHA256" ]] || return 1; AJ_MANIFEST_SHA256="$value" ;;
            source_backend_image_id) [[ -z "$AJ_SOURCE_BACKEND_ID" ]] || return 1; AJ_SOURCE_BACKEND_ID="$value" ;;
            source_backend_revision) [[ -z "$AJ_SOURCE_BACKEND_REVISION" ]] || return 1; AJ_SOURCE_BACKEND_REVISION="$value" ;;
            target_backend_image_id) [[ -z "$AJ_TARGET_BACKEND_ID" ]] || return 1; AJ_TARGET_BACKEND_ID="$value" ;;
            target_backend_revision) [[ -z "$AJ_TARGET_BACKEND_REVISION" ]] || return 1; AJ_TARGET_BACKEND_REVISION="$value" ;;
            source_database_image_id) [[ -z "$AJ_SOURCE_DATABASE_ID" ]] || return 1; AJ_SOURCE_DATABASE_ID="$value" ;;
            source_database_live_version) [[ -z "$AJ_SOURCE_DATABASE_VERSION" ]] || return 1; AJ_SOURCE_DATABASE_VERSION="$value" ;;
            target_database_digest) [[ -z "$AJ_TARGET_DATABASE_DIGEST" ]] || return 1; AJ_TARGET_DATABASE_DIGEST="$value" ;;
            compose_bundle_sha256) [[ -z "$AJ_COMPOSE_BUNDLE_SHA256" ]] || return 1; AJ_COMPOSE_BUNDLE_SHA256="$value" ;;
            migration_gate_version) [[ -z "$AJ_MIGRATION_GATE_VERSION" ]] || return 1; AJ_MIGRATION_GATE_VERSION="$value" ;;
            backup_path) [[ -z "$AJ_BACKUP_PATH" ]] || return 1; AJ_BACKUP_PATH="$value" ;;
            backup_bytes) [[ -z "$AJ_BACKUP_BYTES" ]] || return 1; AJ_BACKUP_BYTES="$value" ;;
            backup_sha256) [[ -z "$AJ_BACKUP_SHA256" ]] || return 1; AJ_BACKUP_SHA256="$value" ;;
            failure_count) [[ -z "$AJ_FAILURE_COUNT" ]] || return 1; AJ_FAILURE_COUNT="$value" ;;
            failure_fingerprint) [[ -z "$AJ_FAILURE_FINGERPRINT" ]] || return 1; AJ_FAILURE_FINGERPRINT="$value" ;;
            blocked_reason) [[ -z "$AJ_BLOCKED_REASON" ]] || return 1; AJ_BLOCKED_REASON="$value" ;;
            created_at) [[ -z "$AJ_CREATED_AT" ]] || return 1; AJ_CREATED_AT="$value" ;;
            updated_at) [[ -z "$AJ_UPDATED_AT" ]] || return 1; AJ_UPDATED_AT="$value" ;;
            *) return 1 ;;
        esac
    done < "$path"
    after=$(mariadb_stat_identity "$path") || return 1
    [[ "$before" == "$after" && "$lines" -eq 23 ]] || return 1

    [[ "$AJ_FORMAT_VERSION" == 1 && "$AJ_PROTOCOL_VERSION" == "$NVR_ACTIVATION_PROTOCOL" ]] || return 1
    case "$AJ_STATE" in staged|backup-in-progress|backup-verified|activating|verifying|committed|blocked|failed) ;; *) return 1 ;; esac
    [[ "$AJ_ACTIVATION_ID" =~ ^[0-9a-f]{64}$ && "$AJ_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ \
        && "$AJ_COMPOSE_BUNDLE_SHA256" =~ ^[0-9a-f]{64}$ ]] || return 1
    [[ "$AJ_EDGE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || return 1
    activation_digest_valid "$AJ_SOURCE_BACKEND_ID" || return 1
    activation_digest_valid "$AJ_TARGET_BACKEND_ID" || return 1
    activation_digest_valid "$AJ_SOURCE_DATABASE_ID" || return 1
    activation_image_digest_ref_valid "$AJ_TARGET_DATABASE_DIGEST" || return 1
    [[ "$AJ_SOURCE_BACKEND_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ \
        && "$AJ_TARGET_BACKEND_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ ]] || return 1
    [[ "$AJ_SOURCE_DATABASE_VERSION" =~ ^10\.5\.[0-9A-Za-z.+_-]+$ ]] || return 1
    [[ "$AJ_MIGRATION_GATE_VERSION" =~ ^[1-9][0-9]*$ \
        && "$AJ_FAILURE_COUNT" =~ ^[0-9]+$ ]] || return 1
    case "$AJ_BACKUP_PATH" in
        NONE|"$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
        *) return 1 ;;
    esac
    case "$AJ_BACKUP_BYTES" in 0|[1-9][0-9]*) ;; *) return 1 ;; esac
    case "$AJ_BACKUP_SHA256" in NONE) ;; *) [[ "$AJ_BACKUP_SHA256" =~ ^[0-9a-f]{64}$ ]] || return 1 ;; esac
    case "$AJ_FAILURE_FINGERPRINT" in NONE) ;; *) [[ "$AJ_FAILURE_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || return 1 ;; esac
    [[ "$AJ_BLOCKED_REASON" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$|^NONE$ ]] || return 1
    activation_timestamp_valid "$AJ_CREATED_AT" && activation_timestamp_valid "$AJ_UPDATED_AT" || return 1
    expected=$(activation_compound_id "$AJ_SOURCE_BACKEND_ID" "$AJ_TARGET_BACKEND_ID" \
        "$AJ_SOURCE_DATABASE_ID" "$AJ_SOURCE_DATABASE_VERSION" "$AJ_TARGET_DATABASE_DIGEST" \
        "$AJ_COMPOSE_BUNDLE_SHA256" "$AJ_MIGRATION_GATE_VERSION" "$AJ_MANIFEST_SHA256") || return 1
    [[ "$expected" == "$AJ_ACTIVATION_ID" ]] || return 1
}

activation_journal_write() {
    local temporary uid now
    activation_ensure_root || return 1
    uid=$(activation_expected_uid) || return 1
    now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    [[ -n "$AJ_CREATED_AT" ]] || AJ_CREATED_AT="$now"
    AJ_UPDATED_AT="$now"
    (
        temporary=$(mktemp "$NVR_ACTIVATION_DIR/.journal.new.XXXXXX") || exit 1
        trap 'rm -f "$temporary"' EXIT
        ( umask 077
          printf '%s\n' \
            "format_version=$AJ_FORMAT_VERSION" \
            "protocol_version=$AJ_PROTOCOL_VERSION" \
            "state=$AJ_STATE" \
            "activation_id=$AJ_ACTIVATION_ID" \
            "edge_id=$AJ_EDGE_ID" \
            "manifest_sha256=$AJ_MANIFEST_SHA256" \
            "source_backend_image_id=$AJ_SOURCE_BACKEND_ID" \
            "source_backend_revision=$AJ_SOURCE_BACKEND_REVISION" \
            "target_backend_image_id=$AJ_TARGET_BACKEND_ID" \
            "target_backend_revision=$AJ_TARGET_BACKEND_REVISION" \
            "source_database_image_id=$AJ_SOURCE_DATABASE_ID" \
            "source_database_live_version=$AJ_SOURCE_DATABASE_VERSION" \
            "target_database_digest=$AJ_TARGET_DATABASE_DIGEST" \
            "compose_bundle_sha256=$AJ_COMPOSE_BUNDLE_SHA256" \
            "migration_gate_version=$AJ_MIGRATION_GATE_VERSION" \
            "backup_path=$AJ_BACKUP_PATH" \
            "backup_bytes=$AJ_BACKUP_BYTES" \
            "backup_sha256=$AJ_BACKUP_SHA256" \
            "failure_count=$AJ_FAILURE_COUNT" \
            "failure_fingerprint=$AJ_FAILURE_FINGERPRINT" \
            "blocked_reason=$AJ_BLOCKED_REASON" \
            "created_at=$AJ_CREATED_AT" \
            "updated_at=$AJ_UPDATED_AT" > "$temporary"
        ) || exit 1
        chown "$uid" "$temporary" 2>/dev/null || true
        chmod 600 "$temporary" || exit 1
        activation_journal_read "$temporary" || exit 1
        sync
        mv -f "$temporary" "$NVR_ACTIVATION_JOURNAL" || exit 1
        sync
        trap - EXIT
    ) || return 1
    activation_journal_read "$NVR_ACTIVATION_JOURNAL"
}

activation_manifest_publish() { # <validated extracted file>
    local source="$1" temporary uid
    activation_ensure_root || return 1
    uid=$(activation_expected_uid) || return 1
    (
        temporary=$(mktemp "$NVR_ACTIVATION_DIR/.manifest.new.XXXXXX") || exit 1
        trap 'rm -f "$temporary"' EXIT
        cp "$source" "$temporary" || exit 1
        chown "$uid" "$temporary" 2>/dev/null || true
        chmod 444 "$temporary" || exit 1
        activation_manifest_read "$temporary" || exit 1
        sync
        mv -f "$temporary" "$NVR_ACTIVATION_MANIFEST" || exit 1
        sync
        trap - EXIT
    )
}

# --- MariaDB physical data-directory compatibility -------------------------
#
# MariaDB supports an in-place 10.5 to 10.11 upgrade, but the upgraded data
# directory must never start on 10.5 again. A separate root-owned guard bind
# keeps the marker outside the service-writable data directory. A pending
# marker raises the minimum runtime before 10.11 starts, closing the power-loss
# window between first startup and post-start checks.

mariadb_series_supported() {
    case "$1" in
        10.5|10.11) return 0 ;;
        *) return 1 ;;
    esac
}

mariadb_bundle_series_from_compose() {
    local compose_file="$1" series db_image
    [[ -f "$compose_file" ]] || {
        echo "ERROR: MariaDB bundle metadata is missing: $compose_file" >&2
        return 1
    }
    series=$(awk -F: '
        $1 == "x-dividia-mariadb-runtime-series" {
            value=$2
            gsub(/[[:space:]"]/, "", value)
            gsub(/\047/, "", value)
            found++
        }
        END {
            if (found == 0) print "LEGACY"
            else if (found == 1) print value
            else exit 2
        }
    ' "$compose_file") || {
        echo "ERROR: MariaDB bundle metadata is duplicated or unreadable" >&2
        return 1
    }
    db_image=$(awk '
        /^  db:/ { in_db=1; next }
        in_db && /^  [A-Za-z0-9_-]+:/ { exit }
        in_db && $1 == "image:" { print $2; exit }
    ' "$compose_file")
    case "$series:$db_image" in
        LEGACY:mariadb:10.5) series=10.5 ;;
        10.5:mariadb:10.5) ;;
        10.5:mariadb:10.5.29@sha256:a530aeeefd82f4fa5150f391b6c75462140904780338766f6b03acecb1cca3ce) ;;
        10.11:mariadb:10.11.*) ;;
        LEGACY:*)
            echo "ERROR: unmarked bundle is not the known legacy MariaDB 10.5 bundle" >&2
            return 1
            ;;
        *)
            echo "ERROR: MariaDB bundle metadata does not match image ${db_image:-missing}" >&2
            return 1
            ;;
    esac
    mariadb_series_supported "$series" || {
        echo "ERROR: unsupported MariaDB bundle series: ${series:-missing}" >&2
        return 1
    }
    printf '%s\n' "$series"
}

mariadb_bundle_series_from_container() {
    local cid="$1" staged series rc=0
    staged=$(mktemp /tmp/nvr-mariadb-compose.XXXXXX) || return 1
    if ! docker cp "$cid:/usr/share/nvr/compose/docker-compose.yml" - 2>/dev/null \
            | tar -xO > "$staged"; then
        echo "ERROR: target backend has no readable Compose bundle" >&2
        rm -f "$staged"
        return 1
    fi
    series=$(mariadb_bundle_series_from_compose "$staged") || rc=$?
    rm -f "$staged"
    [[ $rc -eq 0 ]] || return "$rc"
    printf '%s\n' "$series"
}

mariadb_container_file_extract() { # <container> <source> <destination>
    docker cp "$1:$2" - 2>/dev/null | tar -xO 2>/dev/null > "$3"
}

mariadb_activation_target_series_from_container() (
    local cid="$1" staged file
    staged=$(mktemp -d /tmp/nvr-mariadb-activation.XXXXXX) || exit 1
    local INSTALL_DIR="$staged"
    local NVR_ACTIVATION_MANIFEST="$staged/manifest"
    trap "rm -f '$staged'/*; rmdir '$staged'" EXIT
    trap 'exit 1' HUP INT TERM

    # A missing manifest means this is a legacy single-series bundle. Once a
    # manifest exists, require its complete signed contract and fail closed on
    # every partial or modified activation bundle.
    if ! mariadb_container_file_extract "$cid" \
            /usr/share/nvr/activation/staged-activation-10.5-to-10.11.manifest \
            "$NVR_ACTIVATION_MANIFEST"; then
        exit 2
    fi
    chmod 0444 "$NVR_ACTIVATION_MANIFEST" || exit 1

    for file in docker-compose.yml docker-compose.prod.yml "$NVR_ACTIVATION_OVERLAY"; do
        if ! mariadb_container_file_extract "$cid" \
                "/usr/share/nvr/compose/$file" "$INSTALL_DIR/$file"; then
            echo "ERROR: target backend has an incomplete staged activation bundle" >&2
            exit 1
        fi
    done

    if ! activation_manifest_read || ! activation_manifest_validate_bundle; then
        echo "ERROR: target backend has an invalid staged activation bundle" >&2
        exit 1
    fi
    printf '%s\n' "$AM_TARGET_DATABASE_SERIES"
)

mariadb_channel_target_series() {
    local channel="$1" registry image cid series activation_series rc=0 activation_rc=0
    registry=$(sed -n 's/^REGISTRY=//p' "$INSTALL_DIR/.env" 2>/dev/null | head -1)
    registry="${registry:-docker.io}"
    image="$registry/dividia/nvr-backend:$channel"
    echo "Checking MariaDB compatibility in $image..." >&2
    docker pull --quiet "$image" >/dev/null || {
        echo "ERROR: could not pull target backend for compatibility check" >&2
        return 1
    }
    cid=$(docker create "$image" 2>/dev/null) || {
        echo "ERROR: could not inspect target backend bundle" >&2
        return 1
    }
    series=$(mariadb_bundle_series_from_container "$cid") || rc=$?
    if [[ $rc -eq 0 && "$series" == 10.5 ]]; then
        activation_series=$(mariadb_activation_target_series_from_container "$cid") \
            || activation_rc=$?
        case "$activation_rc" in
            0) series="$activation_series" ;;
            2) ;;
            *) rc="$activation_rc" ;;
        esac
    fi
    docker rm "$cid" >/dev/null 2>&1 || true
    [[ $rc -eq 0 ]] || return "$rc"
    printf '%s\n' "$series"
}

mariadb_live_series() {
    local db_pass version series
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2-)
    version=$($COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" \
        -N -B -e 'SELECT VERSION();' 2>/dev/null) || return 1
    if [[ "$version" =~ ^([0-9]+)\.([0-9]+) ]]; then
        series="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
    else
        return 1
    fi
    mariadb_series_supported "$series" || return 1
    printf '%s\n' "$series"
}

mariadb_stat_identity() {
    stat -c '%d:%i:%u:%a' "$1" 2>/dev/null \
        || stat -f '%d:%i:%u:%Lp' "$1" 2>/dev/null
}

mariadb_expected_marker_uid() {
    local uid
    uid=$(id -u) || return 1
    if [[ "$uid" == 0 ]]; then
        printf '%s\n' 0
    else
        # Non-root callers cannot update the NVR. This branch permits the
        # disposable host contract to authenticate its own fixture.
        printf '%s\n' "$uid"
    fi
}

mariadb_marker_snapshot_value() {
    local snapshot="$1" key="$2"
    printf '%s\n' "$snapshot" | awk -v prefix="$key=" '
        index($0, prefix) == 1 { value=substr($0, length(prefix) + 1); found++ }
        END { if (found == 1) print value; else exit 1 }
    '
}

mariadb_marker_snapshot() {
    local before after expected_uid snapshot owner mode
    [[ -f "$MARIADB_DATADIR_MARKER" && ! -L "$MARIADB_DATADIR_MARKER" ]] || return 1
    before=$(mariadb_stat_identity "$MARIADB_DATADIR_MARKER") || return 1
    expected_uid=$(mariadb_expected_marker_uid) || return 1
    owner=$(printf '%s\n' "$before" | awk -F: '{ print $(NF-1) }')
    mode=$(printf '%s\n' "$before" | awk -F: '{ print $NF }')
    mode=${mode#0}
    [[ "$owner" == "$expected_uid" && "$mode" == 644 ]] || return 1
    snapshot=$(cat "$MARIADB_DATADIR_MARKER") || return 1
    printf '%s\n' "$snapshot" | LC_ALL=C awk -F= '
        BEGIN {
            keys="marker-version state observed-series minimum-runtime-series pre-upgrade-backup activation-id manifest-sha256"
            count=split(keys, required, " ")
            for (i=1; i<=count; i++) allowed[required[i]]=1
        }
        index($0, "\r") != 0 { bad=1; next }
        $0 !~ /^[A-Za-z][A-Za-z0-9_-]*=[^=\r\n]*$/ { bad=1; next }
        { key=$1; if (!(key in allowed) || seen[key]++) bad=1 }
        END {
            for (i=1; i<=count; i++) if (seen[required[i]] != 1) bad=1
            exit bad ? 1 : 0
        }
    ' || return 1
    after=$(mariadb_stat_identity "$MARIADB_DATADIR_MARKER") || return 1
    [[ "$before" == "$after" && ! -L "$MARIADB_DATADIR_MARKER" ]] || return 1
    printf '%s\n' "$snapshot"
}

mariadb_marker_value() {
    local key="$1" snapshot
    snapshot=$(mariadb_marker_snapshot) || return 1
    mariadb_marker_snapshot_value "$snapshot" "$key"
}

mariadb_marker_contract() {
    local snapshot marker_version state observed minimum backup activation_id manifest_sha
    snapshot=$(mariadb_marker_snapshot) || return 1
    marker_version=$(mariadb_marker_snapshot_value "$snapshot" marker-version) || return 1
    state=$(mariadb_marker_snapshot_value "$snapshot" state) || return 1
    observed=$(mariadb_marker_snapshot_value "$snapshot" observed-series) || return 1
    minimum=$(mariadb_marker_snapshot_value "$snapshot" minimum-runtime-series) || return 1
    backup=$(mariadb_marker_snapshot_value "$snapshot" pre-upgrade-backup) || return 1
    activation_id=$(mariadb_marker_snapshot_value "$snapshot" activation-id) || return 1
    manifest_sha=$(mariadb_marker_snapshot_value "$snapshot" manifest-sha256) || return 1
    [[ "$marker_version" == 2 && -n "$backup" ]] || return 1
    mariadb_series_supported "$observed" || return 1
    mariadb_series_supported "$minimum" || return 1
    case "$state:$observed:$minimum:$backup:$activation_id:$manifest_sha" in
        ready:10.5:10.5:NONE:NONE:NONE) ;;
        pending:10.5:10.11:*|ready:10.11:10.11:*)
            case "$backup" in
                "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
                *) return 1 ;;
            esac
            [[ "$activation_id" =~ ^[0-9a-f]{64}$ && "$manifest_sha" =~ ^[0-9a-f]{64}$ ]] || return 1
            ;;
        *) return 1 ;;
    esac
    printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$minimum" "$state" "$observed" "$backup" "$activation_id" "$manifest_sha"
}

mariadb_marker_minimum_series() {
    mariadb_marker_contract | awk -F '\t' '{ print $1 }'
}

mariadb_datadir_is_empty() {
    local datadir entry
    datadir="$MARIADB_DATADIR"
    [[ -d "$datadir" ]] || return 0
    for entry in "$datadir"/* "$datadir"/.[!.]* "$datadir"/..?*; do
        [[ -e "$entry" || -L "$entry" ]] && return 1
    done
    return 0
}

mariadb_effective_minimum_series() {
    local marker_minimum="" marker_state="" live="" contract
    if [[ -e "$MARIADB_DATADIR_MARKER" || -L "$MARIADB_DATADIR_MARKER" ]]; then
        contract=$(mariadb_marker_contract) || {
            echo "ERROR: MariaDB data-directory marker is invalid: $MARIADB_DATADIR_MARKER" >&2
            return 1
        }
        marker_minimum=$(printf '%s\n' "$contract" | awk -F '\t' '{ print $1 }')
        marker_state=$(printf '%s\n' "$contract" | awk -F '\t' '{ print $2 }')
    fi
    live=$(mariadb_live_series 2>/dev/null) || true
    if [[ "$marker_state" == ready && "$marker_minimum" == 10.11 && "$live" == 10.5 ]]; then
        echo "ERROR: running MariaDB 10.5 is below the data-directory minimum 10.11" >&2
        return 1
    fi
    if [[ -z "$marker_minimum" && -z "$live" ]] && mariadb_datadir_is_empty; then
        printf '%s\n' EMPTY
        return 0
    fi
    [[ -n "$marker_minimum" || -n "$live" ]] || {
        echo "ERROR: cannot determine the MariaDB data-directory compatibility" >&2
        return 1
    }
    if [[ "$marker_minimum" == 10.11 || "$live" == 10.11 ]]; then
        printf '%s\n' 10.11
    else
        printf '%s\n' 10.5
    fi
}

mariadb_assert_target_compatible() {
    local target="$1" minimum
    mariadb_series_supported "$target" || {
        echo "ERROR: unsupported target MariaDB series: ${target:-missing}" >&2
        return 1
    }
    minimum=$(mariadb_effective_minimum_series) || return 1
    case "$minimum:$target" in
        EMPTY:10.5|EMPTY:10.11|10.5:10.5|10.5:10.11|10.11:10.11) return 0 ;;
    esac
    echo "ERROR: MariaDB $target cannot safely open this data directory; minimum runtime is $minimum" >&2
    echo "ERROR: restore the pre-upgrade logical backup into a clean $target data directory instead" >&2
    return 1
}

mariadb_sync_file_and_parent() {
    local path="$1" parent
    parent=$(dirname "$path")
    # GNU sync supports -f on current hosts. CentOS 6 does not, so fall back
    # to its global sync after either per-path flush is unavailable.
    if sync -f "$path" >/dev/null 2>&1 \
            && sync -f "$parent" >/dev/null 2>&1; then
        return 0
    fi
    sync
}

mariadb_write_datadir_marker() {
    local state="$1" observed="$2" minimum="$3" backup="$4"
    local activation_id="${5:-NONE}" manifest_sha="${6:-NONE}"
    local marker_dir marker_real datadir_real expected_uid identity owner mode
    case "$state:$observed:$minimum" in
        ready:10.5:10.5|ready:10.11:10.11|pending:10.5:10.11) ;;
        *) echo "ERROR: invalid MariaDB marker transition" >&2; return 1 ;;
    esac
    [[ -n "$backup" && "$backup" != *$'\n'* && "$backup" != *$'\t'* ]] || return 1
    case "$state:$observed:$minimum:$backup:$activation_id:$manifest_sha" in
        ready:10.5:10.5:NONE:NONE:NONE) ;;
        pending:10.5:10.11:*|ready:10.11:10.11:*)
            case "$backup" in
                "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
                *) return 1 ;;
            esac
            [[ "$activation_id" =~ ^[0-9a-f]{64}$ && "$manifest_sha" =~ ^[0-9a-f]{64}$ ]] || return 1
            ;;
        *) return 1 ;;
    esac
    marker_dir=$(dirname "$MARIADB_DATADIR_MARKER")
    [[ "$marker_dir" != "$MARIADB_DATADIR" && "$marker_dir" != "$MARIADB_DATADIR"/* ]] || {
        echo "ERROR: MariaDB marker directory must be outside the service-writable datadir" >&2
        return 1
    }
    [[ ! -L "$marker_dir" ]] || return 1
    mkdir -p "$marker_dir" || return 1
    chmod 700 "$marker_dir" || return 1
    marker_real=$(cd "$marker_dir" && pwd -P) || return 1
    datadir_real=$(cd "$MARIADB_DATADIR" && pwd -P) || return 1
    [[ "$marker_real" != "$datadir_real" && "$marker_real" != "$datadir_real"/* ]] || return 1
    identity=$(mariadb_stat_identity "$marker_dir") || return 1
    expected_uid=$(mariadb_expected_marker_uid) || return 1
    owner=$(printf '%s\n' "$identity" | awk -F: '{ print $(NF-1) }')
    mode=$(printf '%s\n' "$identity" | awk -F: '{ print $NF }')
    mode=${mode#0}
    [[ "$owner" == "$expected_uid" && "$mode" == 700 ]] || return 1
    (
        umask 077
        tmp=$(mktemp "$marker_dir/.dividia-mariadb-version.new.XXXXXX") || exit 1
        trap 'rm -f "$tmp"' EXIT HUP INT TERM
        {
            echo 'marker-version=2'
            echo "state=$state"
            echo "observed-series=$observed"
            echo "minimum-runtime-series=$minimum"
            echo "pre-upgrade-backup=$backup"
            echo "activation-id=$activation_id"
            echo "manifest-sha256=$manifest_sha"
        } > "$tmp"
        chmod 644 "$tmp"
        mv -f "$tmp" "$MARIADB_DATADIR_MARKER"
        mariadb_sync_file_and_parent "$MARIADB_DATADIR_MARKER" || exit 1
        trap - EXIT HUP INT TERM
    )
}

mariadb_prepare_runtime_transition() {
    local target="$1" live backup="NONE" activation_id="NONE" manifest_sha="NONE"
    live=$(mariadb_live_series) || {
        echo "ERROR: running MariaDB version is unavailable; refusing database image transition" >&2
        return 1
    }
    mariadb_assert_target_compatible "$target" || return 1
    case "$live:$target" in
        10.5:10.5)
            mariadb_write_datadir_marker ready 10.5 10.5 NONE
            ;;
        10.5:10.11)
            # The installed updater creates and verifies the generic pending
            # journal. The target 10.11 entrypoint consumes that journal before
            # it opens the existing data directory.
            return 0
            ;;
        10.11:10.11)
            [[ -e "$MARIADB_DATADIR_MARKER" ]] || {
                echo "ERROR: MariaDB 10.11 data directory has no version marker" >&2
                return 1
            }
            backup=$(mariadb_marker_value pre-upgrade-backup) || return 1
            activation_id=$(mariadb_marker_value activation-id) || return 1
            manifest_sha=$(mariadb_marker_value manifest-sha256) || return 1
            mariadb_write_datadir_marker ready 10.11 10.11 "$backup" "$activation_id" "$manifest_sha"
            ;;
        *)
            echo "ERROR: unsafe MariaDB runtime transition: $live to $target" >&2
            return 1
            ;;
    esac
}

mariadb_finalize_runtime_transition() {
    local target="$1" live backup="" activation_id="NONE" manifest_sha="NONE"
    live=$(mariadb_live_series) || {
        echo "ERROR: updated MariaDB version is unavailable" >&2
        return 1
    }
    [[ "$live" == "$target" ]] || {
        echo "ERROR: updated MariaDB is $live, expected $target" >&2
        return 1
    }
    mariadb_assert_target_compatible "$target" || return 1
    if [[ -e "$MARIADB_DATADIR_MARKER" ]]; then
        backup=$(mariadb_marker_value pre-upgrade-backup) || return 1
        activation_id=$(mariadb_marker_value activation-id) || return 1
        manifest_sha=$(mariadb_marker_value manifest-sha256) || return 1
    fi
    [[ -n "$backup" ]] || {
        echo "ERROR: MariaDB transition marker has no verified recovery backup" >&2
        return 1
    }
    mariadb_write_datadir_marker ready "$target" "$target" "$backup" "$activation_id" "$manifest_sha"
}

# --- Core-stack convergence (the update self-heal loop) --------------------
#
# WHY THIS EXISTS. BCC fleet 2026-08-05: cs1686 + cs1129 finished `nvr update`
# with the recording pipeline DOWN and cron still reported success (exit 0).
# Root cause: a single `compose up -d`. Backend, recreated on a freshly pulled
# image, warms up (DB connect + XML-RPC on :43204 + VideoStore mounts) and its
# healthcheck transiently reads `unhealthy`. The moment it does, compose aborts
# the whole bring-up with "dependency failed to start: container backend is
# unhealthy" (every recording service has `depends_on: backend
# service_healthy`) and leaves engine/connector/viewer/playback in `Created`.
# Backend self-recovers ~30s later, but nothing re-runs the `up`, so the box
# sits not-recording for up to 24h until the next daily cron.
#
# This was tuned open-loop four times before (flock the detached docker-start
# `up`, --force-recreate, --remove-orphans, repair_untracked_compose_containers)
# — each tried to make one `up` win a timing window. None added a POST-CONDITION.
# The durable fix is a closed loop: re-apply the desired state (idempotent
# `up -d`), then VERIFY every core service is actually running, retrying with
# backoff until a deadline. A transient backend-unhealthy no longer strands the
# stack, because the next pass re-runs `up` once backend has settled.
#
# Portability: sh/bash on CO6..Ubuntu24. Uses only docker inspect + compose ps.
core_stack_converged() {
    local svc cid running health
    for svc in $1; do
        cid=$($COMPOSE ps -q "$svc" 2>/dev/null | head -1)
        # `ps -q` lists only running containers under Compose v2, so a service
        # stranded in `created` (the exact failure this loop fixes) yields no
        # id here -> not converged. That is correct for this gate; the report
        # path below uses `-aq` so it can still name a stranded service.
        [[ -n "$cid" ]] || return 1
        running=$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null)
        [[ "$running" == "true" ]] || return 1
        # Every core service must be RUNNING. Services with required health must
        # additionally be `healthy` (not `starting`, not `unhealthy`): backend
        # (the dependency gate everything waits on), db, engine (mpengine
        # capture), connector (recorder), PTZD, and remoted. Remoted health is
        # required before update removes its legacy RPM fallback. We deliberately
        # do NOT require viewer/playback to be healthy -- only running -- so a viewing-side
        # healthcheck quirk (e.g. a headless dview) cannot wedge `nvr update`
        # into a nightly `exit 1`. Their health is the fleet monitor's job.
        case "$svc" in
            remoted)
                health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null)
                [[ "$health" == "healthy" ]] || return 1
                ;;
            backend|db|engine|connector|ptz)
                health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null)
                [[ "$health" == "none" || "$health" == "healthy" ]] || return 1
                ;;
        esac
    done
    return 0
}

core_stack_report() {
    local svc cid
    for svc in $1; do
        # `-aq` (not `-q`): the whole point of the report is to name services
        # stranded in `created`/`exited`, which Compose v2's `ps -q` omits.
        cid=$($COMPOSE ps -aq "$svc" 2>/dev/null | head -1)
        if [[ -z "$cid" ]]; then
            echo "  $svc: NO CONTAINER"
            continue
        fi
        docker inspect -f "  $svc: running={{.State.Running}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}" "$cid" 2>/dev/null \
            || echo "  $svc: inspect failed"
    done
}

# Re-apply desired state, then verify, retrying until every core service is up
# or the deadline passes. Returns 0 only when the whole core stack is running
# (and healthy where a healthcheck is defined). Never aborts the caller: a
# failed `up` attempt is swallowed and retried, because the abort itself is the
# condition we are converging past.
converge_core_stack() {
    local core_svcs="$1"
    local timeout="${NVR_UPDATE_CONVERGE_TIMEOUT:-360}"
    local interval="${NVR_UPDATE_CONVERGE_INTERVAL:-10}"
    local start now attempt=0
    start=$(date +%s)
    while :; do
        attempt=$((attempt + 1))
        # Idempotent: a no-op for services already up, and (once backend is
        # healthy) the step that finally starts any stranded dependent. When
        # backend is still unhealthy this returns non-zero fast; we retry.
        # Intentional word split of the service list.
        # shellcheck disable=SC2086
        $COMPOSE up -d --remove-orphans $core_svcs >/dev/null 2>&1 || true
        if core_stack_converged "$core_svcs"; then
            echo "Core stack converged after ${attempt} attempt(s)."
            return 0
        fi
        now=$(date +%s)
        if [[ $((now - start)) -ge $timeout ]]; then
            echo "ERROR: core stack did not converge within ${timeout}s (${attempt} attempts)." >&2
            core_stack_report "$core_svcs" >&2
            return 1
        fi
        sleep "$interval"
    done
}

ensure_preupdate_source_backend() {
    if compose_service_is_running backend && compose_service_is_running db; then
        return 0
    fi

    # A prior failed pull can already point the mutable Compose tags at the
    # target. Recover missing services only through the immutable image IDs
    # captured before that pull. This also covers `nvr stop`, which removes the
    # source containers after saving their identities.
    echo "Starting the installed database and backend before target image pulls..."
    if ! update_source_state_start_missing; then
        echo "ERROR: cannot start the installed database and backend from saved source images" >&2
        return 1
    fi
    if ! compose_service_is_running backend || ! compose_service_is_running db; then
        echo "ERROR: installed database and backend did not start before target image pulls" >&2
        return 1
    fi
}

lpr_templates_copy_from_container() {
    local cid="$1" dest="$2"
    rm -f "$dest"
    docker cp "$cid:/usr/local/etc/lpr_templates" "$dest" 2>/dev/null \
		&& [[ -f "$dest" ]]
}

lpr_templates_mark_refresh_required() {
    local marker="$LPR_TEMPLATES_REFRESH_MARKER"
    local tmp="${marker}.new.$$"
    mkdir -p "$(dirname "$marker")" || return 1
    [[ ! -L "$marker" ]] || return 1
    rm -f "$tmp" || return 1
    ( umask 077; : > "$tmp" ) || return 1
    mv -f "$tmp" "$marker" || return 1
    sync || return 1
}

# Atomically replace the host file. A file bind pins the old inode in every
# running container, so publish a durable refresh marker before replacing an
# existing path. Lifecycle paths recreate both consumers before clearing it.
lpr_templates_publish() {
    local source="$1" dest="$2"
    if [[ -f "$dest" && ! -L "$dest" ]] && cmp -s "$source" "$dest"; then
        rm -f "$source"
        return 0
    fi
    if [[ -e "$dest" || -L "$dest" ]]; then
        lpr_templates_mark_refresh_required || return 1
    fi
    mv -f "$source" "$dest" || return 1
    sync || return 1
}

lpr_templates_quarantine_invalid_source() {
    local dest="$1" quarantine
    [[ -d "$dest" ]] || return 0
    quarantine="${dest}.invalid-dir.$(date +%s).$$"
    lpr_templates_mark_refresh_required || return 1
    mv "$dest" "$quarantine" || return 1
    echo "WARN: moved invalid LPR template directory to $quarantine" >&2
}

# Find every prior container for a service. Include stopped and unlabeled
# canonical containers because field repairs and old Watchtower versions can
# remove Compose labels. The caller compares every distinct writable layer.
lpr_templates_find_container() {
    local service="$1" labeled_cids named_cids candidates="" candidate
    labeled_cids=$(docker ps -aq \
        --filter label=com.docker.compose.project=dividia-nvr \
        --filter label=com.docker.compose.service="$service" 2>/dev/null) || return 1
    named_cids=$(docker ps -a \
        --filter name="dividia-nvr-${service}-" \
        --format '{{.ID}}|{{.Names}}' 2>/dev/null) || return 1
    named_cids=$(printf '%s\n' "$named_cids" \
        | awk -F'|' -v service="$service" \
            '$2 ~ ("^dividia-nvr-" service "-[0-9]+$") { print $1 }')
    for candidate in $labeled_cids $named_cids; do
        case " $candidates " in
            *" $candidate "*) ;;
            *) candidates="${candidates:+$candidates }$candidate" ;;
        esac
    done
    printf '%s\n' "$candidates"
}

# Return the current Compose list without the intentionally missing customer-
# asset guard. This is read-only. It lets a later helper inspect the selected
# connector image and repair the state that caused the guard to be installed.
customer_assets_unblocked_compose_files() {
    local current part rebuilt=""
    local -a parts=()
    current=$(compose_file_get)
    [[ -n "$current" ]] || return 1
    IFS=':' read -r -a parts <<< "$current"
    for part in "${parts[@]}"; do
        [[ -n "$part" && "$part" != "$CUSTOMER_ASSETS_BLOCK_OVERLAY" ]] || continue
        rebuilt="${rebuilt:+$rebuilt:}$part"
    done
    [[ -n "$rebuilt" ]] || return 1
    printf '%s\n' "$rebuilt"
}

# The RPM package treats lpr_templates as config(noreplace). Docker must compare
# the persistent copy and BOTH consumers against each consumer's installed stock
# image. One distinct customer value wins. Two distinct customer values fail
# closed so an operator can reconcile them without silent data loss.
lpr_templates_ensure_persistent() {
    local target_compose_files="$1"
    local dest="$INSTALL_DIR/data/config/lpr_templates"
    local tmp_current="${dest}.current.$$"
    local tmp_persistent="${dest}.persistent.$$"
    local tmp_target="${dest}.target.$$"
    local tmp_live_connector="${dest}.connector.live.$$"
    local tmp_stock_connector="${dest}.connector.stock.$$"
    local tmp_live_engine="${dest}.engine.live.$$"
    local tmp_stock_engine="${dest}.engine.stock.$$"
    local persistent_present=0 persistent_is_stock=0
    local customer_present=0 stock_present=0
    local service cids cid current_image stock_cid live stock target_image target_cid
    local services="connector engine"
    if [[ "$INSTALL_DIR" != "/opt/dividia" \
            && -n "${NVR_TEST_LPR_TEMPLATE_SERVICES:-}" ]]; then
        services="$NVR_TEST_LPR_TEMPLATE_SERVICES"
    fi

    lpr_templates_cleanup() {
        rm -f "$tmp_current" "$tmp_persistent" "$tmp_target" \
            "$tmp_live_connector" "$tmp_stock_connector" \
            "$tmp_live_engine" "$tmp_stock_engine"
    }
    lpr_templates_add_customer_candidate() {
        local source="$1" label="$2"
        if [[ $customer_present -eq 0 ]]; then
            cp -p "$source" "$tmp_current" || return 1
            customer_present=1
        elif ! cmp -s "$tmp_current" "$source"; then
            echo "ERROR: conflicting customer LPR templates include $label" >&2
            return 1
        fi
    }

    mkdir -p "$INSTALL_DIR/data/config" || return 1
    lpr_templates_quarantine_invalid_source "$dest" || return 1
    lpr_templates_cleanup
    if [[ -f "$dest" ]]; then
        cp -p "$dest" "$tmp_persistent" || { lpr_templates_cleanup; return 1; }
        persistent_present=1
    fi

    for service in $services; do
        cids=$(lpr_templates_find_container "$service") || {
            lpr_templates_cleanup
            echo "ERROR: cannot inspect prior $service containers for LPR templates" >&2
            return 1
        }
        [[ -n "$cids" ]] || continue
        for cid in $cids; do
            if [[ "$service" == "connector" ]]; then
                live="$tmp_live_connector"
                stock="$tmp_stock_connector"
            else
                live="$tmp_live_engine"
                stock="$tmp_stock_engine"
            fi
            lpr_templates_copy_from_container "$cid" "$live" || {
                lpr_templates_cleanup
                echo "ERROR: cannot copy LPR templates from a prior $service container" >&2
                return 1
            }
            current_image=$(docker inspect -f '{{.Image}}' "$cid" 2>/dev/null) || {
                lpr_templates_cleanup
                echo "ERROR: cannot inspect a prior $service image" >&2
                return 1
            }
            [[ -n "$current_image" ]] || { lpr_templates_cleanup; return 1; }
            stock_cid=$(docker create "$current_image" 2>/dev/null) || {
                lpr_templates_cleanup
                echo "ERROR: cannot create a stock $service comparison container" >&2
                return 1
            }
            if ! lpr_templates_copy_from_container "$stock_cid" "$stock"; then
                docker rm "$stock_cid" >/dev/null 2>&1 || true
                lpr_templates_cleanup
                echo "ERROR: cannot copy stock LPR templates from a prior $service image" >&2
                return 1
            fi
            docker rm "$stock_cid" >/dev/null 2>&1 || true
            stock_present=1
            if [[ $persistent_present -eq 1 ]] \
                    && cmp -s "$tmp_persistent" "$stock"; then
                persistent_is_stock=1
            fi
            if ! cmp -s "$live" "$stock"; then
                lpr_templates_add_customer_candidate "$live" \
                    "$service container $cid writable layer" || {
                    lpr_templates_cleanup
                    return 1
                }
            fi
        done
    done

    if [[ $persistent_present -eq 1 ]]; then
        if [[ $persistent_is_stock -eq 0 ]]; then
            lpr_templates_add_customer_candidate "$tmp_persistent" "persistent file" || {
                lpr_templates_cleanup
                return 1
            }
        fi
    fi

    if [[ $customer_present -eq 1 ]]; then
        chmod 0644 "$tmp_current" || { lpr_templates_cleanup; return 1; }
        chown root:root "$tmp_current" 2>/dev/null || true
        lpr_templates_publish "$tmp_current" "$dest" \
            || { lpr_templates_cleanup; return 1; }
        lpr_templates_cleanup
        echo "Preserved customized LPR templates."
        return 0
    fi

    # A persistent file with no prior container stock cannot be classified.
    # Preserve it as customer data instead of replacing it with target defaults.
    if [[ $persistent_present -eq 1 && $stock_present -eq 0 ]]; then
        lpr_templates_cleanup
        echo "Kept persistent LPR templates because prior stock is unavailable."
        return 0
    fi

    # A failed first-update handoff appends an intentionally missing Compose
    # file. Do not let that fail-closed guard block the preservation helper
    # from resolving stock and removing the guard on its next successful run.
    if [[ -z "$target_compose_files" ]] && customer_assets_block_is_active; then
        target_compose_files=$(customer_assets_unblocked_compose_files) || {
            lpr_templates_cleanup
            echo "ERROR: cannot recover the Compose list behind the customer asset guard" >&2
            return 1
        }
    fi

    if [[ -n "$target_compose_files" ]]; then
        target_image=$(COMPOSE_FILE="$target_compose_files" $COMPOSE config --images 2>/dev/null \
            | awk '/(^|\/)nvr-connector:/{ print; exit }')
    else
        target_image=$($COMPOSE config --images 2>/dev/null \
            | awk '/(^|\/)nvr-connector:/{ print; exit }')
    fi
    if [[ -z "$target_image" ]]; then
        lpr_templates_cleanup
        echo "ERROR: connector image is not configured; cannot seed LPR templates" >&2
        return 1
    fi
    target_cid=$(docker create "$target_image" 2>/dev/null) || {
        lpr_templates_cleanup
        echo "ERROR: cannot inspect target connector image: $target_image" >&2
        return 1
    }
    if ! lpr_templates_copy_from_container "$target_cid" "$tmp_target" \
				|| [[ ! -s "$tmp_target" ]]; then
        docker rm "$target_cid" >/dev/null 2>&1 || true
        lpr_templates_cleanup
        echo "ERROR: target connector image has no valid LPR template file" >&2
        return 1
    fi
    docker rm "$target_cid" >/dev/null 2>&1 || true
    if ! chmod 0644 "$tmp_target"; then
        lpr_templates_cleanup
        return 1
    fi
    chown root:root "$tmp_target" 2>/dev/null || true
    lpr_templates_publish "$tmp_target" "$dest" \
        || { lpr_templates_cleanup; return 1; }
    lpr_templates_cleanup
    echo "Seeded persistent LPR templates from the target connector image."
}

lpr_templates_refresh_consumers_if_needed() {
    local marker="$LPR_TEMPLATES_REFRESH_MARKER"
    LPR_TEMPLATES_REFRESHED=0
    if [[ ! -e "$marker" && ! -L "$marker" ]]; then
        return 0
    fi
    [[ -f "$marker" && ! -L "$marker" ]] || {
        echo "ERROR: invalid LPR template refresh marker: $marker" >&2
        return 1
    }
    # Recreate instead of restart. Compose then installs the current bind source
    # and current image/config together. Keep the marker if either consumer
    # cannot be recreated so the next lifecycle run retries.
    $COMPOSE up -d --force-recreate engine connector || return 1
    rm -f "$marker" || return 1
    sync || return 1
    LPR_TEMPLATES_REFRESHED=1
    echo "Recreated LPR consumers after the persistent template inode changed."
}

lpr_templates_overlay_write() {
    local dest="$INSTALL_DIR/$LPR_TEMPLATES_OVERLAY"
    local tmp="${dest}.new.$$"
    cat > "$tmp" <<'EOF'
services:
  engine:
    volumes:
      - type: bind
        source: ./data/config/lpr_templates
        target: /usr/local/etc/lpr_templates
        read_only: true
  connector:
    volumes:
      - type: bind
        source: ./data/config/lpr_templates
        target: /usr/local/etc/lpr_templates
        read_only: true
EOF
    chmod 0644 "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$dest"
}

lpr_templates_overlay_enable() {
    local current rebuilt="" part boot="" aiengine=""
    current=$(compose_file_get)
    [[ -n "$current" ]] || {
        echo "ERROR: COMPOSE_FILE is unavailable; cannot enable persistent LPR templates" >&2
        return 1
    }
    local IFS=':'
    for part in $current; do
        [[ -n "$part" ]] || continue
        if [[ "$part" == "$LPR_TEMPLATES_OVERLAY" \
                || "$part" == "$CUSTOMER_ASSETS_BLOCK_OVERLAY" ]]; then
            continue
        fi
        if [[ "$part" == "$BOOT_STORAGE_COMPOSE_OVERLAY" ]]; then
            boot="$part"
            continue
        fi
        if [[ "$part" == "$AIENGINE_OVERLAY" ]]; then
            aiengine="$part"
            continue
        fi
        rebuilt="${rebuilt:+$rebuilt:}$part"
    done
    rebuilt="${rebuilt:+$rebuilt:}$LPR_TEMPLATES_OVERLAY"
    [[ -n "$aiengine" ]] && rebuilt="$rebuilt:$aiengine"
    [[ -n "$boot" ]] && rebuilt="$rebuilt:$boot"
    [[ "$rebuilt" == "$current" ]] && return 0
    env_write_var COMPOSE_FILE "$rebuilt"
}

customer_assets_block_is_active() {
    compose_file_get | tr ':' '\n' | grep -qxF "$CUSTOMER_ASSETS_BLOCK_OVERLAY"
}

# An old updater keeps executing after it extracts a new installer. If the new
# customer-data handoff fails, append an intentionally missing Compose file.
# Every later Compose command then fails before it can replace the only old
# connector or engine writable layer. A new updater removes this token only
# after preservation succeeds.
customer_assets_block_enable() {
    local current
    current=$(compose_file_get)
    [[ -n "$current" ]] || return 1
    customer_assets_block_is_active && return 0
    env_write_var COMPOSE_FILE "$current:$CUSTOMER_ASSETS_BLOCK_OVERLAY"
}

# This command is also the first-update compatibility hook. The old updater
# continues in memory after it extracts a new nvr script. It does, however,
# source the newly extracted installer to refresh the boot service. That
# installer calls this new on-disk command before the first Compose recreate.
prepare_customer_assets_for_compose() {
    local prefetch_display=0 overlay_only=0 arg
    for arg in "$@"; do
        case "$arg" in
            --prefetch-display) prefetch_display=1 ;;
            --overlay-only) overlay_only=1 ;;
            *) echo "ERROR: unknown prepare-customer-assets option: $arg" >&2; return 1 ;;
        esac
    done
    if [[ $overlay_only -eq 1 ]]; then
        lpr_templates_overlay_write
        return
    fi
    lpr_templates_ensure_persistent "" || return 1
    # A failed update intentionally keeps COMPOSE_FILE on its immutable source
    # snapshot. Preserve customer data, but do not replace that recovery guard
    # with a newly generated overlay. The resumed update enables it after the
    # verified source guard is released.
    update_source_guard_is_active && return 0
    lpr_templates_overlay_write || return 1
    lpr_templates_overlay_enable || return 1
    if [[ $prefetch_display -eq 1 ]] && ! host_display_prefetch_payload; then
        echo "WARN: host dview payload was not cached; core services can continue" >&2
    fi
}

# Retire a remoted RPM that survived an older RPM-to-Docker migration. Those
# migrations predated the remoted sidecar, so they had no reason to stop this
# service. Running both copies registers the same serial twice and makes each
# reverse tunnel displace the other.
#
# Stop and disable through the host service manager. Do not use pkill: Docker
# uses the same process name, and host PID visibility would make that kill the
# sidecar too. Remove the RPM without scripts after the service is confirmed
# down, because an RPM uninstall hook must never mutate the live Docker stack.
legacy_remoted_host_pids() {
    local binary="${NVR_LEGACY_REMOTED_BINARY:-/usr/local/bin/remoted}"
    local proc_root="${NVR_PROC_ROOT:-/proc}"
    local binary_id proc exe_id pid
    [[ -x "$binary" ]] || return 0
    binary_id=$(stat -Lc '%d:%i' "$binary" 2>/dev/null \
        || stat -Lf '%d:%i' "$binary" 2>/dev/null) || return 1
    for proc in "$proc_root"/[0-9]*; do
        [[ -d "$proc" ]] || continue
        exe_id=$(stat -Lc '%d:%i' "$proc/exe" 2>/dev/null \
            || stat -Lf '%d:%i' "$proc/exe" 2>/dev/null) || continue
        [[ "$exe_id" == "$binary_id" ]] || continue
        pid="${proc##*/}"
        echo "$pid"
    done
}

LEGACY_REMOTED_WAS_ACTIVE=0
LEGACY_REMOTED_WAS_ENABLED=0
LEGACY_REMOTED_MANAGER=""

retire_legacy_remoted_service() {
    local mode="${1:-}"
    local systemd_runtime="${NVR_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}"
    local init_script="${NVR_LEGACY_REMOTED_INIT_SCRIPT:-/etc/init.d/remoted}"
    local pids="" attempt systemd_handled=0

    if [[ "$mode" == "--if-configured" ]] \
            && ! core_services | grep -qxF remoted; then
        return 0
    fi

    if command -v systemctl >/dev/null 2>&1 \
            && [[ -d "$systemd_runtime" ]] \
            && systemctl cat remoted.service >/dev/null 2>&1; then
        systemd_handled=1
        LEGACY_REMOTED_MANAGER=systemd
        systemctl is-active --quiet remoted.service \
            && LEGACY_REMOTED_WAS_ACTIVE=1 || true
        systemctl is-enabled --quiet remoted.service \
            && LEGACY_REMOTED_WAS_ENABLED=1 || true
        if systemctl is-active --quiet remoted.service \
                && ! systemctl stop remoted.service; then
            echo "ERROR: could not stop legacy remoted.service" >&2
            return 1
        fi
        if ! systemctl disable remoted.service; then
            echo "ERROR: could not disable legacy remoted.service" >&2
            return 1
        fi
        if systemctl is-active --quiet remoted.service; then
            echo "ERROR: legacy remoted.service is still active" >&2
            return 1
        fi
    fi

    # The remoted RPM ships only a systemd unit, even on CentOS 6. Never call a
    # stray SysV stop script because old scripts can match the Docker process by
    # name. De-register one if present, then target only processes whose
    # executable inode is the host RPM binary. A container copy has a different
    # overlay inode even though both executables use the same path and name.
    if [[ -x "$init_script" ]] && command -v chkconfig >/dev/null 2>&1; then
        if [[ -z "$LEGACY_REMOTED_MANAGER" ]]; then
            LEGACY_REMOTED_MANAGER=sysv
            chkconfig --list remoted 2>/dev/null | grep -qE '[2-5]:on' \
                && LEGACY_REMOTED_WAS_ENABLED=1 || true
        fi
        if ! chkconfig remoted off 2>/dev/null; then
            echo "ERROR: could not disable legacy SysV remoted service" >&2
            return 1
        fi
    fi
    pids=$(legacy_remoted_host_pids) || {
        echo "ERROR: could not identify legacy remoted host processes" >&2
        return 1
    }
    if [[ -n "$pids" ]]; then
        LEGACY_REMOTED_WAS_ACTIVE=1
        if [[ $systemd_handled -ne 1 && "$LEGACY_REMOTED_MANAGER" != sysv ]]; then
            echo "ERROR: legacy remoted host process has no managed service: $pids" >&2
            return 1
        fi
        # Intentional word split of numeric PIDs emitted by the inode matcher.
        # shellcheck disable=SC2086
        kill $pids 2>/dev/null || true
        for attempt in 1 2 3 4 5; do
            pids=$(legacy_remoted_host_pids) || return 1
            [[ -z "$pids" ]] && break
            sleep 1
        done
    fi
    pids=$(legacy_remoted_host_pids) || return 1
    if [[ -n "$pids" ]]; then
        echo "ERROR: legacy remoted host process is still active: $pids" >&2
        return 1
    fi
    return 0
}

restore_legacy_remoted_service_if_needed() {
    local cid health rc=0
    [[ $LEGACY_REMOTED_WAS_ACTIVE -eq 1 ]] || return 0
    cid=$($COMPOSE ps -q remoted 2>/dev/null) || rc=$?
    if [[ $rc -ne 0 ]]; then
        echo "ERROR: cannot inspect the remoted sidecar before legacy restore" >&2
        return 1
    fi
    cid=$(printf '%s\n' "$cid" | sed -n '1p')
    if [[ -n "$cid" ]]; then
        health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null) || {
            echo "ERROR: cannot inspect remoted sidecar health before legacy restore" >&2
            return 1
        }
        [[ "$health" == "healthy" ]] && return 0
    fi
    if ! $COMPOSE stop remoted >/dev/null 2>&1; then
        echo "ERROR: could not stop failed remoted sidecar before legacy restore" >&2
        return 1
    fi
    case "$LEGACY_REMOTED_MANAGER" in
        systemd)
            if [[ $LEGACY_REMOTED_WAS_ENABLED -eq 1 ]]; then
                systemctl enable remoted.service >/dev/null 2>&1 || return 1
            fi
            systemctl start remoted.service \
                && systemctl is-active --quiet remoted.service || return 1
            ;;
        sysv)
            if [[ $LEGACY_REMOTED_WAS_ENABLED -eq 1 ]]; then
                chkconfig remoted on >/dev/null 2>&1 || return 1
            fi
            service remoted start >/dev/null 2>&1 || return 1
            ;;
        *)
            echo "ERROR: no legacy remoted service manager is available for restore" >&2
            return 1
            ;;
    esac
    echo "Restored legacy remoted service after update failure."
    return 0
}

# Remove package files only after the Docker sidecar passes every update gate.
remove_legacy_remoted_rpm() {
    local mode="${1:-}" pids=""
    if [[ "$mode" == "--if-configured" ]] \
            && ! core_services | grep -qxF remoted; then
        return 0
    fi
    pids=$(legacy_remoted_host_pids) || {
        echo "ERROR: could not verify legacy remoted processes before RPM removal" >&2
        return 1
    }
    if [[ -n "$pids" ]]; then
        echo "ERROR: refusing to remove remoted RPM while its host process is active" >&2
        return 1
    fi
    if command -v rpm >/dev/null 2>&1 && rpm -q remoted >/dev/null 2>&1; then
        if ! rpm -e --nodeps --noscripts remoted; then
            echo "ERROR: could not remove legacy remoted RPM" >&2
            return 1
        fi
        echo "Removed legacy remoted RPM."
    fi
    return 0
}

cmd_update() {
    local target_mariadb_series=""
    local compatibility_mariadb_series=""
    local activate_now=0
    local NVR_RUNNING_UPDATER_SHA256=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --activate-now) activate_now=1 ;;
            *) echo "Usage: nvr update [--activate-now]" >&2; return 2 ;;
        esac
        shift
    done
    # Capture this before extraction replaces the script on disk. The newly
    # sourced installer compares it with the installed updater generation. An
    # older in-memory updater has no such value, so it requests one re-entry.
    NVR_RUNNING_UPDATER_SHA256=$(activation_sha_file "${BASH_SOURCE[0]}") || true
    export NVR_RUNNING_UPDATER_SHA256
    # Serialize concurrent lifecycle work. A nightly update must wait for a
    # short heal or add-on reconcile instead of silently losing its daily run.
    # The shared helper bounds that wait, then fails loudly if the timeout
    # expires so cron and monitoring retain evidence of the missed update.
    # Skipped for non-root callers (dev mode, container) so /var/lock
    # permission errors don't break local iteration.
    if [[ $EUID -eq 0 ]]; then
        take_lifecycle_lock || {
            echo "ERROR: timed out waiting for lifecycle lock for update" >&2
            return 1
        }
    fi

    # Recover a failed first-update handoff before any Compose command reads the
    # intentional missing-file guard. Preservation success replaces the guard
    # with the real generated LPR overlay.
    if customer_assets_block_is_active; then
        prepare_customer_assets_for_compose || return 1
    fi
    activation_ensure_root || {
        echo "ERROR: cannot prepare the staged activation state directory" >&2
        return 1
    }
    activation_capture_checkpoint || return 1
    activation_resume_before_update || return 1
    rm -f "$NVR_ACTIVATION_DIR/manifest.candidate" 2>/dev/null || true

    # Intentional bring-up: an update converges the stack, so clear the heal stop
    # marker (otherwise the nightly update would start the stack while the marker
    # still suppressed heal). ADR-100 4.5.
    heal_clear_stop_marker

    # Idempotent maintenance crons. Land here (not just install-nvr.sh)
    # so NVRs that were installed before each cron existed pick them up
    # on their next update without needing a re-install.
    ensure_prune_cron
    ensure_update_cron
    ensure_heal_cron

    # Idempotent host-config self-heal (FIX 4). Re-asserts passwordless sudo
    # for dividia and the Docker log cap on every update, repairing boxes left
    # half-configured by an interrupted migration (cs2565). `|| true`: a
    # self-heal must never abort the update; ensure_host_config also no-ops for
    # non-root callers internally.
    ensure_host_config || true

    # Remove a stale autoheal token before the source Compose snapshot is
    # rendered. A missing retired overlay must not prevent the safety guard.
    autoheal_purge || true
    activation_overlay_reconcile || return 1

    # Preserve the source side of the transition before mutable channel tags
    # move. If a service is already absent, use the last durable source state.
    if ! update_source_state_capture && ! update_source_state_read >/dev/null; then
        echo "ERROR: cannot identify the installed database and backend images before update" >&2
        return 1
    fi
    ensure_preupdate_source_backend || return 1
    if ! update_source_guard_activate; then
        echo "ERROR: cannot preserve the installed Compose configuration before update" >&2
        return 1
    fi
    local target_update_compose_files
    target_update_compose_files=$(update_source_target_compose_files) || {
        echo "ERROR: cannot identify the target Compose configuration" >&2
        return 1
    }

    echo "Pulling latest backend image..."
    # Backend carries the desired Compose bundle. Pull and extract it first.
    # This ordering supports adding PTZD and rolling back to a channel whose
    # older Compose bundle and image set do not contain PTZD.
    COMPOSE_FILE="$target_update_compose_files" $COMPOSE pull --quiet backend

    # Extract updated compose files + CLI from new backend image
    echo "Extracting updated files from backend image..."
    local image
    image=$(COMPOSE_FILE="$target_update_compose_files" \
        update_target_backend_image_ref 2>/dev/null || true)
    if [[ -n "$image" ]]; then
        local cid
        cid=$(docker create "$image" 2>/dev/null) || true
        if [[ -n "$cid" ]]; then
            compatibility_mariadb_series=$(mariadb_bundle_series_from_container "$cid") || {
                docker rm "$cid" >/dev/null 2>&1 || true
                return 1
            }
            # Extract files via tar stream so they're owned by calling user
            # (not root). Use atomic write-temp-then-rename: a failed
            # `docker cp | tar -xO` would otherwise truncate the destination
            # to zero bytes via the `>` redirect, leaving (e.g.)
            # /usr/local/bin/nvr-ro-wrap as an empty file — which sshd's
            # forced-command exec can't run, locking out ALL Windows-side
            # ro-key SSH until manual recovery. Verify the temp file is
            # non-empty before swapping it in.
            local extract_files=(
                "/usr/share/nvr/compose/docker-compose.yml:$INSTALL_DIR/docker-compose.yml"
                "/usr/share/nvr/compose/docker-compose.prod.yml:$INSTALL_DIR/docker-compose.prod.yml"
                # Optional HME overlay: not OS-specific, so it refreshes on every
                # box (like prod.yml) rather than being presence-gated like the
                # co6/windows overlays below. Inert unless COMPOSE_FILE names it.
                "/usr/share/nvr/compose/docker-compose.hme.yml:$INSTALL_DIR/docker-compose.hme.yml"
                # ADR-110 artifacts are unknown to every old updater. The new
                # CLI extracts them from the immutable backend only while the
                # ADR-106 source guard keeps all lifecycle paths on the source.
                "/usr/share/nvr/compose/$NVR_ACTIVATION_OVERLAY:$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY"
                "/usr/share/nvr/activation/staged-activation-10.5-to-10.11.manifest:$NVR_ACTIVATION_DIR/manifest.candidate"
                "/usr/share/nvr/bin/nvr:$INSTALL_DIR/nvr"
                "/usr/share/nvr/bin/install-nvr.sh:$INSTALL_DIR/install-nvr.sh"
                "/usr/share/nvr/bin/host-dview:$INSTALL_DIR/host-dview"
                "/usr/share/nvr/bin/removable-videostore-config.py:$INSTALL_DIR/removable-videostore-config.py"
                "/usr/share/nvr/bin/nvr-db-engine-controller:$INSTALL_DIR/nvr-db-engine-controller"
				"/usr/share/nvr/bin/nvr-db-test-restore:$INSTALL_DIR/nvr-db-test-restore"
				"/usr/share/nvr/bin/nvr-db-auto-migrate:$INSTALL_DIR/nvr-db-auto-migrate"
                "/usr/share/nvr/bin/nvr-ddl.py:$INSTALL_DIR/nvr-ddl.py"
                "/usr/share/nvr/bin/innodb-phase-a.manifest:$INSTALL_DIR/innodb-phase-a.manifest"
                "/usr/share/nvr/bin/innodb-approved-tables.txt:$INSTALL_DIR/innodb-approved-tables.txt"
                "/usr/local/bin/nvr-ro-wrap:/usr/local/bin/nvr-ro-wrap"
                # Distribute the aiengine overlay UNCONDITIONALLY (unlike the
                # co6/windows host overlays below). A feature overlay must land
                # on a box before it is ever enabled; it is inert on disk while
                # intent is disabled because it is not in COMPOSE_FILE.
                "/usr/share/nvr/compose/docker-compose.aiengine.yml:$INSTALL_DIR/docker-compose.aiengine.yml"
            )
            # Platform-specific compose overlays: refresh from the image only
            # when the host already has the file on disk. Presence at install
            # time = host needs this overlay (install-nvr.sh writes it
            # conditionally per OS). Without this, co6.yml / windows.yml fixes
            # never reach customers via `nvr update` — they stay on the
            # version that install-nvr.sh originally downloaded. CO9/UB24
            # hosts have no co6.yml on disk and the entry is skipped, so we
            # don't sprout an unused overlay on the wrong platform.
            local optional_extract_files=(
                "/usr/share/nvr/compose/docker-compose.co6.yml:$INSTALL_DIR/docker-compose.co6.yml"
                "/usr/share/nvr/compose/docker-compose.windows.yml:$INSTALL_DIR/docker-compose.windows.yml"
            )
            for entry in "${optional_extract_files[@]}"; do
                if [[ -f "${entry#*:}" ]]; then
                    extract_files+=("$entry")
                fi
            done
            for entry in "${extract_files[@]}"; do
                local src="${entry%%:*}" dest="${entry#*:}"
                local tmp="${dest}.new.$$"
                if docker cp "$cid:$src" - 2>/dev/null | tar -xO > "$tmp" && [[ -s "$tmp" ]]; then
                    # The 6.2 backend image once shipped an nvr script that
                    # parsed on the build host but failed under Bash 4.1 on
                    # 60 migrated BCC appliances. A fleet stopgap repaired
                    # /opt/dividia/nvr, then the next nightly update blindly
                    # extracted the invalid image copy and broke it again.
                    # Validate host shell tools with the HOST'S Bash before
                    # replacing the known-working copy. Compose/config files
                    # are not shell scripts and intentionally skip this gate.
                    if validate_extracted_host_tool "$dest" "$tmp"; then
                        mv -f "$tmp" "$dest"
                    else
                        rm -f "$tmp" 2>/dev/null || true
                        echo "WARN: extracted $src fails host Bash syntax; keeping existing $dest" >&2
                    fi
                else
                    rm -f "$tmp" 2>/dev/null || true
                    echo "WARN: extract of $src failed or produced empty file; keeping existing $dest" >&2
                fi
            done
            chmod 555 "$INSTALL_DIR/nvr" "$INSTALL_DIR/install-nvr.sh" \
                "$INSTALL_DIR/host-dview" \
                "$INSTALL_DIR/removable-videostore-config.py" \
                "$INSTALL_DIR/nvr-db-engine-controller" \
				"$INSTALL_DIR/nvr-db-test-restore" \
				"$INSTALL_DIR/nvr-db-auto-migrate" \
                "$INSTALL_DIR/nvr-ddl.py" 2>/dev/null || true
			chmod 444 "$INSTALL_DIR/innodb-phase-a.manifest" \
				"$INSTALL_DIR/innodb-approved-tables.txt" 2>/dev/null || true
			chown root:root "$INSTALL_DIR/nvr-db-engine-controller" \
				"$INSTALL_DIR/nvr-db-test-restore" \
				"$INSTALL_DIR/nvr-db-auto-migrate" \
				"$INSTALL_DIR/nvr-ddl.py" \
				"$INSTALL_DIR/innodb-phase-a.manifest" \
				"$INSTALL_DIR/innodb-approved-tables.txt" 2>/dev/null || true
            chmod 555 /usr/local/bin/nvr-ro-wrap 2>/dev/null || true
            docker rm "$cid" > /dev/null
            echo "Files updated from image."
        fi
    fi
    chmod 444 "$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY" \
        "$NVR_ACTIVATION_DIR/manifest.candidate" 2>/dev/null || true
    chown root:root "$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY" \
        "$NVR_ACTIVATION_DIR/manifest.candidate" 2>/dev/null || true
    if ! activation_manifest_publish "$NVR_ACTIVATION_DIR/manifest.candidate"; then
        rm -f "$NVR_ACTIVATION_DIR/manifest.candidate" 2>/dev/null || true
        echo "ERROR: staged activation manifest failed strict validation" >&2
        return 1
    fi
    rm -f "$NVR_ACTIVATION_DIR/manifest.candidate" 2>/dev/null || true
    activation_manifest_read || return 1
    activation_manifest_validate_bundle || return 1

    local target_update_backend_image
    target_update_backend_image=$(COMPOSE_FILE="$target_update_compose_files" \
        update_target_backend_image_ref) || {
        echo "ERROR: cannot resolve the extracted target backend image" >&2
        return 1
    }
    activation_import_legacy_ready_marker "$target_update_backend_image" || return 1

    target_mariadb_series="$compatibility_mariadb_series"
    if [[ "$(mariadb_effective_minimum_series 2>/dev/null || true)" == 10.11 ]]; then
        target_mariadb_series="$AM_TARGET_DATABASE_SERIES"
        target_update_compose_files=$(activation_overlay_files \
            "$target_update_compose_files" present) || return 1
    fi
    # The base selects 10.5. A committed floor selects the inert overlay's
    # supported 10.11 target. This check runs only after the complete immutable
    # manifest and Compose artifact set verifies.
    if ! mariadb_prepare_runtime_transition "$target_mariadb_series"; then
        return 1
    fi
    [[ -n "$target_mariadb_series" ]] || {
        echo "ERROR: could not determine target MariaDB runtime series" >&2
        return 1
    }

    echo "Pulling latest core images from the extracted Compose bundle..."
    # Recalculate the required set after extraction. A six-service bundle can
    # become a seven-service bundle here, or the reverse during rollback.
    COMPOSE_FILE="$target_update_compose_files" core_service_pull

    # Capture an older container customization before repair or Compose can
    # remove its writable layer. The mount stays in a generated overlay, so an
    # old in-memory updater cannot apply it before the new helper succeeds.
    lpr_templates_ensure_persistent "$target_update_compose_files" || return 1

    # Keep the optional host-viewer payload available for an offline service
    # visit. This only fills the cache. It never enables the local display.
    if ! COMPOSE_FILE="$target_update_compose_files" host_display_prefetch_payload; then
        echo "WARN: host dview payload was not cached; container updates will continue" >&2
    fi

    # A backend image can run database migrations as soon as Compose recreates
    # it. Create and verify one normal NVR backup before that digest changes.
    # The completed archive records both sides of the transition, so a retry
    # from the same source to the same target reuses the original recovery
    # point instead of rotating it away with newer database state.
    if ! COMPOSE_FILE="$target_update_compose_files" \
            prepare_automatic_update_backup "$target_update_backend_image"; then
        echo "ERROR: update stopped because its automatic database backup is not verified" >&2
        return 1
    fi
    if ! update_source_guard_release; then
        echo "ERROR: update stopped because the verified source guard could not be released" >&2
        return 1
    fi
    activation_overlay_reconcile || return 1
    lpr_templates_overlay_write || return 1
    lpr_templates_overlay_enable || return 1

    # Refresh the generated boot service before repair. On this release and
    # later, the sourced installer runs the newly extracted customer-asset
    # helper before repair can remove a label-stripped connector layer.
    #
    # A host entering this release from an older in-memory updater still reaches
    # its pre-existing repair first. The migration runbook therefore includes a
    # one-time preflight for an already label-stripped connector.
    ensure_boot_service || true

    # Self-heal containers that Compose has lost track of only after the
    # recovery point is verified. A repair can force-remove a running backend
    # or database container. Doing that before the backup gate can leave the
    # service absent when backup discovery or verification then fails.
    if ! take_videostore_lock; then
        echo "ERROR: cannot take VideoStore lock for update repair" >&2
        return 1
    fi
    repair_untracked_compose_containers
    release_videostore_lock

    # One-way autoheal purge: autoheal was removed entirely (heal owns both
    # classes). Strip any dangling docker-compose.autoheal.yml token from
    # COMPOSE_FILE and force-remove a leftover sidecar. MUST run BEFORE the core
    # `compose config`/`up` below, or compose would fail on a token whose file no
    # longer ships. `|| true`: never abort an update.
    autoheal_purge || true

    # Converge the optional aiengine overlay to host intent BEFORE the compose
    # up below, and pull its image in an isolated step. Must run AFTER the
    # extract above (so the overlay file is the freshly shipped version) and
    # BEFORE `up` (so COMPOSE_FILE already reflects intent). `|| true`: an
    # add-on reconcile failure must never abort a core update.
    aiengine_reconcile || true

    # Converge the optional HME addon's HOST state (COMPOSE_FILE overlay + conf +
    # DeviceType seed) BEFORE the main compose up, so the hme container is
    # started or orphan-removed in the SAME pass as everything else. State-only
    # (no compose call here); fail-open on any DB error and never aborts the
    # update. The db container from the prior version is still up at this point,
    # so the Device-signal read succeeds.
    hme_reconcile_config --refresh-image || true
    boot_storage_runtime_ready || return 1
    boot_storage_enforce_running_container_masks pre || return 1

    # Existing Docker NVRs can predate the remoted sidecar. Stop any host RPM
    # copy only after every pre-convergence failure gate passes. Restore it if
    # cleanup itself fails and no healthy sidecar is available.
    if ! COMPOSE_FILE="$target_update_compose_files" \
            retire_legacy_remoted_service --if-configured; then
        restore_legacy_remoted_service_if_needed || true
        return 1
    fi

    # Converge the stack; do NOT blanket-stop it. A `$COMPOSE stop` here would
    # bounce EVERY container every night, interrupting recording even when the
    # cron pulled nothing new. The `compose up -d` below (via
    # converge_core_stack) is already selective: Compose recreates a service
    # only when its image digest or resolved config changed, and leaves an
    # unchanged service running untouched. So a no-op update restarts nothing,
    # and a real update recreates only the service(s) that actually changed --
    # exactly the nightly behavior we want, and what the original ordered
    # `compose up` did before ADR-078 added the blanket stop incidentally. The
    # removable-VideoStore safe-removal saga does not depend on this stop: it
    # runs through backend RPC plus the mask compose overlay, and the
    # boot_storage_enforce_running_container_masks pre/post guards (above and
    # below) remain the actual mask-safety mechanism.
    echo "Converging services..."
    # --remove-orphans: when an NVR migrates off watchtower for the first
    # time, the existing dividia-nvr-watchtower-1 container becomes an
    # orphan of the new compose project (the watchtower service block
    # was stripped from prod.yml in feature/replace-watchtower-with-nvr-cron).
    # Without --remove-orphans, compose emits a "Found orphan containers"
    # warning but leaves it alive — cmd_update_disable_watchtower then has
    # to catch it at the tail of cmd_update. That tail path turned out to
    # be set-e-fragile (cs2427 2026-05-28: chain broke between
    # ensure_update_cron at L106 and the disable call, leaving cron
    # installed but watchtower running). With --remove-orphans, compose
    # itself kills the orphan as part of the upgrade.
    #
    # Safe across all dividia-nvr-* compose project owners: --remove-orphans
    # only touches containers labeled com.docker.compose.project=dividia-nvr
    # that aren't declared in the current compose files. Other compose
    # projects on the host (homebridge, dragon-pilot, etc.) carry their
    # own project label and stay untouched.
    local compose_up_ok=0
    # Bring up CORE services BY NAME. A whole-project `up` aborts entirely if
    # one service's image is missing, so an enabled add-on whose image failed
    # to pull (and has no local copy) would take the core lifecycle down with
    # it. Naming core services isolates that. No --quiet-pull: core images were
    # already pulled by name above; the add-on image was pulled separately in
    # aiengine_reconcile. --remove-orphans still prunes project orphans (e.g. a
    # just-disabled aiengine whose overlay reconcile dropped from COMPOSE_FILE).
    local _core_svcs
    _core_svcs=$(core_services)
    if [[ -n "$_core_svcs" ]]; then
        # Converge + verify instead of a single fire-and-forget `up`. A
        # transient backend-unhealthy during warmup used to abort the bring-up
        # and strand engine/connector/viewer in `Created` with cron still
        # reporting success (BCC cs1686/cs1129 2026-08-05). converge_core_stack
        # re-applies the desired state until every core service is actually
        # running, or fails loudly so the tail below leaves the rollback path
        # intact and cmd_update exits non-zero.
        converge_core_stack "$_core_svcs" && compose_up_ok=1 || true
    else
        $COMPOSE up -d --remove-orphans && compose_up_ok=1 || true
    fi
    if [[ $compose_up_ok -eq 1 ]]; then
        if ! lpr_templates_refresh_consumers_if_needed; then
            echo "ERROR: could not refresh LPR consumers after the template file changed" >&2
            compose_up_ok=0
        elif [[ "${LPR_TEMPLATES_REFRESHED:-0}" -eq 1 && -n "$_core_svcs" ]]; then
            # The forced recreation happens after the first target convergence.
            # Verify both consumers and their dependencies again before update
            # bookkeeping can report success.
            converge_core_stack "$_core_svcs" || compose_up_ok=0
        fi
    fi
    # Start the optional add-on in its OWN step, never gating compose_up_ok. An
    # add-on registry/image outage must not block the watchtower handoff or the
    # host-dview refresh below. Reconcile already set the overlay + pulled.
    aiengine_start_serialized \
        || echo "WARN: aiengine did not start; core services are up" >&2
    if [[ $compose_up_ok -eq 1 ]] && ! boot_storage_enforce_running_container_masks; then
        compose_up_ok=0
    fi
    if [[ $compose_up_ok -eq 1 ]] \
            && ! mariadb_finalize_runtime_transition "$target_mariadb_series"; then
        echo "ERROR: MariaDB data-directory marker did not finalize" >&2
        compose_up_ok=0
    fi
    if [[ $compose_up_ok -eq 1 ]] \
            && ! activation_stage_after_compatibility \
                "$target_update_backend_image" "$activate_now"; then
        echo "ERROR: staged activation did not complete" >&2
        compose_up_ok=0
    fi
    if [[ $compose_up_ok -eq 1 && -n "${NVR_ACTIVE_UPDATE_BACKUP_DIR:-}" ]]; then
        if ! update_backup_set_transition_applied "$NVR_ACTIVE_UPDATE_BACKUP_DIR"; then
            echo "WARN: cannot mark the automatic update transition as applied; the next update will retry" >&2
        fi
    fi
    if [[ $compose_up_ok -eq 1 ]] && ! update_source_state_capture; then
        echo "ERROR: cannot record the installed database and backend images after update" >&2
        compose_up_ok=0
    fi

    # Remove the stopped RPM only after every update success gate passes.
    # Failed convergence, storage, database, activation, or state capture keeps
    # the package files available for diagnosis or manual rollback.
    if [[ $compose_up_ok -eq 1 ]] \
            && ! COMPOSE_FILE="$target_update_compose_files" \
                remove_legacy_remoted_rpm --if-configured; then
        compose_up_ok=0
    fi

    # Reclaim disk only after the target stack converges. A failed update can
    # need its saved source image for recovery. `|| true` keeps a transient
    # prune error separate from the successful watchtower handoff below.
    if [[ $compose_up_ok -eq 1 ]]; then
        cmd_prune --quiet || true
    fi

    # Atomic handoff from watchtower to cron-driven updates. Gated on the
    # explicit compose_up_ok flag, not on `set -e` chain reaching this
    # line. cs2427 2026-05-28: prior version sat at the tail of cmd_update
    # under set -e and silently never ran, even though compose up had
    # succeeded — leaving the migrated NVR half-handed-off (cron in place,
    # watchtower still active). See
    # operational_nvr_update_first_migration_skip_watchtower_drop.md.
    #
    # If compose-up succeeded we know the new stack is at least running
    # (subsequent health checks may still fail, but watchtower can't help
    # with that either — watchtower 1.7.1 is the broken version we're
    # trying to escape). If compose-up failed, leave watchtower in place
    # as the rollback fallback (original atomic-handoff intent).
    if [[ $compose_up_ok -eq 1 ]]; then
        cmd_update_disable_watchtower
    fi

    # HME addon: the main compose up above already started/removed the hme
    # container per the COMPOSE_FILE hme_reconcile_config just set. Two tails
    # remain: a changed conf must be forced into the running container (a
    # bind-mount content change does not recreate on its own), and the 2-minute
    # reconcile cron must track provisioning. Both `|| true` — never break the
    # update.
	if [[ $compose_up_ok -eq 1 && "${HME_DESIRED:-}" == "on" && "${HME_CONF_CHANGED:-0}" -eq 1 ]] && hme_container_running; then
		$COMPOSE up -d --force-recreate hme || true
	fi
	ensure_hme_reconcile_cron || true

    # The host viewer is opt-in. A bad payload must never turn a successful
    # container update into a failed NVR update, nor displace a working prior
    # desktop viewer; refresh performs its own atomic rollback.
    if [[ $compose_up_ok -eq 1 ]] && host_display_is_enabled; then
        local refresh_cmd=(host_display_refresh_after_update)
        if [[ $EUID -ne 0 ]]; then
            refresh_cmd=(sudo -n "$INSTALL_DIR/nvr" display refresh)
        fi
        if ! "${refresh_cmd[@]}"; then
            echo "WARN: host dview was not updated; container services remain updated" >&2
        fi
    fi

    if [[ $compose_up_ok -ne 1 ]]; then
        # The core stack did not converge. Say so loudly and return non-zero so
        # the daily cron emails instead of silently reporting success while the
        # recording pipeline is down. `return 1` (not `exit 1`): under `set -e`
        # the `update) cmd_update "$@"` dispatch propagates it to a non-zero
        # script exit for cron, and it stays catchable by callers/tests. The
        # watchtower rollback fallback is preserved -- its drop is gated on
        # compose_up_ok=1, which stayed 0.
        restore_legacy_remoted_service_if_needed || true
        echo "Update FINISHED WITH ERRORS: core recording stack is not fully up." >&2
        $COMPOSE ps >&2 || true
        return 1
    fi

    echo ""
    echo "Update complete!"
    $COMPOSE ps
}

ensure_prune_cron() {
    local cron_path="/etc/cron.d/dividia-docker-prune"
    local cron_body
    # Do not nest heredocs inside command substitutions: Bash 4.1 on CO6 can
    # lose the closing `)` when the heredoc body later gains apostrophes.
    IFS= read -r -d '' cron_body <<'EOF' || true
# 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 (docker image prune -f; docker image prune -a -f --filter "until=168h") >/var/log/dividia-docker-prune.log 2>&1
EOF
    # Skip if not root (containers, dev mode); only the bare-metal install
    # needs the cron managed.
    if [[ $EUID -ne 0 ]]; then
        if ! sudo -n true 2>/dev/null; then return 0; fi
        if [[ -f "$cron_path" ]] && sudo cmp -s <(echo "$cron_body") "$cron_path"; then return 0; fi
        echo "$cron_body" | sudo tee "$cron_path" >/dev/null && \
            sudo chmod 0644 "$cron_path"
    else
        if [[ -f "$cron_path" ]] && cmp -s <(echo "$cron_body") "$cron_path"; then return 0; fi
        echo "$cron_body" > "$cron_path" && chmod 0644 "$cron_path"
    fi
}

# Compute a stable per-host minute jitter in [0,59] so the fleet
# doesn't all hit DockerHub at the same instant. Primary source:
# the bSerial (customer ID) recorded in dvs.conf at install time —
# stable across reboots, replayable for support. Fallback: a hash
# of the hostname so even a zero-ID dev install gets spread.
#
# Echoes the integer to stdout. Always produces a value in [0,59];
# never errors. Safe to invoke from cron-install paths.
update_cron_jitter() {
    local dvs_conf="/opt/dividia/data/config/dvs.conf"
    local id=""
    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
        echo $(( id % 60 ))
        return 0
    fi
    # Fallback: hostname hash. cksum is on every distro back to
    # CO6; awk does the modulo so the value lands in [0,59].
    local h
    h=$(hostname 2>/dev/null | cksum 2>/dev/null | awk '{print $1 % 60}')
    [[ -n "$h" ]] && echo "$h" || echo 0
}

ensure_update_cron() {
    local cron_path="/etc/cron.d/dividia-nvr-update"
    local jitter
    jitter=$(update_cron_jitter)
    # Note: $jitter interpolates because this heredoc is NOT quoted.
    # Everything else is a literal comment or PATH/SHELL line.
    local cron_body
    # Keep the heredoc outside command substitution for Bash 4.1 (CO6).
    IFS= read -r -d '' cron_body <<EOF || true
# 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
    # Same root + sudo idempotent-write pattern as ensure_prune_cron.
    if [[ $EUID -ne 0 ]]; then
        if ! sudo -n true 2>/dev/null; then return 0; fi
        if [[ -f "$cron_path" ]] && sudo cmp -s <(echo "$cron_body") "$cron_path"; then return 0; fi
        echo "$cron_body" | sudo tee "$cron_path" >/dev/null && \
            sudo chmod 0644 "$cron_path"
    else
        if [[ -f "$cron_path" ]] && cmp -s <(echo "$cron_body") "$cron_path"; then return 0; fi
        echo "$cron_body" > "$cron_path" && chmod 0644 "$cron_path"
    fi
}

# ensure_host_config: idempotently re-assert host-level config that
# install-nvr.sh writes ONCE at install time but a steady-state `nvr update`
# never re-checks. An interrupted migration could leave a box with no
# passwordless sudo for the dividia user, or a daemon.json missing the
# container log cap. `nvr update` runs as root from cron on the whole fleet
# nightly, and `nvr start` runs before the containers bind their ports, so those
# are the right places to self-heal these settings. Everything is guarded so a
# non-root caller or a box missing these paths degrades gracefully — a self-heal
# must NEVER hard-fail an update or start (call sites use `|| true` too).
# Re-generate the host boot service (systemd nvr.service, or the SysV
# /etc/init.d/nvr on CentOS 6) from the CURRENT generator, on every update.
#
# WHY THIS IS A CLI FUNCTION AND NOT INSTALLER-ONLY. `install-nvr.sh` runs once per
# NVR lifetime. Every other host artifact already converges from here -- the crons,
# host config, sudoers, reserved ports, the rc.local block, the Docker log cap -- and
# the boot service was the last one that did not, so a boot-path fix could never
# reach a deployed box. ADR-045 is the proof: its CentOS 6 mounter lives inline in the
# generated init script, and cs2 does not have it.
#
# ONE IMPLEMENTATION, deliberately. The generator stays in install-nvr.sh and is
# INVOKED here, rather than copied: ADR-045 forbids a second implementation of
# anything mount-related, and the generated init script carries the CentOS 6 copy of
# ensure_videostore_mounts. Copying the generator would fork the mount logic three
# ways. install-nvr.sh is re-extracted from the backend image by cmd_update (see the
# extract_files list), so the generator invoked here is always as new as the image.
#
# Sourcing it is safe and is an established contract: the file ends with
# `if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi` precisely so it can be
# sourced without running the installer, which docker/tests/*.sh already rely on. Its
# top level is only `set -eE`, a PATH export and variable defaults -- no traps, no
# commands with side effects.
#
# Run in a SUBSHELL. install-nvr.sh sets `set -eE` and defines ~70 globals (CHANNEL,
# INSTALL_DIR, colors, MIGRATE_MODE...); leaking those into the CLI would be a
# silent behaviour change for every later subcommand, and its `set -eE` would make
# any subsequent non-zero fatal here.
ensure_boot_service() {
    local installer="$INSTALL_DIR/install-nvr.sh"

    # Works whether invoked as root or as the `dividia` user.
    #
    # WHY THIS IS NOT JUST A ROOT GUARD. The boot files live in /etc, so the work needs
    # root -- but on the CentOS 6 fleet the CLI is routinely run as `dividia` (uid 491 on
    # cs1383), and `install-nvr.sh` gives that user passwordless sudo via
    # /etc/sudoers.d/dividia. The first version returned 0 SILENTLY for non-root, so an
    # operator-run `nvr update` skipped the boot-service convergence and said nothing,
    # while the root-owned nightly cron did it correctly. That split is the worst shape:
    # the gap only appears when a human does it, and appears as no output at all. Found
    # on cs1383 2026-07-27 -- the box took the update, got its `noauto` fstab entry, and
    # kept an /etc/init.d/nvr with no mounter in it.
    #
    # So: elevate rather than skip. NVR_BOOT_SERVICE_ELEVATED stops a second pass from
    # re-sudoing if sudo somehow did not actually raise privileges, which would otherwise
    # recurse.
    if [ "$(id -u)" != "0" ]; then
        if [ -n "${NVR_BOOT_SERVICE_ELEVATED:-}" ]; then
            echo "  NOTE: still not root after elevating; boot service not refreshed"
            return 0
        fi
        local self="$INSTALL_DIR/nvr"
        [ -x "$self" ] || self="$0"
        if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
            NVR_BOOT_SERVICE_ELEVATED=1 sudo -n \
                NVR_BOOT_SERVICE_ELEVATED=1 "$self" ensure-boot-service || \
                echo "  WARNING: elevated boot-service refresh failed; boot service left as-is"
            return 0
        fi
        # LOUD, never silent. A boot service that has quietly stopped converging is the
        # exact failure this function exists to prevent.
        echo "  NOTE: not root and passwordless sudo unavailable; boot service NOT refreshed"
        echo "  NOTE: run 'sudo $self ensure-boot-service' to converge it now"
        return 0
    fi

    # No installer on disk => nothing to invoke. Do NOT fall back to a local copy of
    # the generator; a second implementation is the thing this function exists to
    # avoid. The next update re-extracts the installer and this converges then.
    if [ ! -r "$installer" ]; then
        echo "  NOTE: $installer not present; boot service left as-is (next update will refresh it)"
        return 0
    fi

    # Guard against invoking a generator that is not there (an older installer, or a
    # future rename). Silent no-op would mean the boot service quietly stops
    # converging, which is exactly the failure mode this function was written for.
    if ! grep -q '^create_boot_service()' "$installer"; then
        echo "  NOTE: $installer has no create_boot_service; boot service not refreshed"
        return 0
    fi

    (
        # shellcheck disable=SC1090
        . "$installer" >/dev/null 2>&1 || exit 1
        INSTALL_DIR="${NVR_INSTALL_DIR:-/opt/dividia}"
        create_boot_service
    ) || {
        echo "  WARNING: could not refresh the host boot service from $installer"
        return 0
    }
    return 0
}

boot_storage_systemd_host() {
    command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
}

boot_storage_artifacts_ready() {
    if [ ! -x "$BOOT_STORAGE_HELPER" ]; then
        echo "ERROR: boot helper is missing: $BOOT_STORAGE_HELPER" >&2
        return 1
    fi

    if boot_storage_systemd_host; then
        local dropin unit loaded
        for dropin in "$BOOT_STORAGE_CONTAINERD_DROPIN" "$BOOT_STORAGE_DOCKER_DROPIN"; do
            if ! grep -qxF "ExecStartPre=$BOOT_STORAGE_HELPER" "$dropin" 2>/dev/null; then
                echo "ERROR: daemon boot hook is missing: $dropin" >&2
                return 1
            fi
        done
        for unit in containerd.service docker.service; do
            loaded=$(systemctl show -p ExecStartPre "$unit" 2>/dev/null) || {
                echo "ERROR: systemd has not loaded the boot hook for $unit" >&2
                return 1
            }
            case "$loaded" in
                *"$BOOT_STORAGE_HELPER"*) ;;
                *)
                    echo "ERROR: systemd has not loaded the boot hook for $unit" >&2
                    return 1
                    ;;
            esac
        done
    else
        local expected_call hook_line dockerd_line
        expected_call="    \"$BOOT_STORAGE_HELPER\" || return 1"
        hook_line=$(grep -nF "$expected_call" "$BOOT_STORAGE_DOCKER_INIT" 2>/dev/null | head -1 | cut -d: -f1)
        dockerd_line=$(grep -n '^[[:space:]]*\$DOCKERD[[:space:]]' "$BOOT_STORAGE_DOCKER_INIT" 2>/dev/null | head -1 | cut -d: -f1)
        if ! grep -q '^    # dividia-nvr boot-storage hook$' "$BOOT_STORAGE_DOCKER_INIT" 2>/dev/null \
                || [ -z "$hook_line" ] || [ -z "$dockerd_line" ] || [ "$hook_line" -ge "$dockerd_line" ]; then
            echo "ERROR: Docker boot hook is missing: $BOOT_STORAGE_DOCKER_INIT" >&2
            return 1
        fi
    fi
    return 0
}

boot_storage_store_for_source() {
    local source="$1" relative store_name store_digits
    case "$source" in
        "$BOOT_STORAGE_VIDEOSTORE_ROOT"/vs*/?*) ;;
        *) return 1 ;;
    esac
    relative="${source#"$BOOT_STORAGE_VIDEOSTORE_ROOT"/}"
    store_name="${relative%%/*}"
    store_digits="${store_name#vs}"
    case "$store_digits" in ''|*[!0-9]*) return 1 ;; esac
    printf '%s/%s\n' "$BOOT_STORAGE_VIDEOSTORE_ROOT" "$store_name"
}

boot_storage_source_safe() {
    local source="$1" store="$2" canonical_source canonical_store source_dev store_dev uid mode
    canonical_source=$(readlink -f "$source" 2>/dev/null) || canonical_source=""
    canonical_store=$(readlink -f "$store" 2>/dev/null) || canonical_store=""
    [ -n "$canonical_source" ] && [ "$canonical_source" = "$source" ] || {
        echo "ERROR: source must be canonical and cannot contain symlinks: $source" >&2
        return 1
    }
    case "$canonical_source" in "$canonical_store"/*) ;; *)
        echo "ERROR: source resolves outside $store: $source" >&2
        return 1 ;;
    esac
    if mountpoint -q "$source" 2>/dev/null; then
        echo "ERROR: source cannot be a separate mount point: $source" >&2
        return 1
    fi
    source_dev=$(stat -c %d "$source" 2>/dev/null) || source_dev=""
    store_dev=$(stat -c %d "$store" 2>/dev/null) || store_dev=""
    [ -n "$source_dev" ] && [ "$source_dev" = "$store_dev" ] || {
        echo "ERROR: source is not on the $store filesystem: $source" >&2
        return 1
    }
    uid=$(stat -c %u "$source" 2>/dev/null) || uid=""
    mode=$(stat -c %a "$source" 2>/dev/null) || mode=""
    [ "$uid" = "0" ] || { echo "ERROR: source must be owned by root: $source" >&2; return 1; }
    case "$mode" in ''|*[!0-7]*) echo "ERROR: cannot verify source permissions: $source" >&2; return 1 ;; esac
    [ $((8#$mode & 8#22)) -eq 0 ] || {
        echo "ERROR: source cannot be group or world writable: $source" >&2
        return 1
    }
    return 0
}

boot_storage_target_allowed() {
    case "$1" in
        /opt) return 0 ;;
        *) return 1 ;;
    esac
}

boot_storage_fstab_entry() {
    local mount_point="$1"
    awk -v mp="$mount_point" '
        $1 !~ /^#/ && $2 == mp { count++; source=$1; options=$4 }
        END {
            if (count != 1) exit 2
            print source, options
        }
    ' "$BOOT_STORAGE_FSTAB" 2>/dev/null
}

boot_storage_fstab_noauto() {
    local mount_point="$1" entry options
    entry=$(boot_storage_fstab_entry "$mount_point") || return 1
    options="${entry#* }"
    case ",${options}," in
        *,noauto,*) return 0 ;;
        *) return 1 ;;
    esac
}

boot_storage_resolve_block_spec() {
    local spec="$1" resolved=""
    resolved=$(findfs "$spec" 2>/dev/null) || resolved=""
    if [ -z "$resolved" ]; then
        case "$spec" in
            LABEL=*) resolved=$(blkid -L "${spec#LABEL=}" 2>/dev/null) || resolved="" ;;
            UUID=*) resolved=$(blkid -U "${spec#UUID=}" 2>/dev/null) || resolved="" ;;
            /dev/*) resolved="$spec" ;;
        esac
    fi
    [ -n "$resolved" ] || return 1
    readlink -f "$resolved" 2>/dev/null
}

boot_storage_store_identity_matches_fstab() {
    local store="$1" entry spec expected live
    entry=$(boot_storage_fstab_entry "$store") || return 1
    spec="${entry%% *}"
    expected=$(boot_storage_resolve_block_spec "$spec") || return 1
    live=$(findmnt -rn -T "$store" -o SOURCE 2>/dev/null) || live=""
    live=$(readlink -f "$live" 2>/dev/null) || live=""
    [ -n "$live" ] && [ "$live" = "$expected" ]
}

boot_storage_target_in_fstab() {
    local target="$1"
    awk -v mp="$target" '
        $1 !~ /^#/ && $2 == mp { found=1 }
        END { exit !found }
    ' "$BOOT_STORAGE_FSTAB" 2>/dev/null
}

# Inspect the directory hidden below the active bind without taking that bind
# down. A non-recursive bind of the parent excludes the child mount, which
# exposes the target's backing directory through a temporary read path.
boot_storage_measure_bytes() {
    local output bytes
    output=$(du -sx --block-size=1 "$1" 2>/dev/null) || return 1
    bytes=$(printf '%s\n' "$output" | awk 'NR == 1 { print $1; exit }')
    case "$bytes" in ''|*[!0-9]*) return 1 ;; esac
    printf '%s\n' "$bytes"
}

boot_storage_underlying_target_safe() {
    local target="$1" parent base probe hidden shadow cleanup_ok=true measurement_ok=true
    parent="$(dirname "$target")"
    base="$(basename "$target")"
    probe=$(mktemp -d /tmp/dividia-nvr-boot-storage.XXXXXX) || {
        echo "ERROR: could not create the underlying-storage probe" >&2
        return 1
    }
    if ! mount --bind "$parent" "$probe"; then
        rmdir "$probe" 2>/dev/null || true
        echo "ERROR: could not inspect the storage below $target" >&2
        return 1
    fi
    # The parent can be shared on systemd hosts. Make the probe private before
    # another mount below the parent can propagate into it and pin it busy.
    if ! mount --make-private "$probe"; then
        umount "$probe" 2>/dev/null || true
        rmdir "$probe" 2>/dev/null || true
        echo "ERROR: could not isolate the storage probe below $target" >&2
        return 1
    fi

    hidden="$probe/$base"
    if ! shadow=$(boot_storage_measure_bytes "$hidden"); then
        measurement_ok=false
    fi
    if ! umount "$probe"; then
        cleanup_ok=false
    fi
    rmdir "$probe" 2>/dev/null || cleanup_ok=false
    if [[ "$cleanup_ok" != "true" ]]; then
        echo "ERROR: could not remove the underlying-storage probe at $probe" >&2
        return 1
    fi
    if [[ "$measurement_ok" != "true" ]]; then
        echo "ERROR: could not measure the storage below $target" >&2
        return 1
    fi
    case "${shadow:-}" in ''|*[!0-9]*)
        echo "ERROR: could not measure the storage below $target" >&2
        return 1
        ;;
    esac
    if [ "$shadow" -gt "$BOOT_STORAGE_SHADOW_LIMIT_BYTES" ]; then
        echo "ERROR: $target hides $shadow bytes on its backing filesystem" >&2
        echo "Move or remove that old copy before adoption." >&2
        return 1
    fi
    return 0
}

boot_storage_read_mapping() {
    awk '
        $1 ~ /^#/ || NF == 0 { next }
        NF != 2 { exit 2 }
        { count++; source=$1; target=$2 }
        END {
            if (count != 1) exit 2
            print source, target
        }
    ' "$BOOT_STORAGE_CONFIG" 2>/dev/null
}

boot_storage_guard_armed() {
    [ -e "$BOOT_STORAGE_HELPER" ] \
        || [ -e "$BOOT_STORAGE_DOCKER_DROPIN" ] \
        || [ -e "$BOOT_STORAGE_CONTAINERD_DROPIN" ] \
        || grep -q '^    # dividia-nvr boot-storage hook$' "$BOOT_STORAGE_DOCKER_INIT" 2>/dev/null
}

boot_storage_container_mask_ready() {
    local source="$1" owner mode current service
    [ -d "$BOOT_STORAGE_CONTAINER_MASK" ] && [ ! -L "$BOOT_STORAGE_CONTAINER_MASK" ] || return 1
    owner=$(stat -c %u "$BOOT_STORAGE_CONTAINER_MASK" 2>/dev/null) || owner=""
    mode=$(stat -c %a "$BOOT_STORAGE_CONTAINER_MASK" 2>/dev/null) || mode=""
    [ "$owner" = "0" ] || return 1
    case "$mode" in ''|*[!0-7]*) return 1 ;; esac
    [ $((8#$mode & 8#22)) -eq 0 ] || return 1
    if find "$BOOT_STORAGE_CONTAINER_MASK" -mindepth 1 -maxdepth 1 2>/dev/null | grep -q .; then
        return 1
    fi
    [ -f "$BOOT_STORAGE_COMPOSE_OVERLAY" ] && [ ! -L "$BOOT_STORAGE_COMPOSE_OVERLAY" ] || return 1
    owner=$(stat -c %u "$BOOT_STORAGE_COMPOSE_OVERLAY" 2>/dev/null) || owner=""
    mode=$(stat -c %a "$BOOT_STORAGE_COMPOSE_OVERLAY" 2>/dev/null) || mode=""
    [ "$owner" = "0" ] || return 1
    case "$mode" in ''|*[!0-7]*) return 1 ;; esac
    [ $((8#$mode & 8#22)) -eq 0 ] || return 1
    [ "$(grep -cF "source: $BOOT_STORAGE_CONTAINER_MASK" "$BOOT_STORAGE_COMPOSE_OVERLAY" 2>/dev/null)" -eq 5 ] || return 1
    [ "$(grep -cF "target: $source" "$BOOT_STORAGE_COMPOSE_OVERLAY" 2>/dev/null)" -eq 5 ] || return 1
    for service in backend engine connector playback viewer; do
        grep -qE "^  ${service}:$" "$BOOT_STORAGE_COMPOSE_OVERLAY" 2>/dev/null || return 1
    done
    current=$(compose_file_get)
    [ "${current##*:}" = "$BOOT_STORAGE_COMPOSE_OVERLAY" ] || return 1
    $COMPOSE config >/dev/null 2>&1
}

boot_storage_runtime_ready() {
    local mapping source target
    [ -e "$BOOT_STORAGE_CONFIG" ] || return 0
    mapping=$(boot_storage_read_mapping) || {
        echo "ERROR: invalid boot-storage config: $BOOT_STORAGE_CONFIG" >&2
        boot_storage_stop_videostore_consumers
        return 1
    }
    read -r source target <<< "$mapping"
    boot_storage_artifacts_ready || {
        echo "ERROR: VideoStore-backed Docker storage has no valid pre-Docker boot hook" >&2
        echo "Run: sudo nvr ensure-boot-service" >&2
        boot_storage_stop_videostore_consumers
        return 1
    }
    if [ ! -x "$BOOT_STORAGE_HELPER" ] || ! "$BOOT_STORAGE_HELPER"; then
        echo "ERROR: the pre-Docker storage helper rejected the live mapping" >&2
        boot_storage_stop_videostore_consumers
        return 1
    fi
    boot_storage_container_mask_ready "$source" || {
        echo "ERROR: VideoStore-backed Docker storage has no valid container mask" >&2
        echo "Run: sudo nvr ensure-boot-service" >&2
        boot_storage_stop_videostore_consumers
        return 1
    }
}

boot_storage_stop_videostore_consumers() {
    local service ids="" found stop_failed=0
    command -v docker >/dev/null 2>&1 || return 1
    for service in backend engine connector playback viewer; do
        found=$(docker ps -q \
            --filter label=com.docker.compose.project=dividia-nvr \
            --filter "label=com.docker.compose.service=$service" 2>/dev/null) || return 1
        ids="${ids}${found:+${ids:+ }$found}"
    done
    if [ -n "$ids" ]; then
        docker stop $ids >/dev/null 2>&1 || stop_failed=1
    fi
    for service in backend engine connector playback viewer; do
        found=$(docker ps -q \
            --filter label=com.docker.compose.project=dividia-nvr \
            --filter "label=com.docker.compose.service=$service" 2>/dev/null) || return 1
        [ -z "$found" ] || return 1
    done
    if [ "$stop_failed" -ne 0 ]; then
        echo "WARNING: Docker reported a consumer stop error, but no VideoStore consumer remains running" >&2
    fi
    return 0
}

boot_storage_consumers_exist() {
    local service found
    command -v docker >/dev/null 2>&1 || return 0
    for service in backend engine connector playback viewer; do
        found=$(docker ps -aq \
            --filter label=com.docker.compose.project=dividia-nvr \
            --filter "label=com.docker.compose.service=$service" 2>/dev/null) || return 0
        if [ -n "$found" ]; then
            return 0
        fi
    done
    return 1
}

boot_storage_running_containers_masked() {
    local source="$1" service cids cid mount_ok
    for service in backend engine connector playback viewer; do
        cids=$(docker ps -q \
            --filter label=com.docker.compose.project=dividia-nvr \
            --filter "label=com.docker.compose.service=$service" 2>/dev/null) || return 1
        for cid in $cids; do
            mount_ok=$(docker inspect -f "{{range .Mounts}}{{if eq .Destination \"$source\"}}{{.Source}}|{{.RW}}{{end}}{{end}}" "$cid" 2>/dev/null) || return 1
            [ "$mount_ok" = "$BOOT_STORAGE_CONTAINER_MASK|false" ] || return 1
        done
    done
    return 0
}

boot_storage_enforce_running_container_masks() {
    local phase="${1:-post}" mapping source target
    [ -e "$BOOT_STORAGE_CONFIG" ] || return 0
    mapping=$(boot_storage_read_mapping) || return 1
    read -r source target <<< "$mapping"
    if boot_storage_running_containers_masked "$source"; then
        return 0
    fi
    echo "ERROR: a VideoStore consumer started without the reserved-storage mask" >&2
    if ! boot_storage_stop_videostore_consumers; then
        echo "ERROR: could not prove every unmasked VideoStore consumer stopped" >&2
        return 1
    fi
    echo "Stopped VideoStore consumers to protect host control data." >&2
    [ "$phase" = "pre" ]
}

boot_storage_print_status() {
    local mapping source target store bad=0
    echo "Boot storage config: $BOOT_STORAGE_CONFIG"
    if [ ! -e "$BOOT_STORAGE_CONFIG" ]; then
        if boot_storage_guard_armed; then
            echo "Configured: incomplete"
            echo "Pre-Docker hook: armed without config"
            return 1
        fi
        echo "Configured: no"
        return 0
    fi
    mapping=$(boot_storage_read_mapping) || {
        echo "Configured: invalid"
        return 1
    }
    read -r source target <<< "$mapping"
    store=$(boot_storage_store_for_source "$source") || store=""

    echo "Configured: yes"
    echo "Source: $source"
    echo "Target: $target"
    echo "VideoStore: ${store:-invalid}"
    if [ -n "$store" ] && mountpoint -q "$store" 2>/dev/null; then
        echo "VideoStore mounted: yes"
    else
        echo "VideoStore mounted: no"
        bad=1
    fi
    if [ -n "$store" ] && boot_storage_fstab_noauto "$store"; then
        echo "VideoStore fstab: one noauto entry"
    else
        echo "VideoStore fstab: invalid"
        bad=1
    fi
    if [ -n "$store" ] && boot_storage_store_identity_matches_fstab "$store"; then
        echo "VideoStore identity: matches fstab"
    else
        echo "VideoStore identity: mismatch"
        bad=1
    fi
    if boot_storage_target_in_fstab "$target"; then
        echo "Bind target fstab owner: conflicting"
        bad=1
    else
        echo "Bind target fstab owner: none"
    fi
    if mountpoint -q "$target" 2>/dev/null && [ "$source" -ef "$target" ]; then
        echo "Bind verified: yes"
    else
        echo "Bind verified: no"
        bad=1
    fi
    if [ -n "$store" ] && boot_storage_source_safe "$source" "$store"; then
        echo "Source directory: safe"
    else
        echo "Source directory: unsafe"
        bad=1
    fi
    if [ "$EUID" -eq 0 ] && [ -n "$store" ]; then
        if boot_storage_underlying_target_safe "$store" \
                && boot_storage_underlying_target_safe "$target"; then
            echo "Underlying storage: safe"
        else
            echo "Underlying storage: unsafe"
            bad=1
        fi
    elif [ "$EUID" -ne 0 ]; then
        echo "Underlying storage: not checked (run with sudo)"
        bad=1
    else
        echo "Underlying storage: unsafe"
        bad=1
    fi
    if boot_storage_artifacts_ready; then
        echo "Pre-Docker hook: ready"
    else
        echo "Pre-Docker hook: missing"
        bad=1
    fi
    if boot_storage_container_mask_ready "$source"; then
        echo "Container mask: ready"
    else
        echo "Container mask: missing"
        bad=1
    fi
    if boot_storage_running_containers_masked "$source"; then
        echo "Running containers masked: yes"
    else
        echo "Running containers masked: no"
        bad=1
    fi
    return "$bad"
}

boot_storage_require_root() {
    if should_reexec_root; then nvr_try_elevate; return $?; fi
    [ "$EUID" -eq 0 ] && return 0
    echo "ERROR: run 'sudo nvr boot-storage adopt SOURCE TARGET'" >&2
    return 1
}

boot_storage_snapshot_one() {
    local snapshot="$1" label="$2" path="$3"
    if [ -e "$path" ] || [ -L "$path" ]; then
        cp -a "$path" "$snapshot/$label" || return 1
        echo present > "$snapshot/$label.state"
    else
        echo absent > "$snapshot/$label.state"
    fi
}

boot_storage_snapshot_mask_metadata() {
    local snapshot="$1" path="$BOOT_STORAGE_CONTAINER_MASK" uid gid mode
    if [ -L "$path" ]; then
        return 1
    fi
    if [ -d "$path" ]; then
        if find "$path" -mindepth 1 -maxdepth 1 2>/dev/null | grep -q .; then
            return 1
        fi
        uid=$(stat -c %u "$path" 2>/dev/null) || return 1
        gid=$(stat -c %g "$path" 2>/dev/null) || return 1
        mode=$(stat -c %a "$path" 2>/dev/null) || return 1
        printf 'present %s %s %s\n' "$uid" "$gid" "$mode" > "$snapshot/container-mask.state"
    elif [ -e "$path" ]; then
        return 1
    else
        echo absent > "$snapshot/container-mask.state"
    fi
}

boot_storage_restore_mask_metadata() {
    local snapshot="$1" path="$BOOT_STORAGE_CONTAINER_MASK" state uid gid mode
    read -r state uid gid mode < "$snapshot/container-mask.state" || return 1
    if [ "$state" = "present" ]; then
        [ -d "$path" ] && [ ! -L "$path" ] || return 1
        if find "$path" -mindepth 1 -maxdepth 1 2>/dev/null | grep -q .; then
            return 1
        fi
        chown "$uid:$gid" "$path" || return 1
        chmod "$mode" "$path" || return 1
    else
        rmdir "$path" 2>/dev/null || [ ! -e "$path" ]
    fi
}

boot_storage_restore_one() {
    local snapshot="$1" label="$2" path="$3" state
    state=$(cat "$snapshot/$label.state" 2>/dev/null) || return 1
    if [ "$state" = "present" ]; then
        mkdir -p "$(dirname "$path")" || return 1
        rm -f "$path" || return 1
        cp -a "$snapshot/$label" "$path" || return 1
    else
        rm -f "$path" || return 1
    fi
}

boot_storage_snapshot_artifacts() {
    local snapshot
    snapshot=$(mktemp -d /tmp/dividia-nvr-boot-storage-adopt.XXXXXX) || return 1
    chmod 0700 "$snapshot" || { rmdir "$snapshot"; return 1; }
    boot_storage_snapshot_one "$snapshot" helper "$BOOT_STORAGE_HELPER" \
        && boot_storage_snapshot_one "$snapshot" docker-dropin "$BOOT_STORAGE_DOCKER_DROPIN" \
        && boot_storage_snapshot_one "$snapshot" containerd-dropin "$BOOT_STORAGE_CONTAINERD_DROPIN" \
        && boot_storage_snapshot_one "$snapshot" docker-init "$BOOT_STORAGE_DOCKER_INIT" \
        && boot_storage_snapshot_one "$snapshot" compose-overlay "$BOOT_STORAGE_COMPOSE_OVERLAY" \
        && boot_storage_snapshot_one "$snapshot" env "$AIENGINE_ENV_FILE" \
        && boot_storage_snapshot_mask_metadata "$snapshot" || {
            rm -rf "$snapshot"
            return 1
        }
    printf '%s\n' "$snapshot"
}

boot_storage_restore_artifacts() {
    local snapshot="$1" failed=0
    boot_storage_restore_one "$snapshot" helper "$BOOT_STORAGE_HELPER" || failed=1
    boot_storage_restore_one "$snapshot" docker-dropin "$BOOT_STORAGE_DOCKER_DROPIN" || failed=1
    boot_storage_restore_one "$snapshot" containerd-dropin "$BOOT_STORAGE_CONTAINERD_DROPIN" || failed=1
    boot_storage_restore_one "$snapshot" docker-init "$BOOT_STORAGE_DOCKER_INIT" || failed=1
    boot_storage_restore_one "$snapshot" compose-overlay "$BOOT_STORAGE_COMPOSE_OVERLAY" || failed=1
    boot_storage_restore_one "$snapshot" env "$AIENGINE_ENV_FILE" || failed=1
    boot_storage_restore_mask_metadata "$snapshot" || failed=1
    if boot_storage_systemd_host; then
        systemctl daemon-reload >/dev/null 2>&1 || failed=1
    fi
    if [ "$failed" -eq 0 ]; then
        rm -rf "$snapshot"
        return 0
    fi
    echo "ERROR: rollback snapshot retained at $snapshot" >&2
    return 1
}

boot_storage_adopt() {
    local source="${1:-}" target="${2:-}" store config_dir config_tmp existing_mapping="" snapshot=""
    if [ -z "$source" ] || [ -z "$target" ] || [ "$#" -ne 2 ]; then
        echo "Usage: nvr boot-storage adopt SOURCE TARGET" >&2
        return 1
    fi
    boot_storage_require_root || return 1
    if boot_storage_consumers_exist; then
        echo "ERROR: remove existing NVR containers before adopting new boot storage" >&2
        echo "Their stored restart configuration does not contain the required mask." >&2
        return 1
    fi

    case "$source$target" in
        *[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'//'*|*'/..'|*'/.' )
            echo "ERROR: boot-storage paths must be absolute, normalized, and use safe characters" >&2
            return 1
            ;;
    esac
    store=$(boot_storage_store_for_source "$source") || {
        echo "ERROR: source must be below $BOOT_STORAGE_VIDEOSTORE_ROOT/vsN" >&2
        return 1
    }
    boot_storage_target_allowed "$target" || {
        echo "ERROR: unsupported target: $target" >&2
        return 1
    }
    if [ -e "$BOOT_STORAGE_CONFIG" ]; then
        existing_mapping=$(boot_storage_read_mapping) || {
            echo "ERROR: existing boot-storage config is invalid: $BOOT_STORAGE_CONFIG" >&2
            return 1
        }
        if [ "$existing_mapping" != "$source $target" ]; then
            echo "ERROR: $BOOT_STORAGE_CONFIG already declares: $existing_mapping" >&2
            echo "Changing boot storage needs a planned maintenance action." >&2
            return 1
        fi
    fi
    [ -d "$source" ] || { echo "ERROR: source directory does not exist: $source" >&2; return 1; }
    [ -d "$target" ] || { echo "ERROR: target directory does not exist: $target" >&2; return 1; }
    mountpoint -q "$store" 2>/dev/null || {
        echo "ERROR: VideoStore is not mounted: $store" >&2
        return 1
    }
    boot_storage_fstab_noauto "$store" || {
        echo "ERROR: $store needs exactly one noauto entry in $BOOT_STORAGE_FSTAB" >&2
        return 1
    }
    boot_storage_store_identity_matches_fstab "$store" || {
        echo "ERROR: live mount at $store does not match its fstab source" >&2
        return 1
    }
    if boot_storage_target_in_fstab "$target"; then
        echo "ERROR: remove the existing $target entry from $BOOT_STORAGE_FSTAB before adoption" >&2
        echo "The root helper must own this bind so mount -a cannot race Docker." >&2
        return 1
    fi
    if ! mountpoint -q "$target" 2>/dev/null || ! [ "$source" -ef "$target" ]; then
        echo "ERROR: $target is not an active bind of $source" >&2
        echo "Move the data and establish the bind before adoption." >&2
        return 1
    fi
    boot_storage_source_safe "$source" "$store" || return 1
    boot_storage_underlying_target_safe "$store" || return 1
    boot_storage_underlying_target_safe "$target" || return 1

    # Prepare the config and snapshot before installing any guard artifact.
    # A pre-publish failure restores the complete prior artifact set.
    if [ -z "$existing_mapping" ]; then
        config_dir="$(dirname "$BOOT_STORAGE_CONFIG")"
        mkdir -p "$config_dir" || return 1
        chmod 0755 "$config_dir" 2>/dev/null || true
        config_tmp=$(mktemp "$config_dir/.boot-storage.conf.XXXXXX") || return 1
        {
            echo "# Managed by: nvr boot-storage adopt"
            printf '%s %s\n' "$source" "$target"
        } > "$config_tmp"
        chmod 0644 "$config_tmp" || { rm -f "$config_tmp"; return 1; }
        snapshot=$(boot_storage_snapshot_artifacts) || {
            rm -f "$config_tmp"
            echo "ERROR: could not snapshot existing boot-storage artifacts" >&2
            return 1
        }
    fi

    NVR_BOOT_STORAGE_PREPARE=1 NVR_BOOT_STORAGE_STAGED_CONFIG="${config_tmp:-$BOOT_STORAGE_CONFIG}" ensure_boot_service
    if ! boot_storage_artifacts_ready; then
        [ -z "$config_tmp" ] || rm -f "$config_tmp"
        if [ -n "$snapshot" ] && ! boot_storage_restore_artifacts "$snapshot"; then
            echo "ERROR: artifact rollback failed; inspect the pre-Docker hooks before reboot" >&2
        fi
        return 1
    fi
    if ! boot_storage_container_mask_ready "$source"; then
        [ -z "$config_tmp" ] || rm -f "$config_tmp"
        if [ -n "$snapshot" ] && ! boot_storage_restore_artifacts "$snapshot"; then
            echo "ERROR: artifact rollback failed; inspect the pre-Docker hooks before reboot" >&2
        fi
        return 1
    fi

    if [ -n "$existing_mapping" ]; then
        "$BOOT_STORAGE_HELPER" || return 1
        echo "Boot storage is already adopted: $source -> $target"
        return 0
    fi

    if ! mv -f "$config_tmp" "$BOOT_STORAGE_CONFIG"; then
        rm -f "$config_tmp"
        if [ -n "$snapshot" ] && ! boot_storage_restore_artifacts "$snapshot"; then
            echo "ERROR: artifact rollback failed; inspect the pre-Docker hooks before reboot" >&2
        fi
        return 1
    fi

    if ! "$BOOT_STORAGE_HELPER"; then
        if [ -n "$snapshot" ] && boot_storage_restore_artifacts "$snapshot"; then
            rm -f "$BOOT_STORAGE_CONFIG"
            echo "ERROR: the pre-Docker helper rejected the mapping; adoption was rolled back" >&2
        else
            echo "ERROR: artifact rollback failed; inspect the pre-Docker hooks before reboot" >&2
            NVR_BOOT_STORAGE_PREPARE=1 NVR_BOOT_STORAGE_STAGED_CONFIG="$BOOT_STORAGE_CONFIG" ensure_boot_service
            if boot_storage_artifacts_ready && boot_storage_container_mask_ready "$source"; then
                echo "The new guard was re-converged. The mapping and recovery snapshot remain." >&2
            else
                boot_storage_stop_videostore_consumers
                echo "ERROR: the guard could not be restored. Keep Docker stopped and repair it before reboot." >&2
            fi
        fi
        return 1
    fi
    [ -z "$snapshot" ] || rm -rf "$snapshot"
    echo "Adopted boot storage: $source -> $target"
    echo "Containerd and Docker will now fail closed until this bind is verified."
    return 0
}

cmd_boot_storage() {
    local action="${1:-status}"
    shift || true
    case "$action" in
        status) boot_storage_print_status "$@" ;;
        adopt) boot_storage_adopt "$@" ;;
        *)
            echo "Usage: nvr boot-storage {status|adopt SOURCE TARGET}" >&2
            return 1
            ;;
    esac
}

ensure_host_config() {
    # Only the bare-metal root install owns these host files. Skip quietly for
    # non-root callers (dev mode, in-container invocations).
    if [[ $EUID -ne 0 ]]; then
        return 0
    fi
    ensure_dividia_sudoers || true
    ensure_docker_log_cap || true
    ensure_reserved_service_ports || true
    ensure_rc_local_legacy_block || true
    ensure_nvr_symlink || true
    return 0
}

# Put `nvr` on a PATH that BOTH the dividia user and sudo's secure_path include.
# The script itself lives at $INSTALL_DIR/nvr (/opt/dividia/nvr), but sudo's
# secure_path omits /opt/dividia, so `sudo nvr ...` fails command-not-found and
# operators must type the full path. /usr/local/bin is on secure_path and every
# login PATH, so a symlink there makes `nvr` and `sudo nvr` both resolve. Paired
# with the self-elevation gate below, an operator never has to think about sudo.
# Idempotent; refuses to clobber a non-symlink already at that path.
ensure_nvr_symlink() {
    local target="$INSTALL_DIR/nvr"
    [[ -x "$target" ]] || return 0
    # Already correct: no-op (keeps the fast path silent on every update).
    if [[ -L "$NVR_BIN_SYMLINK" ]] \
        && [[ "$(readlink "$NVR_BIN_SYMLINK" 2>/dev/null)" == "$target" ]]; then
        return 0
    fi
    # Never replace a real file we did not create (a distro package, a
    # hand-placed wrapper). Only a missing entry or an existing symlink.
    if [[ -e "$NVR_BIN_SYMLINK" && ! -L "$NVR_BIN_SYMLINK" ]]; then
        echo "WARN: $NVR_BIN_SYMLINK exists and is not a symlink; leaving it untouched" >&2
        return 0
    fi
    mkdir -p "$(dirname "$NVR_BIN_SYMLINK")" 2>/dev/null || true
    if ln -sfn "$target" "$NVR_BIN_SYMLINK" 2>/dev/null; then
        echo "nvr: linked $NVR_BIN_SYMLINK -> $target"
    else
        echo "WARN: could not create $NVR_BIN_SYMLINK symlink" >&2
    fi
    return 0
}

# Canonicalize a comma-separated Linux ip_local_reserved_ports value plus one
# required range. POSIX awk keeps this host-side code working on CO6/CO7 as
# well as CO9/UB24. Expanding at most 65,535 array entries is cheap and lets us
# coalesce overlaps rather than asking the kernel to accept duplicate ranges.
merge_reserved_port_ranges() {
    local current="${1:-}"
    local required="${2:-$NVR_SERVICE_PORT_RANGE}"
    printf '%s\n' "${current}${current:+,}${required}" | awk -F, '
        function trim(s) {
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", s)
            return s
        }
        {
            for (i = 1; i <= NF; i++) {
                token = trim($i)
                if (token == "") continue
                count = split(token, edge, "-")
                if (count == 1 && edge[1] ~ /^[0-9]+$/) {
                    first = edge[1] + 0
                    last = first
                } else if (count == 2 && edge[1] ~ /^[0-9]+$/ && edge[2] ~ /^[0-9]+$/) {
                    first = edge[1] + 0
                    last = edge[2] + 0
                } else {
                    invalid = 1
                    continue
                }
                if (first < 1 || last > 65535 || first > last) {
                    invalid = 1
                    continue
                }
                for (port = first; port <= last; port++) reserved[port] = 1
            }
        }
        END {
            if (invalid) exit 2
            separator = ""
            port = 1
            while (port <= 65535) {
                if (!(port in reserved)) {
                    port++
                    continue
                }
                first = port
                while (port < 65535 && ((port + 1) in reserved)) port++
                last = port
                if (first == last) printf "%s%d", separator, first
                else printf "%s%d-%d", separator, first, last
                separator = ","
                port++
            }
            print ""
        }
    '
}

# Keep fixed NVR listeners out of Linux's ephemeral client-port allocator.
#
# All NVR containers use network_mode: host. An outbound camera connection can
# otherwise receive a local source port before the corresponding NVR service
# binds its listener, causing that service to crash-loop. Reserving the complete
# 43202-43210 product range prevents the allocator from creating that collision.
#
# Merge rather than replace: other software may own reservations on the same
# appliance. Apply live with sysctl -w and persist the exact same canonical
# union for reboot. This does not evict a socket that already owns a port; the
# monitor's notification-port collision alert still identifies that recovery
# case.
ensure_reserved_service_ports() {
    local current merged wanted file tmp mode
    file="$SYSCTL_RESERVED_PORTS_FILE"

    if ! current=$(sysctl -n net.ipv4.ip_local_reserved_ports 2>/dev/null); then
        echo "WARN: kernel does not expose net.ipv4.ip_local_reserved_ports; NVR service ports were not reserved" >&2
        return 0
    fi
    if ! merged=$(merge_reserved_port_ranges "$current" "$NVR_SERVICE_PORT_RANGE"); then
        echo "WARN: current ip_local_reserved_ports value is invalid; left live and persistent settings untouched" >&2
        return 0
    fi
    wanted="net.ipv4.ip_local_reserved_ports = $merged"

    if [[ "$current" != "$merged" ]]; then
        if ! sysctl -w "net.ipv4.ip_local_reserved_ports=$merged" >/dev/null 2>&1; then
            echo "WARN: could not reserve NVR service ports live; left $file untouched" >&2
            return 0
        fi
        echo "nvr: reserved host service ports $NVR_SERVICE_PORT_RANGE from the ephemeral allocator"
    fi

    if ! mkdir -p "$(dirname "$file")" 2>/dev/null; then
        echo "WARN: could not create service-port reservation directory for $file" >&2
        return 0
    fi
    if [[ -f "$file" ]] && grep -qxF "$wanted" "$file" 2>/dev/null; then
        mode=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "")
        [[ -z "$mode" || "$mode" == "644" ]] || chmod 0644 "$file" 2>/dev/null || true
        return 0
    fi

    tmp="${file}.new.$$"
    if printf '%s\n' "$wanted" > "$tmp" 2>/dev/null; then
        chmod 0644 "$tmp" 2>/dev/null || true
        if ! mv -f "$tmp" "$file" 2>/dev/null; then
            rm -f "$tmp" 2>/dev/null || true
            echo "WARN: could not persist NVR service-port reservation to $file" >&2
        fi
    else
        rm -f "$tmp" 2>/dev/null || true
        echo "WARN: could not write NVR service-port reservation to $file" >&2
    fi
    return 0
}

# (c) Neutralize the RPM-era `### dvs start ###` block in rc.local on a migrated
#     Docker host, keeping the one part of it that still matters.
#
# install-nvr.sh has removed this block since the migration flow was written, but
# it edited the WRONG FILE. On CentOS 7/9 `/etc/rc.local` is a symlink to
# `/etc/rc.d/rc.local`, and `sed -i` on a symlink REPLACES the symlink with a
# regular file: the edit lands on a brand-new /etc/rc.local that systemd never
# reads, while `rc-local.service` keeps running the untouched
# `/etc/rc.d/rc.local`. It logged "Removed DVS block from rc.local" and changed
# nothing. Found on cs256 2026-07-26 (473-byte /etc/rc.local dated the migration,
# 1586-byte /etc/rc.d/rc.local dated before it and still carrying the block).
#
# What the surviving block does on every boot of a migrated NVR:
#   systemctl start mariadb          <- a SECOND MariaDB. The containers run
#                                       network_mode: host, so both want :3306.
#                                       Harmless only because the migration
#                                       masks/removes the unit; on any box where
#                                       it is still installed and unmasked this
#                                       is a stack-down race for the port.
#   systemctl start rda-backend      <- unit gone; fails
#   /usr/local/bin/videostore-conf-sync.py   <- shipped by rda-autofs, removed
#   /usr/bin/rdalog --msg ...        <- shipped by an rda RPM, removed
# The failures are why a migrated box reports systemd `degraded`.
#
# It does NOT delete the whole block. The block is also the only thing on a
# migrated NVR that sets the CPU governor (`tuned-adm profile
# throughput-performance`, or the per-CPU scaling_governor loop on CO6) -- I
# checked, nothing in the Docker install replaces it -- and dropping that on a
# fleet of video encoders to silence a log line is the wrong trade. So the block
# is REPLACED by a Docker-appropriate one that keeps the tuning and drops the
# dead service starts.
ensure_rc_local_legacy_block() {
    local rc real backup
    # The file systemd actually executes. rc-local.service on CO7/CO9 declares
    # ExecStart=/etc/rc.d/rc.local, so prefer it, and resolve any symlink so the
    # edit cannot land on the link instead of the target (the original bug).
    for rc in /etc/rc.d/rc.local /etc/rc.local; do
        [[ -f "$rc" ]] || continue
        real=$(readlink -f "$rc" 2>/dev/null || echo "$rc")
        [[ -n "$real" && -f "$real" ]] || continue
        # The trigger is simply "a legacy block is present". No separate
        # already-converted check is needed, because the replacement does not
        # emit a `### dvs start ###` line -- so a converted file no longer
        # matches and a second run is a no-op by construction. A marker-based
        # skip would ALSO have made the write-once backup guard below
        # unreachable, and therefore untestable.
        grep -q '^### dvs start ###' "$real" 2>/dev/null || continue

        # REFUSE without a matching column-0 end marker.
        #
        # The awk below clears `inblock` only on /^### dvs end ###/, so a start
        # marker with no such line deletes everything from it to EOF -- and the
        # "refuse a truncated result" guard cannot see that, because the shebang
        # and non-empty tests both live ABOVE the block. Measured by review against
        # this function: with the end marker INDENTED, three operator lines below
        # the block went to zero, rc=0, and it printed the success line. A
        # tech-added VLAN route or ethtool line would be gone from the file root
        # runs at boot, silently, from the nightly root cron.
        #
        # Reachable without anyone hand-editing anything: the generator that wrote
        # these blocks appends the start and end markers in separate steps
        # (rda-db/src/setup/scripts/type/rc-local), so a run killed between them
        # leaves a start with no end.
        if ! grep -q '^### dvs end ###' "$real" 2>/dev/null; then
            echo "WARNING: $real has '### dvs start ###' with no matching '### dvs end ###' at column 0;"
            echo "WARNING: refusing to touch it (converting would delete every line below the marker)."
            continue
        fi

        # If our block is already there, the tuning is in place, so a legacy
        # block that reappeared (an operator paste, an old installer re-run) is
        # dropped rather than replaced -- otherwise the tuning is emitted twice.
        local mode=replace
        grep -q '^### dvs docker-migrated ###' "$real" 2>/dev/null && mode=drop

        backup="${real}.pre-docker"
        [[ -f "$backup" ]] || cp -p "$real" "$backup" 2>/dev/null || true

        local tmp
        tmp=$(mktemp "${real}.dividia.XXXXXX" 2>/dev/null) || return 0
        # Replace the marked region; everything outside it is the operator's and
        # is copied through untouched.
        awk -v mode="$mode" '
            /^### dvs start ###/ { inblock=1
                if( mode == "drop" ) next
                print "### dvs docker-migrated ###"
                print "# RPM-era NVR startup block, replaced on Docker migration."
                print "#"
                print "# Removed: the host-service starts for the database and the backend"
                print "# (the container stack owns both, and a host database daemon would"
                print "# contend for port 3306 under network_mode: host), and two helper"
                print "# binaries that shipped in RPMs no longer installed here. Also the"
                print "# rda_postboot run-once handler: nothing in the Docker tree writes"
                print "# that directory any more, and the handler referenced a log variable"
                print "# this file never defined, so every line of it errored anyway."
                print "#"
                print "# Kept: the CPU governor tuning below. Nothing else on a Docker NVR"
                print "# sets it, and these are video encoders."
                print "#"
                print "# Deliberately no literal command names above: an operator grepping"
                print "# this file for a service start must not get a hit from a comment"
                print "# saying it was taken out."
                print "if [ -e /usr/sbin/tuned-adm ] ; then"
                print "\ttuned-adm profile throughput-performance"
                print "else"
                print "\tfor CPU in 0 1 2 3 4 5 6 7 8 9 ; do"
                print "\t\tif [ -e /sys/devices/system/cpu/cpu${CPU}/cpufreq/scaling_governor ] ; then"
                print "\t\t\techo performance >/sys/devices/system/cpu/cpu${CPU}/cpufreq/scaling_governor"
                print "\t\tfi"
                print "\tdone"
                print "fi"
                print "### dvs end ###"
                next }
            /^### dvs end ###/ {
                # ONLY the marker that closes a block we are inside. Dropping
                # every end marker also removed the converted block own closer
                # (it is preceded by the docker-migrated marker, not by a start),
                # leaving the file with no end marker at all -- after which the
                # guard above correctly refuses to touch it, forever.
                if( inblock ) { inblock=0; next }
            }
            !inblock { print }
        ' "$real" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }

        # Refuse to install a truncated or non-script result. rc.local runs as
        # root at boot; a mangled one is worse than a noisy one.
        if [[ ! -s "$tmp" ]] || ! head -1 "$tmp" | grep -q '^#!'; then
            rm -f "$tmp"
            echo "WARNING: refusing to rewrite $real (unexpected result); left as-is"
            return 0
        fi
        # Count the lines OUTSIDE our markers before and after. The shebang and
        # non-empty checks above only look at the top of the file, so they cannot
        # detect a truncated tail; this can.
        local before_outside after_outside
        # SAME anchor set on both sides, or this compares apples to oranges: the
        # first version anchored the "before" count on `dvs start` and the "after"
        # count on `dvs docker-migrated`, so a file containing both counted
        # different regions and the check refused a correct conversion.
        # PLAIN awk. `awk -E` is not "use ERE" -- it is gawk's --exec, so it took
        # the program text as a FILENAME, printed nothing, and both counts came
        # back empty; the fallback below then set them equal and silently disabled
        # this whole check. POSIX awk already supports alternation inside /.../,
        # so no flag is needed, and the fallback is gone so a future failure is
        # visible instead of self-muting.
        local vs_outside='/^### dvs (start|docker-migrated) ###/{i=1} /^### dvs end ###/{i=0;next} !i'
        before_outside=$( awk "$vs_outside" "$real" 2>/dev/null | wc -l | tr -d ' ' )
        after_outside=$( awk "$vs_outside" "$tmp" 2>/dev/null | wc -l | tr -d ' ' )
        if [ "${after_outside:-0}" -lt "${before_outside:-0}" ]; then
            rm -f "$tmp"
            echo "WARNING: refusing to rewrite $real: $before_outside lines outside the block became $after_outside"
            continue
        fi
        chmod --reference="$real" "$tmp" 2>/dev/null || chmod 755 "$tmp"
        mv -f "$tmp" "$real" 2>/dev/null || { rm -f "$tmp"; return 0; }
        echo "Replaced the RPM-era dvs block in $real (backup: $backup)"
    done
    return 0
}

# (a) /etc/sudoers.d/dividia grants passwordless sudo without requiring a TTY,
#     mode 440. CentOS 6 enables `Defaults requiretty` globally, so the
#     per-user exception is required for nvr-monitor and other headless tools.
#     Only rewrites when missing or wrong, and NEVER installs a sudoers file
#     that fails `visudo -cf` validation (a bad drop-in can lock every user
#     out of sudo) — validate on a temp file, then atomically move into place.
ensure_dividia_sudoers() {
    local want="dividia ALL=(ALL) NOPASSWD: ALL"
    local no_tty="Defaults:dividia !requiretty"
    mkdir -p "$(dirname "$SUDOERS_FILE")" 2>/dev/null || true

    # Fast idempotent path: correct content already present. Fix only the mode
    # if it drifted off 440.
    if [[ -f "$SUDOERS_FILE" ]] \
        && grep -qxF "$no_tty" "$SUDOERS_FILE" 2>/dev/null \
        && grep -qxF "$want" "$SUDOERS_FILE" 2>/dev/null; then
        local mode
        mode=$(stat -c '%a' "$SUDOERS_FILE" 2>/dev/null || stat -f '%Lp' "$SUDOERS_FILE" 2>/dev/null || echo "")
        if [[ -n "$mode" && "$mode" != "440" ]]; then
            chmod 440 "$SUDOERS_FILE" 2>/dev/null || true
            echo "nvr: fixed mode on $SUDOERS_FILE ($mode -> 440)"
        fi
        return 0
    fi

    # Missing or wrong: write a temp file, set 440, validate, then atomic move.
    local tmp
    tmp=$(mktemp "${SUDOERS_FILE}.new.XXXXXX" 2>/dev/null) || tmp="${SUDOERS_FILE}.new.$$"
    printf '%s\n%s\n' "$no_tty" "$want" > "$tmp" 2>/dev/null \
        || { rm -f "$tmp" 2>/dev/null || true; return 0; }
    chmod 440 "$tmp" 2>/dev/null || true
    if command -v visudo >/dev/null 2>&1; then
        if ! visudo -cf "$tmp" >/dev/null 2>&1; then
            rm -f "$tmp" 2>/dev/null || true
            echo "WARN: generated dividia sudoers failed visudo validation; left $SUDOERS_FILE untouched" >&2
            return 0
        fi
    fi
    if mv -f "$tmp" "$SUDOERS_FILE" 2>/dev/null; then
        echo "nvr: (re)installed $SUDOERS_FILE (dividia passwordless sudo)"
    else
        rm -f "$tmp" 2>/dev/null || true
        echo "WARN: could not install $SUDOERS_FILE" >&2
    fi
    return 0
}

# (b) /etc/docker/daemon.json must carry the json-file log cap
#     (max-size 10m, max-file 3), MERGED into any existing JSON so data-root /
#     storage-driver etc. are preserved. Only touches the file when the cap is
#     missing. Does NOT restart docker (too disruptive from an update): a
#     daemon.json change only affects containers created after a daemon
#     reload, and the compose per-service logging cap already bounds the
#     running stack — so we just fix the file and log that a docker restart
#     applies it to running containers.
ensure_docker_log_cap() {
    local daemon="$DOCKER_DAEMON_JSON"
    mkdir -p "$(dirname "$daemon")" 2>/dev/null || true

    local py=""
    if command -v python3 >/dev/null 2>&1; then py=python3
    elif command -v python >/dev/null 2>&1; then py=python; fi

    if [[ -n "$py" ]]; then
        # Authoritative JSON merge. Prints CHANGED / UNCHANGED so we know
        # whether to advise a restart, and only rewrites the file when a value
        # actually differs (byte-identical no-op on the second run).
        local py_body result
        # Bash 4.1 cannot reliably parse a heredoc nested inside $(). Capture
        # the program first, then keep the command substitution itself plain.
        IFS= read -r -d '' py_body <<'PYEOF' || true
import json, os, sys
path = sys.argv[1]
try:
    with open(path) as f:
        cfg = json.load(f)
    if not isinstance(cfg, dict):
        cfg = {}
except FileNotFoundError:
    cfg = {}
except Exception:
    # Unparseable existing daemon.json: do NOT rebuild from empty (that would
    # silently drop data-root and any other keys). Leave the file untouched and
    # report so an operator can fix the JSON by hand; the log cap is simply not
    # applied this run.
    print("PARSE_ERROR")
    sys.exit(0)

want_opts = {'max-size': '10m', 'max-file': '3'}
changed = False
if cfg.get('log-driver') != 'json-file':
    cfg['log-driver'] = 'json-file'
    changed = True
opts = cfg.get('log-opts')
if not isinstance(opts, dict):
    opts = {}
for k, v in want_opts.items():
    if opts.get(k) != v:
        opts[k] = v
        changed = True
cfg['log-opts'] = opts
if changed:
    tmp = path + '.new'
    with open(tmp, 'w') as f:
        json.dump(cfg, f, indent=2)
    os.replace(tmp, path)
    print('CHANGED')
else:
    print('UNCHANGED')
PYEOF
        result=$("$py" -c "$py_body" "$daemon" 2>/dev/null) || result="ERROR"
        case "$result" in
            *CHANGED*)
                echo "nvr: added Docker log cap to $daemon (10m x 3). Run 'systemctl restart docker' to apply it to running containers (the compose per-service logging cap already bounds the current stack)." ;;
            *UNCHANGED*)
                : ;;
            *PARSE_ERROR*)
                echo "WARN: $daemon is not valid JSON; left untouched (fix by hand, then re-run). Docker log cap NOT applied." >&2 ;;
            *)
                echo "WARN: could not update Docker log cap in $daemon" >&2 ;;
        esac
        return 0
    fi

    # No python: safe fallback. Write a fresh capped file only if none exists;
    # never text-merge into an existing daemon.json (would risk clobbering
    # data-root etc. without a real JSON parser).
    if [[ ! -f "$daemon" ]]; then
        cat > "$daemon" <<'DAEMONJSON'
{
    "log-driver": "json-file",
    "log-opts": {
        "max-size": "10m",
        "max-file": "3"
    }
}
DAEMONJSON
        echo "nvr: wrote $daemon with Docker log cap (10m x 3). Restart docker to apply to running containers."
    elif ! grep -q 'max-size' "$daemon" 2>/dev/null; then
        echo "WARN: $daemon exists without a log cap and python is unavailable to merge safely; left it untouched." >&2
    fi
    return 0
}

# Atomic handoff from watchtower to cron. Invoked from cmd_update after
# `$COMPOSE up -d --quiet-pull --remove-orphans` returns success — gated on
# an explicit `compose_up_ok` flag, not on the surrounding `set -e` chain.
# The flag-based gate exists because cs2427 2026-05-28 surfaced a silent
# chain-break: cron was installed (L106 of cmd_update fires) but this
# function never ran (set -e aborted somewhere between L106 and the
# original tail-of-function call site). See
# operational_nvr_update_first_migration_skip_watchtower_drop.md.
#
# Always-attempt + silent-on-missing: handles fresh-install (no watchtower
# yet), already-removed (subsequent updates), and partial states (e.g.
# previous disable killed the container but `docker rm` raced with a
# manual cleanup). The `|| true` is load-bearing — a missing-container
# error here under set -e would abort cmd_update mid-flight on every
# steady-state run.
cmd_update_disable_watchtower() {
    docker stop dividia-nvr-watchtower-1 2>/dev/null || true
    docker rm dividia-nvr-watchtower-1 2>/dev/null || true
}

cmd_prune() {
    local quiet=0
    if [[ "${1:-}" == "--quiet" ]]; then quiet=1; fi

    # Two-pass prune. Dangling-only pass runs first with no time filter:
    # those <none>:<none> images are by definition replaced/orphaned and
    # immediately safe to remove. The 168h pass catches still-tagged
    # images that haven't been used in a week. Without the dangling pass,
    # active dev iteration (e.g. a feature channel rebuilt several times
    # in one day) accumulates GB of dangling images that the time filter
    # won't release until a week later — surfaced when cs256 hit /opt full
    # mid-pilot.
    [[ $quiet -eq 0 ]] && echo "Pruning unused images..."
    local out
    out=$(docker image prune -f 2>&1; docker image prune -a -f --filter "until=168h" 2>&1) || {
        [[ $quiet -eq 0 ]] && echo -e "${RED}prune failed:${NC} $out"
        return 0
    }

    # Sum the two "Total reclaimed space:" lines into a single number.
    # docker prints sizes like "4.154GB" or "523.1MB"; convert to bytes,
    # add, format back so the user sees one tidy number.
    local total_bytes=0 line size unit bytes
    while IFS= read -r line; do
        size=$(echo "$line" | sed -E 's/^Total reclaimed space: //; s/([0-9.]+)([A-Za-z]+)$/\1 \2/')
        [[ -z "$size" ]] && continue
        bytes=$(awk -v s="$size" 'BEGIN {
            split(s, parts, " ")
            n = parts[1] + 0
            unit = parts[2]
            mult = 1
            if (unit == "kB" || unit == "KB") mult = 1024
            else if (unit == "MB") mult = 1024 * 1024
            else if (unit == "GB") mult = 1024 * 1024 * 1024
            else if (unit == "TB") mult = 1024 * 1024 * 1024 * 1024
            printf "%d", n * mult
        }')
        total_bytes=$((total_bytes + bytes))
    done < <(echo "$out" | grep -E "^Total reclaimed space")

    local reclaimed
    reclaimed=$(awk -v b="$total_bytes" 'BEGIN {
        if (b >= 1024^3)      printf "Total reclaimed space: %.2fGB", b / 1024^3
        else if (b >= 1024^2) printf "Total reclaimed space: %.1fMB", b / 1024^2
        else if (b >= 1024)   printf "Total reclaimed space: %.1fkB", b / 1024
        else                  printf "Total reclaimed space: %dB", b
    }')

    if [[ $quiet -eq 1 ]]; then
        # Only print when we actually freed something, to keep nvr update output tight.
        [[ $total_bytes -gt 0 ]] && echo "$reclaimed"
    else
        echo "$reclaimed"
    fi
}

nvr_timeout_binary() {
	if [[ -x /usr/bin/timeout ]]; then
		printf '%s\n' /usr/bin/timeout
	elif [[ -x /bin/timeout ]]; then
		printf '%s\n' /bin/timeout
	else
		command -v timeout
	fi
}

nvr_backup_inner_timeout() {
	local outer_seconds="$1"
	if [[ "$outer_seconds" -gt 1 ]]; then
		printf '%ss\n' $((outer_seconds - 1))
	else
		printf '%s\n' 0.5s
	fi
}

nvr_backup_run_until_deadline() {
	local deadline="$1" timeout_binary="$2" remaining
	shift 2
	remaining=$((deadline - $(date +%s)))
	[[ "$remaining" -gt 0 ]] || return 124
	"$timeout_binary" --signal=KILL "${remaining}s" "$@"
}

backup_lock_release() {
    flock -u 8 2>/dev/null || true
    exec 8>&-
}

compose_service_is_running() {
    local container running docker_command="${NVR_BACKUP_DOCKER_COMMAND:-docker}"
    container=$($COMPOSE ps -aq "$1" 2>/dev/null | sed -n '1p')
    [[ -n "$container" ]] || return 1
    running=$("$docker_command" inspect --format '{{.State.Running}}' "$container" 2>/dev/null) || return 1
    [[ "$running" == "true" ]]
}

update_source_image_id_is_safe() {
    local digest
    case "$1" in
        sha256:*) digest=${1#sha256:} ;;
        *) return 1 ;;
    esac
    [[ ${#digest} -eq 64 ]] || return 1
    case "$digest" in
        *[!0-9a-fA-F]*) return 1 ;;
    esac
}

update_source_service_image_id() {
    local service="$1" container image_id
    container=$($COMPOSE ps -aq "$service" 2>/dev/null | sed -n '1p')
    [[ -n "$container" ]] || return 1
    image_id=$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null) || return 1
    update_source_image_id_is_safe "$image_id" || return 1
    printf '%s\n' "$image_id"
}

update_source_service_name_is_safe() {
    case "$1" in
        ""|*[!a-zA-Z0-9_-]*) return 1 ;;
        *) return 0 ;;
    esac
}

update_source_core_service_images() {
    local services service image_id
    services=$(core_services | grep -vxF hme) || return 1
    [[ -n "$services" ]] || return 1
    while IFS= read -r service; do
        [[ -n "$service" ]] || continue
        update_source_service_name_is_safe "$service" || return 1
        image_id=$(update_source_service_image_id "$service") || return 1
        printf 'source_service_image=%s|%s\n' "$service" "$image_id"
    done <<< "$services"
}

update_source_compose_files_are_safe() {
    [[ -n "$1" ]] || return 1
    case "$1" in
        *$'\n'*|*$'\r'*) return 1 ;;
    esac
}

update_source_guard_compose_files() {
    local files="$NVR_UPDATE_SOURCE_COMPOSE:$NVR_UPDATE_SOURCE_OVERRIDE"
    local target_files
    target_files=$(update_source_target_compose_files) || return 1
    case ":$target_files:" in
        *":$BOOT_STORAGE_COMPOSE_OVERLAY:"*)
            [[ -f "$BOOT_STORAGE_COMPOSE_OVERLAY" && ! -L "$BOOT_STORAGE_COMPOSE_OVERLAY" ]] \
                || return 1
            files="$files:$BOOT_STORAGE_COMPOSE_OVERLAY"
            ;;
    esac
    printf '%s\n' "$files"
}

update_source_guard_is_active() {
    local expected
    expected=$(update_source_guard_compose_files) || return 1
    [[ $(compose_file_get) == "$expected" ]] || return 1
    [[ -f "$NVR_UPDATE_SOURCE_COMPOSE" && ! -L "$NVR_UPDATE_SOURCE_COMPOSE" ]] || return 1
    [[ -f "$NVR_UPDATE_SOURCE_OVERRIDE" && ! -L "$NVR_UPDATE_SOURCE_OVERRIDE" ]] || return 1
}

update_source_target_compose_files() {
    local target_files
    [[ -f "$NVR_UPDATE_SOURCE_STATE" && ! -L "$NVR_UPDATE_SOURCE_STATE" ]] || return 1
    [[ $(grep -c '^target_compose_files=' "$NVR_UPDATE_SOURCE_STATE" 2>/dev/null) -eq 1 ]] || return 1
    target_files=$(sed -n 's/^target_compose_files=//p' "$NVR_UPDATE_SOURCE_STATE")
    update_source_compose_files_are_safe "$target_files" || return 1
    printf '%s\n' "$target_files"
}

update_source_state_capture() {
    local backend_id database_id service_images target_files directory temporary
    backend_id=$(update_source_service_image_id backend) || return 1
    database_id=$(update_source_service_image_id db) || return 1
    service_images=$(update_source_core_service_images) || return 1
    if update_source_guard_is_active; then
        target_files=$(update_source_target_compose_files) || return 1
    else
        target_files=$(compose_file_get)
        update_source_compose_files_are_safe "$target_files" || return 1
    fi
    directory=$(dirname "$NVR_UPDATE_SOURCE_STATE")
    mkdir -p "$directory" || return 1
    [[ ! -L "$NVR_UPDATE_SOURCE_STATE" ]] || return 1
    temporary=$(mktemp "${NVR_UPDATE_SOURCE_STATE}.new.XXXXXX") || return 1
    if ! {
        printf '%s\n' \
            "backend_image_id=$backend_id" \
            "database_image_id=$database_id" \
            "target_compose_files=$target_files" \
            "$service_images" > "$temporary"
        chmod 0444 "$temporary"
        mv -f "$temporary" "$NVR_UPDATE_SOURCE_STATE"
    }; then
        rm -f "$temporary"
        return 1
    fi
}

update_source_state_read() {
    local backend_id database_id target_files service image_id
    local seen=" " backend_seen=0 database_seen=0
    [[ -f "$NVR_UPDATE_SOURCE_STATE" && ! -L "$NVR_UPDATE_SOURCE_STATE" ]] || return 1
    [[ $(grep -c '^backend_image_id=' "$NVR_UPDATE_SOURCE_STATE" 2>/dev/null) -eq 1 ]] || return 1
    [[ $(grep -c '^database_image_id=' "$NVR_UPDATE_SOURCE_STATE" 2>/dev/null) -eq 1 ]] || return 1
    [[ $(grep -c '^target_compose_files=' "$NVR_UPDATE_SOURCE_STATE" 2>/dev/null) -eq 1 ]] || return 1
    backend_id=$(sed -n 's/^backend_image_id=//p' "$NVR_UPDATE_SOURCE_STATE")
    database_id=$(sed -n 's/^database_image_id=//p' "$NVR_UPDATE_SOURCE_STATE")
    update_source_image_id_is_safe "$backend_id" || return 1
    update_source_image_id_is_safe "$database_id" || return 1
    target_files=$(update_source_target_compose_files) || return 1
    docker image inspect "$backend_id" >/dev/null 2>&1 || return 1
    docker image inspect "$database_id" >/dev/null 2>&1 || return 1
    [[ $(grep -c '^source_service_image=' "$NVR_UPDATE_SOURCE_STATE" 2>/dev/null) -ge 2 ]] || return 1
    while IFS='|' read -r service image_id; do
        update_source_service_name_is_safe "$service" || return 1
        update_source_image_id_is_safe "$image_id" || return 1
        case "$seen" in *" $service "*) return 1 ;; esac
        seen="$seen$service "
        docker image inspect "$image_id" >/dev/null 2>&1 || return 1
        case "$service" in
            backend) [[ "$image_id" == "$backend_id" ]] || return 1; backend_seen=1 ;;
            db) [[ "$image_id" == "$database_id" ]] || return 1; database_seen=1 ;;
        esac
    done < <(sed -n 's/^source_service_image=//p' "$NVR_UPDATE_SOURCE_STATE")
    [[ $backend_seen -eq 1 && $database_seen -eq 1 ]] || return 1
    printf '%s\n%s\n' "$backend_id" "$database_id"
}

update_source_override_write() {
    local destination="$1" service image_id
    {
        printf '%s\n' 'services:'
        while IFS='|' read -r service image_id; do
            update_source_service_name_is_safe "$service" || return 1
            update_source_image_id_is_safe "$image_id" || return 1
            printf '  %s:\n' "$service"
            printf "    image: '%s'\n" "$image_id"
        done < <(sed -n 's/^source_service_image=//p' "$NVR_UPDATE_SOURCE_STATE")
    } > "$destination"
}

update_source_guard_activate() {
    local facts backend_id database_id target_files directory
    local compose_temporary override_temporary guard_files

    if update_source_guard_is_active; then
        update_source_state_read >/dev/null
        return $?
    fi

    facts=$(update_source_state_read) || return 1
    backend_id=${facts%%$'\n'*}
    database_id=${facts#*$'\n'}
    target_files=$(update_source_target_compose_files) || return 1
    guard_files=$(update_source_guard_compose_files) || return 1
    directory=$(dirname "$NVR_UPDATE_SOURCE_COMPOSE")
    mkdir -p "$directory" || return 1
    [[ ! -L "$NVR_UPDATE_SOURCE_COMPOSE" && ! -L "$NVR_UPDATE_SOURCE_OVERRIDE" ]] || return 1
    compose_temporary=$(mktemp "${NVR_UPDATE_SOURCE_COMPOSE}.new.XXXXXX") || return 1
    override_temporary=$(mktemp "${NVR_UPDATE_SOURCE_OVERRIDE}.new.XXXXXX") || {
        rm -f "$compose_temporary"
        return 1
    }
    if ! COMPOSE_FILE="$target_files" $COMPOSE config > "$compose_temporary" ||
            [[ ! -s "$compose_temporary" ]] ||
            ! update_source_override_write "$override_temporary"; then
        rm -f "$compose_temporary" "$override_temporary"
        return 1
    fi
    env_preserve_perms "$AIENGINE_ENV_FILE" "$compose_temporary"
    env_preserve_perms "$AIENGINE_ENV_FILE" "$override_temporary"
    if ! mv -f "$compose_temporary" "$NVR_UPDATE_SOURCE_COMPOSE" ||
            ! mv -f "$override_temporary" "$NVR_UPDATE_SOURCE_OVERRIDE" ||
            ! env_write_var COMPOSE_FILE "$guard_files"; then
        rm -f "$compose_temporary" "$override_temporary"
        return 1
    fi
}

update_source_guard_release() {
    local target_files
    target_files=$(update_source_target_compose_files) || return 1
    env_write_var COMPOSE_FILE "$target_files"
}

update_source_state_start_missing() {
    local facts backend_id database_id current_files override services="" result=0
    facts=$(update_source_state_read) || return 1
    backend_id=${facts%%$'\n'*}
    database_id=${facts#*$'\n'}
    current_files=$(compose_file_get)
    [[ -n "$current_files" ]] || return 1
    override=$(mktemp "${TMPDIR:-/tmp}/nvr-update-source-compose.XXXXXX") || return 1
    if ! update_source_override_write "$override"; then
        rm -f "$override"
        return 1
    fi
    compose_service_is_running db || services="db"
    if ! compose_service_is_running backend; then
        services="${services:+$services }backend"
    fi
    if [[ -n "$services" ]]; then
        # The selected service list needs intentional word splitting.
        # shellcheck disable=SC2086
        COMPOSE_FILE="$current_files:$override" \
            $COMPOSE up -d --no-deps $services || result=$?
    fi
    rm -f "$override"
    return "$result"
}

backup_lock_acquire() {
	local removable_uuid="" removable_rc=0
	local backup_quiesced="${quiesced:-0}"
	local backup_timeout_seconds="${timeout_seconds:-}"
	local backup_timeout_deadline="${timeout_deadline:-0}"
	local backup_timeout_remaining
	if [[ "$backup_quiesced" -eq 0 ]] && ! compose_service_is_running backend; then
        echo -e "${RED}ERROR: NVR backend container is not running${NC}"
        return 1
    fi

    # Share one host-visible inode with mount, unmount, and recovery. Taking
    # the lock before reading durable intent closes the check-to-write race:
    # either this backup finishes first, or prepare-remove records intent first
    # and this command never opens the VideoStore for writing.
    if ! prepare_videostore_lock; then
        echo -e "${RED}ERROR: VideoStore lock path is not protected${NC}"
        return 1
    fi
    if ! exec 8>"$NVR_VIDEOSTORE_LOCK"; then
        echo -e "${RED}ERROR: Cannot open the VideoStore operation lock${NC}"
        return 1
    fi
	if [[ -n "$backup_timeout_seconds" ]]; then
		backup_timeout_remaining=$((backup_timeout_deadline - $(date +%s)))
		if [[ "$backup_timeout_remaining" -le 0 ]] || ! flock -x -w "$backup_timeout_remaining" 8; then
			echo -e "${RED}ERROR: Timed out waiting for the VideoStore operation lock${NC}"
			exec 8>&-
			return 1
		fi
    elif ! flock -x 8; then
        echo -e "${RED}ERROR: Cannot take the VideoStore operation lock${NC}"
        exec 8>&-
        return 1
    fi

    if videostore_intent_suppresses "" "/videostore/vs1"; then
        echo -e "${RED}ERROR: Backup blocked by removable VideoStore intent${NC}"
        backup_lock_release
        return 1
    fi

	removable_uuid=$(videostore_removable_uuid_for_path "/videostore/vs1") || removable_rc=$?
	if [ "$removable_rc" -gt 1 ]; then
		echo -e "${RED}ERROR: Backup blocked by invalid removable VideoStore config${NC}"
		backup_lock_release
		return 1
	fi

	if [ "$removable_rc" = 0 ] && \
	   ! videostore_live_uuid_mount "/videostore/vs1" "$removable_uuid"; then
        echo -e "${RED}ERROR: Backup blocked because removable /videostore/vs1 is not the configured mounted filesystem${NC}"
        backup_lock_release
        return 1
    fi

    return 0
}

backup_archive_snapshot() {
    if [[ "${quiesced:-0}" -eq 1 ]]; then
        local archive
        for archive in "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            [[ -d "$archive" && ! -L "$archive" ]] || continue
            printf '%s\n' "$archive"
        done
        return 0
    fi
    $COMPOSE exec -T backend sh -c '
        root=$1
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            test -d "$archive" && test ! -L "$archive" || continue
            printf "%s\n" "$archive"
        done
    ' sh "$NVR_UPDATE_BACKUP_ROOT"
}

backup_run_locked() {
    NVR_LAST_BACKUP_DIR=""
    NVR_LAST_BACKUP_OUTPUT=""
    local backup_dir backup_output before_archives after_archives candidate
    local fallback_count=0 result=0
    local backup_command=(rda-db --backup)

    if [[ $# -eq 8 || $# -eq 14 ]]; then
        backup_command=(
            env
            "NVR_UPDATE_BACKUP_SOURCE_BACKEND_ID=$1"
            "NVR_UPDATE_BACKUP_SOURCE_BACKEND_REVISION=$2"
            "NVR_UPDATE_BACKUP_TARGET_BACKEND_ID=$3"
            "NVR_UPDATE_BACKUP_TARGET_BACKEND_REVISION=$4"
            "NVR_UPDATE_BACKUP_SOURCE_DATABASE_ID=$5"
            "NVR_UPDATE_BACKUP_TARGET_DATABASE_ID=$6"
            "NVR_UPDATE_BACKUP_SOURCE_DATABASE_VERSION=$7"
            "NVR_UPDATE_BACKUP_TARGET_DATABASE_VERSION=$8"
        )
        if [[ $# -eq 14 ]]; then
            backup_command+=(
                "NVR_UPDATE_BACKUP_ACTIVATION_ID=$9"
                "NVR_UPDATE_BACKUP_ACTIVATION_EDGE_ID=${10}"
                "NVR_UPDATE_BACKUP_TARGET_DATABASE_DIGEST=${11}"
                "NVR_UPDATE_BACKUP_COMPOSE_BUNDLE_SHA256=${12}"
                "NVR_UPDATE_BACKUP_MIGRATION_GATE_VERSION=${13}"
                "NVR_UPDATE_BACKUP_ACTIVATION_MANIFEST_SHA256=${14}"
            )
        fi
        backup_command+=(rda-db --backup)
    elif [[ $# -ne 0 ]]; then
        echo -e "${RED}ERROR: Invalid automatic backup identity${NC}"
        return 1
    fi

    # The source backend can be one release older than this host CLI after an
    # interrupted first rollout. Snapshot exact archive names so that backend,
    # which does not yet print BACKUP_ARCHIVE_CREATED, remains compatible.
    before_archives=$(backup_archive_snapshot) || {
        echo -e "${RED}ERROR: Cannot inspect existing backup archives${NC}"
        return 1
    }

    echo "Starting NVR backup..."
    if [[ "${quiesced:-0}" -eq 1 ]]; then
        if [[ -n "${timeout_seconds:-}" ]]; then
            timeout_remaining=$((timeout_deadline - $(date +%s)))
            if [[ "$timeout_remaining" -le 0 ]]; then
                result=124
            else
                inner_timeout=$(nvr_backup_inner_timeout "$timeout_remaining")
                backup_output=$("$timeout_binary" --signal=KILL "${timeout_remaining}s" \
                    $COMPOSE run --rm --no-deps --entrypoint /usr/bin/timeout backend \
                    --signal=KILL "$inner_timeout" /usr/bin/rda-db --backup </dev/null 2>&1) \
                    || result=$?
            fi
        else
            backup_output=$($COMPOSE run --rm --no-deps --entrypoint /usr/bin/rda-db \
                backend --backup </dev/null 2>&1) || result=$?
        fi
    elif [[ -n "${timeout_seconds:-}" ]]; then
        timeout_remaining=$((timeout_deadline - $(date +%s)))
        if [[ "$timeout_remaining" -le 0 ]]; then
            result=124
        else
            inner_timeout=$(nvr_backup_inner_timeout "$timeout_remaining")
            backup_output=$("$timeout_binary" --signal=KILL "${timeout_remaining}s" \
                $COMPOSE exec -T backend /usr/bin/timeout --signal=KILL "$inner_timeout" \
                "${backup_command[@]}" </dev/null 2>&1) || result=$?
        fi
    else
        backup_output=$($COMPOSE exec -T backend "${backup_command[@]}" </dev/null 2>&1) \
            || result=$?
    fi
    NVR_LAST_BACKUP_OUTPUT="$backup_output"
    printf '%s\n' "$backup_output"
    backup_dir=$(printf '%s\n' "$backup_output" \
        | sed -n 's/^BACKUP_ARCHIVE_CREATED=//p' | sed -n '$p')
    case "$backup_dir" in
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])
            NVR_LAST_BACKUP_DIR="$backup_dir"
            ;;
        "") ;;
        *)
            echo -e "${RED}ERROR: Backup reported an unsafe archive path: $backup_dir${NC}"
            result=1
            ;;
    esac
    if [[ -z "$backup_dir" ]]; then
        if ! after_archives=$(backup_archive_snapshot); then
            echo -e "${RED}ERROR: Cannot inspect backup archives after backup${NC}"
            result=1
        else
            while IFS= read -r candidate; do
                [[ -n "$candidate" ]] || continue
                if ! grep -Fqx -- "$candidate" <<< "$before_archives"; then
                    backup_dir="$candidate"
                    fallback_count=$((fallback_count + 1))
                fi
            done <<< "$after_archives"
            if [[ $fallback_count -eq 1 ]]; then
                NVR_LAST_BACKUP_DIR="$backup_dir"
                echo "Detected backup archive from legacy backend: $backup_dir"
            elif [[ $fallback_count -gt 1 ]]; then
                echo -e "${RED}ERROR: Backup created multiple possible archives${NC}"
                result=1
            fi
        fi
    fi
    if [[ $result -ne 0 ]]; then
        echo -e "${RED}ERROR: Backup failed (exit code: $result)${NC}"
        return 1
    fi
    if [[ -z "$NVR_LAST_BACKUP_DIR" ]]; then
        echo -e "${RED}ERROR: Backup command did not create a new archive${NC}"
        return 1
    fi
    echo "Backup saved to: $NVR_LAST_BACKUP_DIR"

	# Save .env to VideoStore via backend container's /videostore bind mount
	if [[ -f .env && "${quiesced:-0}" -eq 0 ]]; then
		if [[ -n "${timeout_seconds:-}" ]]; then
			nvr_backup_run_until_deadline "$timeout_deadline" "$timeout_binary" \
				$COMPOSE cp .env backend:/videostore/vs1/backups/.env.save 2>/dev/null \
				&& echo "Saved .env to VideoStore" \
				|| echo -e "${YELLOW}WARN: Could not save .env to VideoStore${NC}"
		else
			$COMPOSE cp .env backend:/videostore/vs1/backups/.env.save 2>/dev/null \
            && echo "Saved .env to VideoStore" \
            || echo -e "${YELLOW}WARN: Could not save .env to VideoStore${NC}"
		fi
	elif [[ -f .env ]]; then
		if [[ -n "${timeout_seconds:-}" ]]; then
			nvr_backup_run_until_deadline "$timeout_deadline" "$timeout_binary" \
				cp .env /videostore/vs1/backups/.env.save 2>/dev/null \
				&& echo "Saved .env to VideoStore" \
				|| echo -e "${YELLOW}WARN: Could not save .env to VideoStore${NC}"
		else
			cp .env /videostore/vs1/backups/.env.save 2>/dev/null \
			&& echo "Saved .env to VideoStore" \
			|| echo -e "${YELLOW}WARN: Could not save .env to VideoStore${NC}"
		fi
    fi

    return 0
}

_cmd_backup_worker() {
    local quiesced=0 timeout_seconds="" timeout_deadline=0 timeout_binary=""
    local timeout_remaining=0 inner_timeout=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --quiesced)
                quiesced=1
                shift
                ;;
            --timeout-seconds)
                [[ $# -ge 2 ]] || {
                    echo "Usage: nvr backup [--quiesced] [--timeout-seconds N]" >&2
                    return 2
                }
                timeout_seconds="$2"
                shift 2
                ;;
            *)
                echo "Usage: nvr backup [--quiesced] [--timeout-seconds N]" >&2
                return 2
                ;;
        esac
    done
    if [[ -n "$timeout_seconds" ]]; then
        [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || {
            echo "ERROR: --timeout-seconds requires a positive integer" >&2
            return 2
        }
        timeout_binary=$(nvr_timeout_binary) || {
            echo "ERROR: timeout is required for a bounded backup" >&2
            return 1
        }
        if [[ "${NVR_BACKUP_OUTER_DEADLINE:-}" =~ ^[1-9][0-9]*$ ]]; then
            timeout_deadline="$NVR_BACKUP_OUTER_DEADLINE"
        else
            timeout_deadline=$(($(date +%s) + timeout_seconds))
        fi
    fi

    backup_lock_acquire || return 1

    local result=0
    backup_run_locked || result=$?
    backup_lock_release

    if [[ $result -ne 0 ]]; then
        return "$result"
    fi

    echo "Backup complete!"
}

cmd_backup() {
    local timeout_seconds="" expect_timeout=0 argument timeout_binary
    local supervisor_python supervisor_ps deadline result=0
    if [[ "${NVR_BACKUP_OUTER_TIMEOUT_ACTIVE:-0}" == 1 ]]; then
        _cmd_backup_worker "$@"
        return $?
    fi
    for argument in "$@"; do
        if [[ "$expect_timeout" == 1 ]]; then
            timeout_seconds="$argument"
            expect_timeout=0
        elif [[ "$argument" == --timeout-seconds ]]; then
            expect_timeout=1
        fi
    done
    if [[ -z "$timeout_seconds" ]]; then
        _cmd_backup_worker "$@"
        return $?
    fi
    [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || {
        echo "ERROR: --timeout-seconds requires a positive integer" >&2
        return 2
    }
    timeout_binary=$(nvr_timeout_binary) || {
        echo "ERROR: timeout is required for a bounded backup" >&2
        return 1
    }
    supervisor_python=$(cloudapi_profile_python) || return 1
    supervisor_ps="${NVR_BACKUP_SUPERVISOR_PS:-ps}"
    deadline=$(($(date +%s) + timeout_seconds))
    NVR_BACKUP_OUTER_TIMEOUT_ACTIVE=1 NVR_BACKUP_OUTER_DEADLINE="$deadline" \
        NVR_BACKUP_COMPOSE_COMMAND="$COMPOSE" \
        NVR_BACKUP_DOCKER_COMMAND=docker \
        "$supervisor_python" - "$deadline" "$supervisor_ps" "$INSTALL_DIR/nvr" backup "$@" <<'PY' || result=$?
from __future__ import print_function

import os
import signal
import subprocess
import sys
import time

deadline = float(sys.argv[1])
ps_binary = sys.argv[2]
worker = subprocess.Popen(sys.argv[3:], preexec_fn=os.setsid)
while worker.poll() is None and time.time() < deadline:
    time.sleep(0.05)
if worker.poll() is None:
    ps_args = [ps_binary, '-axo', 'pid=', '-o', 'ppid='] if sys.platform == 'darwin' \
        else [ps_binary, '-eo', 'pid=', '-o', 'ppid=']
    stopped_members = set()
    discovery_failed = False
    try:
        for unused_attempt in range(2):
            try:
                process = subprocess.Popen(ps_args, stdout=subprocess.PIPE)
                output = process.communicate()[0]
            except OSError:
                discovery_failed = True
                break
            if process.returncode != 0:
                discovery_failed = True
                break
            children = {}
            for row in output.decode('ascii').splitlines():
                fields = row.split()
                if len(fields) == 2:
                    children.setdefault(int(fields[1]), []).append(int(fields[0]))
            members = [worker.pid]
            for pid in members:
                members.extend(children.get(pid, []))
            for pid in members:
                try:
                    if os.getsid(pid) == worker.pid:
                        os.kill(pid, signal.SIGSTOP)
                        stopped_members.add(pid)
                except OSError:
                    pass
    finally:
        try:
            if os.getsid(worker.pid) == worker.pid:
                os.killpg(worker.pid, signal.SIGKILL)
        except OSError:
            pass
        for pid in reversed(sorted(stopped_members)):
            try:
                if os.getsid(pid) == worker.pid:
                    os.kill(pid, signal.SIGKILL)
            except OSError:
                pass
    worker.wait()
    if discovery_failed:
        raise SystemExit(1)
    raise SystemExit(124)
raise SystemExit(worker.returncode if worker.returncode >= 0 else 1)
PY
    case "$result" in
        0) return 0 ;;
        124|137)
            echo -e "${RED}ERROR: Backup exceeded its total deadline${NC}" >&2
            return 1
            ;;
        *) return "$result" ;;
    esac
}

update_backup_archive_facts() {
    local archive="$1"
    $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1
        dump="$archive/db/dtech.sql"
        conf="$archive/conf/dvs.conf"

        test -d "$archive" && test ! -L "$archive" || {
            echo "backup archive is missing or is a symlink: $archive" >&2
            exit 1
        }
        test -f "$conf" && test ! -L "$conf" && test -s "$conf" || {
            echo "backup configuration is missing, empty, or a symlink: $conf" >&2
            exit 1
        }
        test -f "$dump" && test ! -L "$dump" && test -s "$dump" || {
            echo "database dump is missing, empty, or a symlink: $dump" >&2
            exit 1
        }

        header=$(sed -n "1p" "$dump" | sed "s/[[:space:]]*$//")
        if [ "$header" = "/*M!999999\\- enable the sandbox mode */" ]; then
            header=$(sed -n "2p" "$dump" | sed "s/[[:space:]]*$//")
        fi
        case "$header" in
            "-- MariaDB dump"*|"-- MySQL dump"*) ;;
            *) echo "database dump has no mysqldump header: $dump" >&2; exit 1 ;;
        esac
        for table in Camera Misc Server; do
            grep -Fqx -- "-- Table structure for table \`$table\`" "$dump" || {
                echo "database dump is missing core table $table: $dump" >&2
                exit 1
            }
        done
        tail_text=$(tail -n 50 "$dump")
        printf "%s\n" "$tail_text" | grep -Eq \
            "^-- Dump completed on [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ( [0-9]|[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$" || {
            echo "database dump has no completion footer: $dump" >&2
            exit 1
        }

        bytes=$(wc -c < "$dump")
        bytes=$(printf "%s" "$bytes" | tr -d "[:space:]")
        case "$bytes" in
            ""|*[!0-9]*|0) echo "database dump has an invalid byte count: $dump" >&2; exit 1 ;;
        esac
        if command -v sha256sum >/dev/null 2>&1; then
            checksum=$(sha256sum "$dump")
            checksum=${checksum%% *}
        elif command -v shasum >/dev/null 2>&1; then
            checksum=$(shasum -a 256 "$dump")
            checksum=${checksum%% *}
        else
            echo "no SHA-256 tool is available in the backend container" >&2
            exit 1
        fi
        case "$checksum" in
            *[!0-9a-fA-F]*|"") echo "database dump checksum is invalid: $dump" >&2; exit 1 ;;
        esac
        printf "dump_bytes=%s\ndump_sha256=%s\n" "$bytes" "$checksum"
    ' sh "$archive"
}

update_backup_metadata_field() {
    local archive="$1"
    local field="$2"
    $COMPOSE exec -T backend sh -c '
        metadata="$1/$2"
        field=$3
        test -f "$metadata" && test ! -L "$metadata" || exit 1
        sed -n "s/^${field}=//p" "$metadata"
    ' sh "$archive" "$NVR_UPDATE_BACKUP_METADATA" "$field"
}

update_backup_find_reusable() {
    local source_backend_id="$1"
    local target_backend_id="$2"
    $COMPOSE exec -T backend sh -c '
        root=$1
        marker=$2
        source_id=$3
        target_id=$4
        found=""
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=completed" "$metadata" || continue
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "source_backend_image_id=$source_id" "$metadata" || continue
            grep -Fqx "target_backend_image_id=$target_id" "$metadata" || continue
            found=$archive
        done
        test -z "$found" || printf "%s\n" "$found"
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$source_backend_id" "$target_backend_id"
}

update_backup_find_incomplete() {
    local source_backend_id="$1"
    local target_backend_id="$2"
    $COMPOSE exec -T backend sh -c '
        root=$1
        marker=$2
        source_id=$3
        target_id=$4
        found=""
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=incomplete" "$metadata" || continue
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "source_backend_image_id=$source_id" "$metadata" || continue
            grep -Fqx "target_backend_image_id=$target_id" "$metadata" || continue
            found=$archive
        done
        test -z "$found" || printf "%s\n" "$found"
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$source_backend_id" "$target_backend_id"
}

update_backup_find_failed_transition() {
    local source_backend_id="$1"
    local target_backend_id="$2"
    $COMPOSE exec -T backend sh -c '
        root=$1
        marker=$2
        source_id=$3
        target_id=$4
        failed_root=$5
        for archive in \
            "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9] \
            "$failed_root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9] \
            "$failed_root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].*; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            case "$archive" in
                "$failed_root"/*) ;;
                *) grep -Fqx "state=failed" "$metadata" || continue ;;
            esac
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "source_backend_image_id=$source_id" "$metadata" || continue
            grep -Fqx "target_backend_image_id=$target_id" "$metadata" || continue
            printf "%s\n" "$archive"
            exit 0
        done
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$source_backend_id" "$target_backend_id" "$NVR_UPDATE_BACKUP_FAILED_ROOT"
}

update_backup_verify_archive() {
    local archive="$1"
    local require_metadata="${2:-0}"
    local facts current_bytes current_sha saved_bytes saved_sha

    facts=$(update_backup_archive_facts "$archive") || {
        echo "ERROR: automatic update backup verification failed for $archive" >&2
        return 1
    }
    current_bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
    current_sha=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')

    if [[ "$require_metadata" == "1" ]]; then
        [[ "$(update_backup_metadata_field "$archive" kind 2>/dev/null || true)" == "automatic-update" ]] || {
            echo "ERROR: automatic update backup kind is invalid: $archive" >&2
            return 1
        }
        [[ "$(update_backup_metadata_field "$archive" state 2>/dev/null || true)" == "completed" ]] || {
            echo "ERROR: automatic update backup is not completed: $archive" >&2
            return 1
        }
        saved_bytes=$(update_backup_metadata_field "$archive" dump_bytes 2>/dev/null || true)
        saved_sha=$(update_backup_metadata_field "$archive" dump_sha256 2>/dev/null || true)
        if [[ -z "$saved_bytes" || -z "$saved_sha" || \
              "$saved_bytes" != "$current_bytes" || "$saved_sha" != "$current_sha" ]]; then
            echo "ERROR: automatic update backup checksum verification failed: $archive" >&2
            return 1
        fi
    fi

    printf '%s\n' "$facts"
}

update_backup_value_is_safe() {
    case "$1" in
        *$'\n'*|*$'\r'*) return 1 ;;
        *) return 0 ;;
    esac
}

update_backup_write_metadata() {
    local archive="$1"
    local source_backend_id="$2"
    local source_backend_revision="$3"
    local target_backend_id="$4"
    local target_backend_revision="$5"
    local source_database_id="$6"
    local target_database_id="$7"
    local source_database_version="$8"
    local target_database_version="$9"
    local dump_bytes="${10}"
    local dump_sha="${11}"
    local activation_id="${12:-}"
    local activation_edge_id="${13:-}"
    local target_database_digest="${14:-}"
    local compose_bundle_sha256="${15:-}"
    local migration_gate_version="${16:-}"
    local activation_manifest_sha256="${17:-}"
    local format_version=1
    local value

    if [[ $# -eq 17 ]]; then
        format_version=2
    elif [[ $# -ne 11 ]]; then
        echo "ERROR: invalid automatic update backup metadata identity" >&2
        return 1
    fi

    for value in "$source_backend_id" "$source_backend_revision" \
        "$target_backend_id" "$target_backend_revision" \
        "$source_database_id" "$target_database_id" \
        "$source_database_version" "$target_database_version" \
        "$dump_bytes" "$dump_sha" "$activation_id" "$activation_edge_id" \
        "$target_database_digest" "$compose_bundle_sha256" \
        "$migration_gate_version" "$activation_manifest_sha256"; do
        [[ -n "$value" || "$format_version" -eq 1 ]] || {
            echo "ERROR: staged activation backup metadata is incomplete" >&2
            return 1
        }
        update_backup_value_is_safe "$value" || {
            echo "ERROR: automatic update backup metadata contains a newline" >&2
            return 1
        }
    done

    {
        printf '%s\n' \
            "format_version=$format_version" \
            'kind=automatic-update' \
            'state=completed' \
            'transition_state=pending' \
            "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            "source_backend_image_id=$source_backend_id" \
            "source_backend_revision=$source_backend_revision" \
            "target_backend_image_id=$target_backend_id" \
            "target_backend_revision=$target_backend_revision" \
            "source_database_image_id=$source_database_id" \
            "target_database_image_id=$target_database_id" \
            "source_database_version=$source_database_version" \
            "target_database_version=$target_database_version" \
            "dump_bytes=$dump_bytes" \
            "dump_sha256=$dump_sha"
        if [[ "$format_version" -eq 2 ]]; then
            printf '%s\n' \
                "activation_id=$activation_id" \
                "activation_edge_id=$activation_edge_id" \
                "target_database_digest=$target_database_digest" \
                "compose_bundle_sha256=$compose_bundle_sha256" \
                "migration_gate_version=$migration_gate_version" \
                "activation_manifest_sha256=$activation_manifest_sha256"
        fi
    } | $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1
        marker=$2
        metadata="$archive/$marker"
        temporary="$metadata.new.$$"
        trap '\''rm -f "$temporary"'\'' EXIT HUP INT TERM
        umask 022
        cat > "$temporary"
        chmod 0444 "$temporary"
        mv -f "$temporary" "$metadata"
        trap - EXIT HUP INT TERM
    ' sh "$archive" "$NVR_UPDATE_BACKUP_METADATA"
}

update_backup_set_transition_state() {
    local archive="$1"
    local transition_state="$2"
    [[ -n "$archive" ]] || return 0
    case "$transition_state" in
        applied|superseded) ;;
        *) echo "ERROR: invalid automatic update transition state: $transition_state" >&2; return 1 ;;
    esac
    $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1
        marker=$2
        transition_state=$3
        metadata="$archive/$marker"
        temporary="$metadata.new.$$"
        test -f "$metadata" && test ! -L "$metadata"
        if grep -Fqx "transition_state=$transition_state" "$metadata"; then
            exit 0
        fi
        grep -Fqx "transition_state=pending" "$metadata"
        trap '\''rm -f "$temporary"'\'' EXIT HUP INT TERM
        sed "s/^transition_state=pending$/transition_state=$transition_state/" \
            "$metadata" > "$temporary"
        chmod 0444 "$temporary"
        mv -f "$temporary" "$metadata"
        trap - EXIT HUP INT TERM
    ' sh "$archive" "$NVR_UPDATE_BACKUP_METADATA" "$transition_state"
}

update_backup_set_transition_applied() {
    update_backup_set_transition_state "$1" applied
}

update_backup_close_pending_for_target() {
    local target_backend_id="$1"
    local archive archives
    archives=$($COMPOSE exec -T backend sh -c '
        root=$1
        marker=$2
        target_id=$3
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=completed" "$metadata" || continue
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "target_backend_image_id=$target_id" "$metadata" || continue
            printf "%s\n" "$archive"
        done
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$target_backend_id") || return 1
    while IFS= read -r archive; do
        [[ -n "$archive" ]] || continue
        update_backup_set_transition_applied "$archive" || return 1
        echo "Closed applied automatic update transition: $archive"
    done <<< "$archives"
}

update_backup_supersede_other_pending_for_source() {
    local source_backend_id="$1"
    local target_backend_id="$2"
    local archive archives
    archives=$($COMPOSE exec -T backend sh -c '
        root=$1
        marker=$2
        source_id=$3
        target_id=$4
        failed_root=$5
        for archive in \
            "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9] \
            "$failed_root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9] \
            "$failed_root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].*; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "source_backend_image_id=$source_id" "$metadata" || continue
            grep -Fqx "target_backend_image_id=$target_id" "$metadata" && continue
            printf "%s\n" "$archive"
        done
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$source_backend_id" "$target_backend_id" \
        "$NVR_UPDATE_BACKUP_FAILED_ROOT") || return 1
    while IFS= read -r archive; do
        [[ -n "$archive" ]] || continue
        update_backup_set_transition_state "$archive" superseded || return 1
        echo "Superseded abandoned automatic update transition: $archive"
    done <<< "$archives"
}

update_backup_quarantine_failed() {
    local archive="$1"
    local source_backend_id="$2"
    local source_backend_revision="$3"
    local target_backend_id="$4"
    local target_backend_revision="$5"
    local source_database_id="$6"
    local target_database_id="$7"
    local source_database_version="$8"
    local target_database_version="$9"
    local transition_state="${10:-aborted}"
    local value quarantine

    case "$transition_state" in
        pending|aborted) ;;
        *) echo "ERROR: invalid failed backup transition state: $transition_state" >&2; return 1 ;;
    esac

    for value in "$source_backend_id" "$source_backend_revision" \
        "$target_backend_id" "$target_backend_revision" \
        "$source_database_id" "$target_database_id" \
        "$source_database_version" "$target_database_version"; do
        update_backup_value_is_safe "$value" || return 1
    done

    {
        printf '%s\n' \
            'format_version=1' \
            'kind=automatic-update' \
            'state=failed' \
            "transition_state=$transition_state" \
            "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            "source_backend_image_id=$source_backend_id" \
            "source_backend_revision=$source_backend_revision" \
            "target_backend_image_id=$target_backend_id" \
            "target_backend_revision=$target_backend_revision" \
            "source_database_image_id=$source_database_id" \
            "target_database_image_id=$target_database_id" \
            "source_database_version=$source_database_version" \
            "target_database_version=$target_database_version" \
            'verification=failed'
    } | $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1
        root=$2
        marker=$3
        case "$archive" in
            "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
            *) echo "refusing unsafe failed backup path: $archive" >&2; exit 1 ;;
        esac
        test -d "$archive" && test ! -L "$archive"
        basename=${archive##*/}
        quarantine_root=$4
        quarantine="$quarantine_root/$basename"
        mkdir -p "$quarantine_root"
        if test -e "$quarantine"; then
            quarantine="$quarantine.$$"
        fi
        metadata="$archive/$marker"
        temporary="$metadata.new.$$"
        trap '\''rm -f "$temporary"'\'' EXIT HUP INT TERM
        umask 022
        cat > "$temporary"
        printf "quarantined_archive=%s\n" "$quarantine" >> "$temporary"
        chmod 0444 "$temporary"
        mv -f "$temporary" "$metadata"
        mv -- "$archive" "$quarantine"
        mkdir "$archive"
        cp "$quarantine/$marker" "$archive/$marker"
        chmod 0444 "$archive/$marker"
        trap - EXIT HUP INT TERM
        echo "Quarantined failed automatic update backup: $quarantine" >&2
    ' sh "$archive" "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$NVR_UPDATE_BACKUP_FAILED_ROOT" || return 1

    quarantine=$(update_backup_metadata_field \
        "$archive" quarantined_archive 2>/dev/null) || return 1
    update_backup_rotate_failed "$quarantine"
}

update_backup_rotate_failed() {
    local protected_archive="${1:-}"
    $COMPOSE exec -T backend sh -c '
        set -eu
        root=$1
        marker=$2
        retain=$3
        protected=$4
        protected_count=0
        set --
        for archive in "$root"/*; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=failed" "$metadata" || continue
            if { test -n "$protected" && test "$archive" = "$protected"; } ||
                    grep -Fqx "transition_state=pending" "$metadata"; then
                protected_count=$((protected_count + 1))
            else
                set -- "$@" "$archive"
            fi
        done
        total=$(($# + protected_count))
        while [ "$total" -gt "$retain" ] && [ "$#" -gt 0 ]; do
            victim=$1
            shift
            basename=${victim##*/}
            timestamp=${basename%%.*}
            suffix=""
            test "$timestamp" = "$basename" || suffix=${basename#*.}
            case "$timestamp" in
                [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
                *) echo "refusing unsafe failed backup rotation target: $victim" >&2; exit 1 ;;
            esac
            case "$suffix" in
                "") ;;
                *[!0-9]*)
                    echo "refusing unsafe failed backup rotation suffix: $victim" >&2
                    exit 1
                    ;;
            esac
            rm -rf -- "$victim"
            echo "Removed old failed automatic update backup: $victim"
            total=$((total - 1))
        done
    ' sh "$NVR_UPDATE_BACKUP_FAILED_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$NVR_UPDATE_BACKUP_FAILED_RETAIN" "$protected_archive"
}

update_backup_rotate_completed() {
    local protected_archive="${1:-}"
    $COMPOSE exec -T backend sh -c '
        set -eu
        root=$1
        marker=$2
        retain=$3
        protected=$4
        protected_count=0
        set --
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=completed" "$metadata" || continue
            if { test -n "$protected" && test "$archive" = "$protected"; } ||
                    grep -Fqx "transition_state=pending" "$metadata"; then
                protected_count=$((protected_count + 1))
            else
                set -- "$@" "$archive"
            fi
        done
        total=$(($# + protected_count))
        while [ "$total" -gt "$retain" ] && [ "$#" -gt 0 ]; do
            victim=$1
            shift
            case "$victim" in
                "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
                *) echo "refusing unsafe automatic backup rotation target: $victim" >&2; exit 1 ;;
            esac
            rm -rf -- "$victim"
            echo "Removed old automatic update backup: $victim"
            total=$((total - 1))
        done
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" \
        "$NVR_UPDATE_BACKUP_RETAIN" "$protected_archive"
}

update_image_revision() {
    local image_id="$1"
    local revision
    revision=$(docker image inspect --format \
        '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
        "$image_id" 2>/dev/null || true)
    case "$revision" in
        ""|"<no value>") revision="unlabeled:$image_id" ;;
    esac
    printf '%s\n' "$revision"
}

update_target_backend_image_ref() {
    local images image
    images=$($COMPOSE config --images 2>/dev/null) || return 1
    while IFS= read -r image; do
        case "$image" in
            */dividia/nvr-backend:*|dividia/nvr-backend:*|*/dividia/nvr-backend@*|dividia/nvr-backend@*)
                printf '%s\n' "$image"
                return 0
                ;;
        esac
    done <<< "$images"
    return 1
}

update_target_database_image_ref() {
    local images image
    images=$($COMPOSE config --images 2>/dev/null) || return 1
    while IFS= read -r image; do
        case "$image" in
            mariadb:*|*/mariadb:*|mariadb@*|*/mariadb@*) printf '%s\n' "$image"; return 0 ;;
        esac
    done <<< "$images"
    return 1
}

update_target_database_version() {
    local image_ref="$1"
    local image_env line version=""
    image_env=$(docker image inspect --format \
        '{{range .Config.Env}}{{println .}}{{end}}' "$image_ref" 2>/dev/null || true)
    while IFS= read -r line; do
        case "$line" in
            MARIADB_VERSION=*) version=${line#MARIADB_VERSION=}; break ;;
        esac
    done <<< "$image_env"
    if [[ -z "$version" ]]; then
        version=${image_ref##*:}
        [[ "$version" == "$image_ref" ]] && version=unknown
    fi
    printf '%s\n' "$version"
}

update_live_database_version() {
    local version
    version=$($COMPOSE exec -T db sh -c '
        if command -v mariadb >/dev/null 2>&1; then
            mariadb -u root --batch --skip-column-names -e "SELECT VERSION()"
        else
            mysql -u root --batch --skip-column-names -e "SELECT VERSION()"
        fi
    ' 2>/dev/null) || return 1
    version=${version%%$'\n'*}
    [[ -n "$version" ]] || return 1
    printf '%s\n' "$version"
}

prepare_automatic_update_backup() {
    local target_backend_ref="$1"
    local source_backend_container source_backend_id target_backend_id
    local source_backend_revision target_backend_revision
    local source_database_container source_database_id target_database_ref target_database_id
    local source_database_version target_database_version reusable incomplete failed facts dump_bytes dump_sha
    local result=0

    NVR_ACTIVE_UPDATE_BACKUP_DIR=""

    [[ -n "$target_backend_ref" ]] || {
        echo "ERROR: cannot resolve the target backend image for automatic backup" >&2
        return 1
    }
    source_backend_container=$($COMPOSE ps -aq backend 2>/dev/null | sed -n '1p')
    [[ -n "$source_backend_container" ]] || {
        echo "ERROR: cannot identify the current backend container for automatic backup" >&2
        return 1
    }
    source_backend_id=$(docker inspect --format '{{.Image}}' \
        "$source_backend_container" 2>/dev/null) || return 1
    target_backend_id=$(docker image inspect --format '{{.Id}}' \
        "$target_backend_ref" 2>/dev/null) || {
        echo "ERROR: cannot inspect target backend image $target_backend_ref" >&2
        return 1
    }
    if [[ "$source_backend_id" == "$target_backend_id" ]]; then
        if ! compose_service_is_running backend; then
            echo "Backend image digest is unchanged; automatic update backup is not needed."
            echo "Automatic update transition repair is deferred until the backend is running."
            return 0
        fi
        if ! backup_lock_acquire; then
            echo "WARN: automatic update transition repair is deferred until the VideoStore is available" >&2
            echo "Backend image digest is unchanged; automatic update backup is not needed."
            return 0
        fi
        if ! update_backup_close_pending_for_target "$source_backend_id"; then
            echo "WARN: cannot repair an automatic update transition marker; the next update will retry" >&2
        fi
        backup_lock_release
        echo "Backend image digest is unchanged; automatic update backup is not needed."
        return 0
    fi

    source_backend_revision=$(update_image_revision "$source_backend_id")
    target_backend_revision=$(update_image_revision "$target_backend_id")
    source_database_container=$($COMPOSE ps -aq db 2>/dev/null | sed -n '1p')
    [[ -n "$source_database_container" ]] || {
        echo "ERROR: cannot identify the current database container for automatic backup" >&2
        return 1
    }
    source_database_id=$(docker inspect --format '{{.Image}}' \
        "$source_database_container" 2>/dev/null) || return 1
    target_database_ref=$(update_target_database_image_ref) || {
        echo "ERROR: cannot resolve the target database image for automatic backup" >&2
        return 1
    }
    target_database_id=$(docker image inspect --format '{{.Id}}' \
        "$target_database_ref" 2>/dev/null) || {
        echo "ERROR: cannot inspect target database image $target_database_ref" >&2
        return 1
    }
    source_database_version=$(update_live_database_version) || {
        echo "ERROR: cannot read the source database version for automatic backup" >&2
        return 1
    }
    target_database_version=$(update_target_database_version "$target_database_ref")

    backup_lock_acquire || return 1
    result=0

    # A running target proves that an earlier pending transition reached its
    # backend. Close that record before considering a rollback or later repeat.
    update_backup_close_pending_for_target "$source_backend_id" || result=$?

    # A different selected target starts a new transition generation. It must
    # not leave another target from this source retryable forever.
    if [[ $result -eq 0 ]]; then
        update_backup_supersede_other_pending_for_source \
            "$source_backend_id" "$target_backend_id" || result=$?
    fi

    if [[ $result -eq 0 ]]; then
        failed=$(update_backup_find_failed_transition \
            "$source_backend_id" "$target_backend_id") || result=$?
    fi
    if [[ $result -eq 0 && -n "$failed" ]]; then
        echo "ERROR: the original automatic update backup failed verification: $failed" >&2
        result=1
    fi

    if [[ $result -eq 0 ]]; then
        reusable=$(update_backup_find_reusable \
            "$source_backend_id" "$target_backend_id") || result=$?
    fi
    if [[ $result -eq 0 && -n "$reusable" ]]; then
        echo "Reusing verified automatic update backup: $reusable"
        if update_backup_verify_archive "$reusable" 1 >/dev/null; then
            NVR_ACTIVE_UPDATE_BACKUP_DIR="$reusable"
        else
            result=$?
            update_backup_quarantine_failed "$reusable" \
                "$source_backend_id" "$source_backend_revision" \
                "$target_backend_id" "$target_backend_revision" \
                "$source_database_id" "$target_database_id" \
                "$source_database_version" "$target_database_version" \
                pending || result=$?
        fi
    elif [[ $result -eq 0 ]]; then
        incomplete=$(update_backup_find_incomplete \
            "$source_backend_id" "$target_backend_id") || result=$?
    fi
    if [[ $result -eq 0 && -n "$incomplete" ]]; then
        echo "Completing original automatic update backup: $incomplete"
        facts=$(update_backup_verify_archive "$incomplete") || {
            result=$?
            update_backup_quarantine_failed "$incomplete" \
                "$source_backend_id" "$source_backend_revision" \
                "$target_backend_id" "$target_backend_revision" \
                "$source_database_id" "$target_database_id" \
                "$source_database_version" "$target_database_version" || result=$?
        }
        if [[ $result -eq 0 ]]; then
            dump_bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
            dump_sha=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
            update_backup_write_metadata "$incomplete" \
                "$source_backend_id" "$source_backend_revision" \
                "$target_backend_id" "$target_backend_revision" \
                "$source_database_id" "$target_database_id" \
                "$source_database_version" "$target_database_version" \
                "$dump_bytes" "$dump_sha" || result=$?
        fi
        if [[ $result -eq 0 ]]; then
            if update_backup_verify_archive "$incomplete" 1 >/dev/null; then
                NVR_ACTIVE_UPDATE_BACKUP_DIR="$incomplete"
            else
                result=$?
                update_backup_quarantine_failed "$incomplete" \
                    "$source_backend_id" "$source_backend_revision" \
                    "$target_backend_id" "$target_backend_revision" \
                    "$source_database_id" "$target_database_id" \
                    "$source_database_version" "$target_database_version" || result=$?
            fi
        fi
    elif [[ $result -eq 0 && -z "$reusable" ]]; then
        echo "Backend image digest will change. Creating automatic update backup..."
        backup_run_locked \
            "$source_backend_id" "$source_backend_revision" \
            "$target_backend_id" "$target_backend_revision" \
            "$source_database_id" "$target_database_id" \
            "$source_database_version" "$target_database_version" || result=$?
        if [[ $result -ne 0 && -n "$NVR_LAST_BACKUP_DIR" ]]; then
            update_backup_quarantine_failed "$NVR_LAST_BACKUP_DIR" \
                "$source_backend_id" "$source_backend_revision" \
                "$target_backend_id" "$target_backend_revision" \
                "$source_database_id" "$target_database_id" \
                "$source_database_version" "$target_database_version" || result=$?
        fi
        if [[ $result -eq 0 && -z "$NVR_LAST_BACKUP_DIR" ]]; then
            echo "ERROR: normal NVR backup did not report its archive path" >&2
            result=1
        fi
        if [[ $result -eq 0 ]]; then
            facts=$(update_backup_verify_archive "$NVR_LAST_BACKUP_DIR") || {
                result=$?
                update_backup_quarantine_failed "$NVR_LAST_BACKUP_DIR" \
                    "$source_backend_id" "$source_backend_revision" \
                    "$target_backend_id" "$target_backend_revision" \
                    "$source_database_id" "$target_database_id" \
                    "$source_database_version" "$target_database_version" || result=$?
            }
        fi
        if [[ $result -eq 0 ]]; then
            dump_bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
            dump_sha=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
            update_backup_write_metadata "$NVR_LAST_BACKUP_DIR" \
                "$source_backend_id" "$source_backend_revision" \
                "$target_backend_id" "$target_backend_revision" \
                "$source_database_id" "$target_database_id" \
                "$source_database_version" "$target_database_version" \
                "$dump_bytes" "$dump_sha" || result=$?
        fi
        if [[ $result -eq 0 ]]; then
            if update_backup_verify_archive "$NVR_LAST_BACKUP_DIR" 1 >/dev/null; then
                NVR_ACTIVE_UPDATE_BACKUP_DIR="$NVR_LAST_BACKUP_DIR"
            else
                result=$?
                update_backup_quarantine_failed "$NVR_LAST_BACKUP_DIR" \
                    "$source_backend_id" "$source_backend_revision" \
                    "$target_backend_id" "$target_backend_revision" \
                    "$source_database_id" "$target_database_id" \
                    "$source_database_version" "$target_database_version" || result=$?
            fi
        fi
        if [[ $result -eq 0 ]]; then
            echo "Automatic update backup verified: $NVR_LAST_BACKUP_DIR"
        fi
    fi

    if [[ $result -eq 0 ]]; then
        update_backup_rotate_completed "$NVR_ACTIVE_UPDATE_BACKUP_DIR" || result=$?
    fi
    backup_lock_release
    return "$result"
}

# --- ADR-110 staged activation execution -----------------------------------

activation_backend_mode_read() {
    local before after snapshot line key value lines=0
    ABM_FORMAT_VERSION=""; ABM_MODE=""; ABM_BACKEND_IMAGE_ID=""
    ABM_MANIFEST_SHA256=""; ABM_UPDATED_AT=""
    activation_file_identity_valid "$NVR_ACTIVATION_BACKEND_MODE" 444 || return 1
    before=$(mariadb_stat_identity "$NVR_ACTIVATION_BACKEND_MODE") || return 1
    snapshot=$(cat "$NVR_ACTIVATION_BACKEND_MODE") || return 1
    while IFS= read -r line || [[ -n "$line" ]]; do
        lines=$((lines + 1))
        [[ "$line" == *=* && "$line" != *$'\r'* ]] || return 1
        key=${line%%=*}; value=${line#*=}
        activation_value_token_safe "$value" || return 1
        case "$key" in
            format_version) [[ -z "$ABM_FORMAT_VERSION" ]] || return 1; ABM_FORMAT_VERSION="$value" ;;
            mode) [[ -z "$ABM_MODE" ]] || return 1; ABM_MODE="$value" ;;
            backend_image_id) [[ -z "$ABM_BACKEND_IMAGE_ID" ]] || return 1; ABM_BACKEND_IMAGE_ID="$value" ;;
            manifest_sha256) [[ -z "$ABM_MANIFEST_SHA256" ]] || return 1; ABM_MANIFEST_SHA256="$value" ;;
            updated_at) [[ -z "$ABM_UPDATED_AT" ]] || return 1; ABM_UPDATED_AT="$value" ;;
            *) return 1 ;;
        esac
    done <<< "$snapshot"
    after=$(mariadb_stat_identity "$NVR_ACTIVATION_BACKEND_MODE") || return 1
    [[ "$before" == "$after" && "$lines" -eq 5 && "$ABM_FORMAT_VERSION" == 1 ]] || return 1
    case "$ABM_MODE" in compatibility|activated|migration-failed) ;; *) return 1 ;; esac
    case "$ABM_BACKEND_IMAGE_ID" in unavailable) ;; *) activation_digest_valid "$ABM_BACKEND_IMAGE_ID" || return 1 ;; esac
    case "$ABM_MANIFEST_SHA256" in NONE) ;; *) [[ "$ABM_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] || return 1 ;; esac
    activation_timestamp_valid "$ABM_UPDATED_AT"
}

activation_service_image_id() { # <service>
    local cid
    cid=$($COMPOSE ps -aq "$1" 2>/dev/null | sed -n '1p')
    [[ -n "$cid" ]] || return 1
    docker inspect --format '{{.Image}}' "$cid" 2>/dev/null
}

activation_capture_checkpoint() {
    NVR_UPDATE_CHECKPOINT_OK=0
    NVR_UPDATE_CHECKPOINT_BACKEND_ID=""
    NVR_UPDATE_JOURNAL_PRESENT_AT_START=0
    if [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]]; then
        activation_journal_read || {
            echo "ERROR: the staged activation journal is invalid" >&2
            return 1
        }
        NVR_UPDATE_JOURNAL_PRESENT_AT_START=1
    fi
    NVR_UPDATE_CHECKPOINT_BACKEND_ID=$(activation_service_image_id backend 2>/dev/null || true)
    if [[ -n "$NVR_UPDATE_CHECKPOINT_BACKEND_ID" ]] \
            && activation_backend_mode_read \
            && [[ "$ABM_MODE" == compatibility \
                && "$ABM_BACKEND_IMAGE_ID" == "$NVR_UPDATE_CHECKPOINT_BACKEND_ID" ]]; then
        NVR_UPDATE_CHECKPOINT_OK=1
    fi
}

activation_journal_prepare() { # source-be source-rev target-be target-rev source-db live compose-sha
    activation_journal_reset
    AJ_FORMAT_VERSION=1
    AJ_PROTOCOL_VERSION="$NVR_ACTIVATION_PROTOCOL"
    AJ_STATE=staged
    AJ_EDGE_ID="$AM_EDGE_ID"
    AJ_MANIFEST_SHA256="$AM_MANIFEST_SHA256"
    AJ_SOURCE_BACKEND_ID="$1"
    AJ_SOURCE_BACKEND_REVISION="$2"
    AJ_TARGET_BACKEND_ID="$3"
    AJ_TARGET_BACKEND_REVISION="$4"
    AJ_SOURCE_DATABASE_ID="$5"
    AJ_SOURCE_DATABASE_VERSION="$6"
    AJ_TARGET_DATABASE_DIGEST="$AM_TARGET_DATABASE_DIGEST"
    AJ_COMPOSE_BUNDLE_SHA256="$7"
    AJ_MIGRATION_GATE_VERSION="$AM_MIGRATION_GATE_VERSION"
    AJ_BACKUP_PATH=NONE
    AJ_BACKUP_BYTES=0
    AJ_BACKUP_SHA256=NONE
    AJ_FAILURE_COUNT=0
    AJ_FAILURE_FINGERPRINT=NONE
    AJ_BLOCKED_REASON=NONE
    AJ_CREATED_AT=""
    AJ_ACTIVATION_ID=$(activation_compound_id "$AJ_SOURCE_BACKEND_ID" \
        "$AJ_TARGET_BACKEND_ID" "$AJ_SOURCE_DATABASE_ID" "$AJ_SOURCE_DATABASE_VERSION" \
        "$AJ_TARGET_DATABASE_DIGEST" "$AJ_COMPOSE_BUNDLE_SHA256" \
        "$AJ_MIGRATION_GATE_VERSION" "$AJ_MANIFEST_SHA256") || return 1
}

activation_journal_initialize() { # source-be source-rev target-be target-rev source-db live compose-sha
    activation_journal_prepare "$@" || return 1
    activation_journal_write
}

activation_legacy_ready_backup() {
    local path="$NVR_LEGACY_MARIADB_DATADIR_MARKER"
    local before after expected_uid owner mode snapshot backup
    [[ -f "$path" && ! -L "$path" ]] || return 1
    before=$(mariadb_stat_identity "$path") || return 1
    expected_uid=$(mariadb_expected_marker_uid) || return 1
    owner=$(printf '%s\n' "$before" | awk -F: '{ print $(NF-1) }')
    mode=$(printf '%s\n' "$before" | awk -F: '{ print $NF }')
    mode=${mode#0}
    [[ "$owner" == "$expected_uid" && "$mode" == 644 ]] || return 1
    snapshot=$(cat "$path") || return 1
    printf '%s\n' "$snapshot" | LC_ALL=C awk -F= '
        BEGIN {
            keys="marker-version state observed-series minimum-runtime-series pre-upgrade-backup"
            count=split(keys, required, " ")
            for (i=1; i<=count; i++) allowed[required[i]]=1
        }
        index($0, "\r") != 0 { bad=1; next }
        $0 !~ /^[A-Za-z][A-Za-z0-9_-]*=[^=\r\n]*$/ { bad=1; next }
        { key=$1; if (!(key in allowed) || seen[key]++) bad=1 }
        END {
            for (i=1; i<=count; i++) if (seen[required[i]] != 1) bad=1
            exit bad ? 1 : 0
        }
    ' || return 1
    after=$(mariadb_stat_identity "$path") || return 1
    [[ "$before" == "$after" && ! -L "$path" ]] || return 1
    [[ "$(mariadb_marker_snapshot_value "$snapshot" marker-version)" == 1 ]] || return 1
    [[ "$(mariadb_marker_snapshot_value "$snapshot" state)" == ready ]] || return 1
    [[ "$(mariadb_marker_snapshot_value "$snapshot" observed-series)" == 10.11 ]] || return 1
    [[ "$(mariadb_marker_snapshot_value "$snapshot" minimum-runtime-series)" == 10.11 ]] || return 1
    backup=$(mariadb_marker_snapshot_value "$snapshot" pre-upgrade-backup) || return 1
    case "$backup" in
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
        *) return 1 ;;
    esac
    [[ -d "$backup" && ! -L "$backup" ]] || return 1
    printf '%s\n' "$backup"
}

activation_import_legacy_ready_marker() { # <target-backend-ref>
    local target_backend_ref="$1" backup facts bytes checksum live
    local source_backend_id source_backend_revision target_backend_id target_backend_revision
    local source_database_id source_database_version target_database_id running_database_id

    if [[ -e "$NVR_ACTIVATION_JOURNAL" && ! -L "$NVR_ACTIVATION_JOURNAL" \
            && -e "$MARIADB_DATADIR_MARKER" && ! -L "$MARIADB_DATADIR_MARKER" ]]; then
        return 0
    fi
    [[ -e "$NVR_LEGACY_MARIADB_DATADIR_MARKER" \
        || -L "$NVR_LEGACY_MARIADB_DATADIR_MARKER" ]] || return 0
    backup=$(activation_legacy_ready_backup) || {
        echo "ERROR: legacy MariaDB 10.11 ready marker is invalid" >&2
        return 1
    }
    live=$(mariadb_live_series 2>/dev/null || true)
    [[ "$live" == 10.11 ]] || {
        echo "ERROR: legacy MariaDB marker says ready, but live MariaDB is ${live:-unavailable}" >&2
        return 1
    }
    if [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]]; then
        activation_journal_read || return 1
        [[ "$AJ_STATE" == committed && "$AJ_BACKUP_PATH" == "$backup" ]] || return 1
        if [[ ! -e "$MARIADB_DATADIR_MARKER" && ! -L "$MARIADB_DATADIR_MARKER" ]]; then
            mariadb_write_datadir_marker ready 10.11 10.11 "$backup" \
                "$AJ_ACTIVATION_ID" "$AJ_MANIFEST_SHA256" || return 1
            echo "Recovered the ADR-110 floor from a committed legacy import."
        fi
        return 0
    fi
    [[ ! -e "$MARIADB_DATADIR_MARKER" && ! -L "$MARIADB_DATADIR_MARKER" ]] || {
        echo "ERROR: ADR-110 floor exists without its activation journal" >&2
        return 1
    }
    [[ -n "$target_backend_ref" ]] || return 0
    activation_manifest_read || return 1
    activation_manifest_validate_bundle || return 1
    facts=$(update_backup_archive_facts "$backup") || {
        echo "ERROR: legacy MariaDB recovery backup failed verification" >&2
        return 1
    }
    bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
    checksum=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
    [[ "$bytes" =~ ^[1-9][0-9]*$ && "$checksum" =~ ^[0-9a-f]{64}$ ]] || return 1

    source_backend_id=$(activation_service_image_id backend) || return 1
    source_backend_revision=$(update_image_revision "$source_backend_id")
    target_backend_id=$(docker image inspect --format '{{.Id}}' "$target_backend_ref" 2>/dev/null) || return 1
    target_backend_revision=$(update_image_revision "$target_backend_id")
    [[ "$target_backend_revision" == "$AM_TARGET_BACKEND_REVISION" ]] || return 1
    running_database_id=$(activation_service_image_id db) || return 1
    target_database_id=$(docker image inspect --format '{{.Id}}' \
        "$AM_TARGET_DATABASE_DIGEST" 2>/dev/null) || return 1
    [[ "$running_database_id" == "$target_database_id" ]] || {
        echo "ERROR: legacy MariaDB runtime does not match the staged target digest" >&2
        return 1
    }
    docker pull --quiet "$AM_SOURCE_DATABASE_DIGESTS" >/dev/null || return 1
    source_database_id=$(docker image inspect --format '{{.Id}}' \
        "$AM_SOURCE_DATABASE_DIGESTS" 2>/dev/null) || return 1
    source_database_version=$(update_backup_metadata_field \
        "$backup" source_database_version 2>/dev/null || true)
    [[ "$source_database_version" =~ ^10\.5\.[0-9A-Za-z.+_-]+$ ]] \
        || source_database_version=10.5.29-MariaDB-ubu2004

    activation_journal_prepare "$source_backend_id" "$source_backend_revision" \
        "$target_backend_id" "$target_backend_revision" "$source_database_id" \
        "$source_database_version" "$AM_COMPOSE_BUNDLE_SHA256" || return 1
    AJ_STATE=committed
    AJ_BACKUP_PATH="$backup"
    AJ_BACKUP_BYTES="$bytes"
    AJ_BACKUP_SHA256="$checksum"
    activation_journal_write || return 1
    mariadb_write_datadir_marker ready 10.11 10.11 "$backup" \
        "$AJ_ACTIVATION_ID" "$AJ_MANIFEST_SHA256" || return 1
    echo "Imported the legacy MariaDB 10.11 ready marker into ADR-110 state."
}

activation_journal_state_set() { # <state> [reason]
    AJ_STATE="$1"
    AJ_BLOCKED_REASON="${2:-NONE}"
    activation_journal_write
}

activation_record_failure() { # <reason-token> <structural:0|1> <safe-state>
    local reason="$1" structural="$2" safe_state="$3" fingerprint count
    fingerprint=$(printf '%s\n' "$reason" | activation_sha_stream) || return 1
    count=1
    if [[ "$AJ_FAILURE_FINGERPRINT" == "$fingerprint" ]]; then
        count=$((AJ_FAILURE_COUNT + 1))
    fi
    AJ_FAILURE_COUNT="$count"
    AJ_FAILURE_FINGERPRINT="$fingerprint"
    AJ_BLOCKED_REASON="$reason"
    if [[ "$structural" -eq 1 || "$count" -ge 3 ]]; then
        AJ_STATE=blocked
    else
        AJ_STATE="$safe_state"
    fi
    activation_journal_write || return 1
    echo "ERROR: staged activation $AJ_STATE: $reason (failure $count)" >&2
    return 1
}

activation_videostore_identity_valid() {
    local mount_path=/videostore/vs1 source root_device store_device expected actual
    mountpoint -q "$mount_path" 2>/dev/null || {
        NVR_ACTIVATION_FAILURE_DETAIL=videostore-unmounted
        echo "ERROR: activation backup target is not a mounted VideoStore: $mount_path" >&2
        return 1
    }
    source=$(findmnt -no SOURCE "$mount_path" 2>/dev/null | sed -n '1p' | sed 's/\[.*\]//')
    [[ -n "$source" ]] || return 1
    root_device=$(stat -c %d / 2>/dev/null || stat -f %d / 2>/dev/null) || return 1
    store_device=$(stat -c %d "$mount_path" 2>/dev/null || stat -f %d "$mount_path" 2>/dev/null) || return 1
    [[ "$root_device" != "$store_device" ]] || {
        NVR_ACTIVATION_FAILURE_DETAIL=videostore-root-shadow
        echo "ERROR: activation backup target resolves to the root filesystem" >&2
        return 1
    }
    expected=$(awk -v mp="$mount_path" '$1 ~ /^UUID=/ && $2 == mp { print substr($1, 6); exit }' \
        "$NVR_FSTAB" 2>/dev/null || true)
    if [[ -n "$expected" ]]; then
        actual=$(blkid -o value -s UUID "$source" 2>/dev/null || true)
        [[ -n "$actual" \
            && "$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" \
                == "$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" ]] || {
            NVR_ACTIVATION_FAILURE_DETAIL=videostore-identity-mismatch
            echo "ERROR: activation backup target does not match its fstab UUID" >&2
            return 1
        }
    fi
}

activation_backup_reserve_probe() { # <activation-id>
    local required_kb available_kb reserve blocks
    activation_videostore_identity_valid || return 1
    [[ -d "$NVR_UPDATE_BACKUP_ROOT" && ! -L "$NVR_UPDATE_BACKUP_ROOT" ]] || {
        mkdir -p "$NVR_UPDATE_BACKUP_ROOT" || return 1
        [[ -d "$NVR_UPDATE_BACKUP_ROOT" && ! -L "$NVR_UPDATE_BACKUP_ROOT" ]] || return 1
    }
    required_kb=$(du -sk "$MARIADB_DATADIR" 2>/dev/null | awk '{print $1}')
    [[ "$required_kb" =~ ^[0-9]+$ ]] || return 1
    required_kb=$((required_kb + NVR_ACTIVATION_BACKUP_RESERVE_EXTRA_KB))
    available_kb=$(df -Pk "$NVR_UPDATE_BACKUP_ROOT" 2>/dev/null | awk 'NR == 2 {print $4}')
    [[ "$available_kb" =~ ^[0-9]+$ && "$available_kb" -gt "$required_kb" ]] || {
        NVR_ACTIVATION_FAILURE_DETAIL="backup-space-${available_kb:-unknown}-${required_kb}"
        echo "ERROR: VideoStore has insufficient space for the staged activation backup" >&2
        return 1
    }
    reserve="$NVR_UPDATE_BACKUP_ROOT/.activation-reserve.${1:0:12}"
    # A leftover reserve at this deterministic path is throwaway scratch from an
    # earlier aborted probe of THIS SAME activation (for example an old-CLI probe,
    # or a fallocate that failed and returned before its own cleanup). Reclaim it
    # in place instead of aborting: the activation id keys the path to us and the
    # probe holds the backup lock, so nothing else writes it concurrently. Refuse
    # anything that is not a plain regular file (a symlink, directory, or device is
    # not ours to remove) and fail closed.
    if [[ -L "$reserve" || ( -e "$reserve" && ! -f "$reserve" ) ]]; then
        echo "ERROR: activation reserve path is not a reclaimable file: $reserve" >&2
        return 1
    fi
    rm -f "$reserve" || return 1
    if command -v fallocate >/dev/null 2>&1 && fallocate -l "${required_kb}K" "$reserve" 2>/dev/null; then
        :
    else
        # fallocate is missing, or it ran but the VideoStore filesystem does not
        # support the fallocate syscall. ext3 returns "Operation not supported"
        # and leaves a 0-byte reserve file behind. Remove any partial reserve, then
        # fall back to a portable write, cleaning up on failure so a failed probe
        # never strands the scratch file for the next attempt.
        rm -f "$reserve" || return 1
        blocks=$(((required_kb + 1023) / 1024))
        dd if=/dev/zero of="$reserve" bs=1M count="$blocks" >/dev/null 2>&1 || { rm -f "$reserve"; return 1; }
    fi
    sync
    rm -f "$reserve" || return 1
    sync
}

activation_backup_find() { # <completed|incomplete> <activation-id>
    $COMPOSE exec -T backend sh -c '
        root=$1; marker=$2; wanted=$3; activation=$4; found=""
        for archive in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]; do
            metadata="$archive/$marker"
            test -d "$archive" && test ! -L "$archive" || continue
            test -f "$metadata" && test ! -L "$metadata" || continue
            grep -Fqx "format_version=2" "$metadata" || continue
            grep -Fqx "kind=automatic-update" "$metadata" || continue
            grep -Fqx "state=$wanted" "$metadata" || continue
            grep -Fqx "transition_state=pending" "$metadata" || continue
            grep -Fqx "activation_id=$activation" "$metadata" || continue
            found=$archive
        done
        test -z "$found" || printf "%s\n" "$found"
    ' sh "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_METADATA" "$1" "$2"
}

activation_backup_incomplete_valid() { # <archive> <target-db-image-id> <target-db-version>
    local archive="$1" metadata expected key actual
    local keys
    metadata="$archive/$NVR_UPDATE_BACKUP_METADATA"
    activation_file_identity_valid "$metadata" 444 || return 1
    keys='format_version kind state transition_state created_at_utc source_backend_image_id source_backend_revision target_backend_image_id target_backend_revision source_database_image_id target_database_image_id source_database_version target_database_version activation_id activation_edge_id target_database_digest compose_bundle_sha256 migration_gate_version activation_manifest_sha256'
    LC_ALL=C awk -F= -v keys="$keys" '
        BEGIN { count=split(keys, required, " "); for (i=1; i<=count; i++) allowed[required[i]]=1 }
        index($0, "\r") != 0 { bad=1; next }
        $0 !~ /^[A-Za-z][A-Za-z0-9_-]*=[^=\r\n]*$/ { bad=1; next }
        { key=$1; if (!(key in allowed) || seen[key]++) bad=1 }
        END { for (i=1; i<=count; i++) if (seen[required[i]] != 1) bad=1; exit bad ? 1 : 0 }
    ' "$metadata" || return 1
    for expected in \
        'format_version=2' 'kind=automatic-update' 'state=incomplete' \
        'transition_state=pending' \
        "source_backend_image_id=$AJ_SOURCE_BACKEND_ID" \
        "source_backend_revision=$AJ_SOURCE_BACKEND_REVISION" \
        "target_backend_image_id=$AJ_TARGET_BACKEND_ID" \
        "target_backend_revision=$AJ_TARGET_BACKEND_REVISION" \
        "source_database_image_id=$AJ_SOURCE_DATABASE_ID" \
        "target_database_image_id=$2" \
        "source_database_version=$AJ_SOURCE_DATABASE_VERSION" \
        "target_database_version=$3" \
        "activation_id=$AJ_ACTIVATION_ID" "activation_edge_id=$AJ_EDGE_ID" \
        "target_database_digest=$AJ_TARGET_DATABASE_DIGEST" \
        "compose_bundle_sha256=$AJ_COMPOSE_BUNDLE_SHA256" \
        "migration_gate_version=$AJ_MIGRATION_GATE_VERSION" \
        "activation_manifest_sha256=$AJ_MANIFEST_SHA256"; do
        key=${expected%%=*}; actual=$(sed -n "s/^${key}=//p" "$metadata")
        [[ "$actual" == "${expected#*=}" ]] || return 1
    done
}

activation_backup_publication_valid() { # <archive>
    local archive="$1"
    case "$archive" in
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]|\
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].restored) ;;
        *) return 1 ;;
    esac
    [[ -d "$archive" && ! -L "$archive" ]] || return 1
    [[ -f "$archive/backup-format-version.txt" \
        && ! -L "$archive/backup-format-version.txt" \
        && "$(cat "$archive/backup-format-version.txt")" == 2 ]] || return 1
    [[ -f "$archive/db/dump-complete" && ! -L "$archive/db/dump-complete" \
        && "$(cat "$archive/db/dump-complete")" == complete ]] || return 1
    [[ -f "$archive/backup-complete" && ! -L "$archive/backup-complete" \
        && "$(cat "$archive/backup-complete")" == complete ]]
}

activation_backup_metadata_valid() { # <archive> <target-db-image-id> <target-db-version>
    local archive="$1" target_database_id="$2" target_database_version="$3"
    $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1; marker=$2; metadata="$archive/$marker"
        keys="format_version kind state transition_state created_at_utc source_backend_image_id source_backend_revision target_backend_image_id target_backend_revision source_database_image_id target_database_image_id source_database_version target_database_version dump_bytes dump_sha256 activation_id activation_edge_id target_database_digest compose_bundle_sha256 migration_gate_version activation_manifest_sha256"
        test -d "$archive" && test ! -L "$archive"
        test -f "$metadata" && test ! -L "$metadata"
        test "$(stat -c "%u:%a" "$metadata" 2>/dev/null)" = 0:444
        LC_ALL=C awk -F= -v keys="$keys" '\''
            BEGIN { count=split(keys, required, " "); for (i=1; i<=count; i++) allowed[required[i]]=1 }
            index($0, "\r") != 0 { bad=1; next }
            $0 !~ /^[A-Za-z][A-Za-z0-9_-]*=[^=\r\n]*$/ { bad=1; next }
            { key=$1; if (!(key in allowed) || seen[key]++) bad=1 }
            END { for (i=1; i<=count; i++) if (seen[required[i]] != 1) bad=1; exit bad ? 1 : 0 }
        '\'' "$metadata"
        value() { awk -F= -v key="$2" '\''$1 == key { print substr($0, length(key) + 2) }'\'' "$1"; }
        shift 2
        for expected in "$@"; do
            key=${expected%%=*}; wanted=${expected#*=}
            test "$(value "$metadata" "$key")" = "$wanted"
        done
    ' sh "$archive" "$NVR_UPDATE_BACKUP_METADATA" \
        'format_version=2' 'kind=automatic-update' 'state=completed' \
        'transition_state=pending' \
        "source_backend_image_id=$AJ_SOURCE_BACKEND_ID" \
        "source_backend_revision=$AJ_SOURCE_BACKEND_REVISION" \
        "target_backend_image_id=$AJ_TARGET_BACKEND_ID" \
        "target_backend_revision=$AJ_TARGET_BACKEND_REVISION" \
        "source_database_image_id=$AJ_SOURCE_DATABASE_ID" \
        "target_database_image_id=$target_database_id" \
        "source_database_version=$AJ_SOURCE_DATABASE_VERSION" \
        "target_database_version=$target_database_version" \
        "activation_id=$AJ_ACTIVATION_ID" "activation_edge_id=$AJ_EDGE_ID" \
        "target_database_digest=$AJ_TARGET_DATABASE_DIGEST" \
        "compose_bundle_sha256=$AJ_COMPOSE_BUNDLE_SHA256" \
        "migration_gate_version=$AJ_MIGRATION_GATE_VERSION" \
        "activation_manifest_sha256=$AJ_MANIFEST_SHA256"
}

activation_backup_verify() { # <archive> <target-db-image-id> <target-db-version>
    local archive="$1" facts bytes checksum saved_bytes saved_sha
    activation_backup_publication_valid "$archive" || return 1
    activation_backup_metadata_valid "$@" || return 1
    facts=$(update_backup_archive_facts "$archive") || return 1
    bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
    checksum=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
    saved_bytes=$(update_backup_metadata_field "$archive" dump_bytes 2>/dev/null || true)
    saved_sha=$(update_backup_metadata_field "$archive" dump_sha256 2>/dev/null || true)
    [[ "$bytes" == "$saved_bytes" && "$checksum" == "$saved_sha" ]] || return 1
    if [[ "$AJ_BACKUP_PATH" != NONE ]]; then
        [[ "$archive" == "$AJ_BACKUP_PATH" && "$bytes" == "$AJ_BACKUP_BYTES" \
            && "$checksum" == "$AJ_BACKUP_SHA256" ]] || return 1
    fi
    printf 'dump_bytes=%s\ndump_sha256=%s\n' "$bytes" "$checksum"
}

activation_backup_verify_host() { # <archive> <target-db-image-id> <target-db-version>
    local archive="$1" target_database_id="$2" target_database_version="$3"
    local metadata dump conf before after bytes checksum key expected actual
    local keys
    case "$archive" in
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]|\
        "$NVR_UPDATE_BACKUP_ROOT"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].restored) ;;
        *) return 1 ;;
    esac
    [[ -d "$archive" && ! -L "$archive" ]] || return 1
    activation_backup_publication_valid "$archive" || return 1
    metadata="$archive/$NVR_UPDATE_BACKUP_METADATA"
    dump="$archive/db/dtech.sql"
    conf="$archive/conf/dvs.conf"
    activation_file_identity_valid "$metadata" 444 || return 1
    [[ -f "$dump" && ! -L "$dump" && -s "$dump" \
        && -f "$conf" && ! -L "$conf" && -s "$conf" ]] || return 1
    before=$(mariadb_stat_identity "$metadata") || return 1
    keys='format_version kind state transition_state created_at_utc source_backend_image_id source_backend_revision target_backend_image_id target_backend_revision source_database_image_id target_database_image_id source_database_version target_database_version dump_bytes dump_sha256 activation_id activation_edge_id target_database_digest compose_bundle_sha256 migration_gate_version activation_manifest_sha256'
    LC_ALL=C awk -F= -v keys="$keys" '
        BEGIN { count=split(keys, required, " "); for (i=1; i<=count; i++) allowed[required[i]]=1 }
        index($0, "\r") != 0 { bad=1; next }
        $0 !~ /^[A-Za-z][A-Za-z0-9_-]*=[^=\r\n]*$/ { bad=1; next }
        { key=$1; if (!(key in allowed) || seen[key]++) bad=1 }
        END { for (i=1; i<=count; i++) if (seen[required[i]] != 1) bad=1; exit bad ? 1 : 0 }
    ' "$metadata" || return 1
    after=$(mariadb_stat_identity "$metadata") || return 1
    [[ "$before" == "$after" ]] || return 1
    for expected in \
        'format_version=2' 'kind=automatic-update' 'state=completed' \
        'transition_state=pending' \
        "source_backend_image_id=$AJ_SOURCE_BACKEND_ID" \
        "source_backend_revision=$AJ_SOURCE_BACKEND_REVISION" \
        "target_backend_image_id=$AJ_TARGET_BACKEND_ID" \
        "target_backend_revision=$AJ_TARGET_BACKEND_REVISION" \
        "source_database_image_id=$AJ_SOURCE_DATABASE_ID" \
        "target_database_image_id=$target_database_id" \
        "source_database_version=$AJ_SOURCE_DATABASE_VERSION" \
        "target_database_version=$target_database_version" \
        "activation_id=$AJ_ACTIVATION_ID" "activation_edge_id=$AJ_EDGE_ID" \
        "target_database_digest=$AJ_TARGET_DATABASE_DIGEST" \
        "compose_bundle_sha256=$AJ_COMPOSE_BUNDLE_SHA256" \
        "migration_gate_version=$AJ_MIGRATION_GATE_VERSION" \
        "activation_manifest_sha256=$AJ_MANIFEST_SHA256"; do
        key=${expected%%=*}; actual=$(sed -n "s/^${key}=//p" "$metadata")
        [[ "$actual" == "${expected#*=}" ]] || return 1
    done
    head -n 2 "$dump" | grep -Eq '^-- (MariaDB|MySQL) dump' || return 1
    for key in Camera Misc Server; do
        grep -Fqx -- "-- Table structure for table \`$key\`" "$dump" || return 1
    done
    tail -n 50 "$dump" | grep -Eq \
        '^-- Dump completed on [0-9]{4}-[0-9]{2}-[0-9]{2} ( [0-9]|[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$' || return 1
    bytes=$(wc -c < "$dump" | tr -d '[:space:]')
    checksum=$(activation_sha_file "$dump") || return 1
    [[ "$bytes" == "$AJ_BACKUP_BYTES" && "$checksum" == "$AJ_BACKUP_SHA256" ]] || return 1
    [[ "$(sed -n 's/^dump_bytes=//p' "$metadata")" == "$bytes" \
        && "$(sed -n 's/^dump_sha256=//p' "$metadata")" == "$checksum" ]]
}

activation_backup_tombstone() { # <archive>
    local archive="$1"
    $COMPOSE exec -T backend sh -c '
        set -eu
        archive=$1; root=$2; failed_root=$3; marker=$4
        case "$archive" in "$root"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;; *) exit 1 ;; esac
        test -d "$archive" && test ! -L "$archive"
        metadata="$archive/$marker"
        test -f "$metadata" && test ! -L "$metadata"
        temporary="$archive/.tombstone.$$"
        sed "s/^state=incomplete$/state=failed/" "$metadata" > "$temporary"
        printf "dump_bytes=0\ndump_sha256=NONE\n" >> "$temporary"
        chmod 0444 "$temporary"
        for child in "$archive"/* "$archive"/.[!.]* "$archive"/..?*; do
            test -e "$child" || test -L "$child" || continue
            test "$child" = "$temporary" && continue
            rm -rf -- "$child"
        done
        mv -f "$temporary" "$archive/$marker"
        mkdir -p "$failed_root"
        destination="$failed_root/${archive##*/}"
        test ! -e "$destination" || destination="$destination.$$"
        mv -- "$archive" "$destination"
        printf "%s\n" "$destination"
    ' sh "$archive" "$NVR_UPDATE_BACKUP_ROOT" "$NVR_UPDATE_BACKUP_FAILED_ROOT" \
        "$NVR_UPDATE_BACKUP_METADATA"
}

activation_keeper_name() { # backend|database
    printf '%s-%s-%s\n' "$NVR_ACTIVATION_KEEPER_PREFIX" "${AJ_ACTIVATION_ID:0:12}" "$1"
}

activation_keeper_remove_one() { # backend|database
    local name owner
    name=$(activation_keeper_name "$1") || return 1
    docker inspect "$name" >/dev/null 2>&1 || return 0
    owner=$(docker inspect --format '{{index .Config.Labels "net.dividia.nvr.activation-id"}}' "$name" 2>/dev/null || true)
    [[ "$owner" == "$AJ_ACTIVATION_ID" ]] || {
        echo "ERROR: refusing to remove foreign activation keeper: $name" >&2
        return 1
    }
    docker rm -f "$name" >/dev/null
}

activation_keepers_create() { # [verified-backup-path]
    local backup_path="${1:-}" role image name current owner keeper_backup
    for role in backend database; do
        case "$role" in backend) image="$AJ_SOURCE_BACKEND_ID" ;; database) image="$AJ_SOURCE_DATABASE_ID" ;; esac
        name=$(activation_keeper_name "$role") || return 1
        if docker inspect "$name" >/dev/null 2>&1; then
            current=$(docker inspect --format '{{.Image}}' "$name" 2>/dev/null || true)
            owner=$(docker inspect --format '{{index .Config.Labels "net.dividia.nvr.activation-id"}}' "$name" 2>/dev/null || true)
            keeper_backup=$(docker inspect --format '{{index .Config.Labels "net.dividia.nvr.activation-backup"}}' "$name" 2>/dev/null || true)
            if [[ "$current" == "$image" && "$owner" == "$AJ_ACTIVATION_ID" \
                    && "$keeper_backup" == "${backup_path:-NONE}" ]]; then
                continue
            fi
            activation_keeper_remove_one "$role" || return 1
        fi
        local -a keeper_args=(create --name "$name" --entrypoint /bin/true
            --label "net.dividia.nvr.activation-id=$AJ_ACTIVATION_ID"
            --label "net.dividia.nvr.activation-role=$role"
            --label "net.dividia.nvr.activation-backup=${backup_path:-NONE}")
        if [[ -n "$backup_path" ]]; then
            keeper_args+=(--mount "type=bind,src=$backup_path,dst=/activation-backup,readonly")
        fi
        docker "${keeper_args[@]}" "$image" >/dev/null || return 1
    done
}

activation_keepers_remove() {
    activation_keeper_remove_one backend || return 1
    activation_keeper_remove_one database
}

activation_supersede_loaded_journal() {
    local old_backup="$AJ_BACKUP_PATH"
    # Keeper names include the loaded activation identity. Remove them before
    # a replacement journal changes that identity and makes them unreachable.
    activation_keepers_remove || return 1
    if [[ "$old_backup" != NONE ]]; then
        update_backup_set_transition_state "$old_backup" superseded || return 1
    fi
}

activation_pull_target_database() {
    local ref="$AJ_TARGET_DATABASE_DIGEST" id
    docker pull --quiet "$ref" >/dev/null || return 1
    id=$(docker image inspect --format '{{.Id}}' "$ref" 2>/dev/null) || return 1
    activation_digest_valid "$id" || return 1
    printf '%s\n' "$id"
}

activation_prepare_backup() { # <target-db-image-id> <target-db-version>
    local target_database_id="$1" target_database_version="$2"
    local archive facts bytes checksum incomplete result=0
    NVR_ACTIVATION_FAILURE_DETAIL="backup-verification"
    NVR_ACTIVATION_FAILURE_STRUCTURAL=0

    if [[ "$AJ_BACKUP_PATH" != NONE ]]; then
        activation_backup_verify "$AJ_BACKUP_PATH" "$target_database_id" \
            "$target_database_version" >/dev/null || {
            NVR_ACTIVATION_FAILURE_DETAIL="backup-contract"
            NVR_ACTIVATION_FAILURE_STRUCTURAL=1
            return 1
        }
        activation_keepers_create "$AJ_BACKUP_PATH" || return 1
        [[ "$AJ_STATE" == backup-verified ]] || activation_journal_state_set backup-verified
        return $?
    fi

    archive=$(activation_backup_find completed "$AJ_ACTIVATION_ID") || return 1
    if [[ -n "$archive" ]]; then
        facts=$(activation_backup_verify "$archive" "$target_database_id" \
            "$target_database_version") || {
            NVR_ACTIVATION_FAILURE_DETAIL="backup-contract"
            NVR_ACTIVATION_FAILURE_STRUCTURAL=1
            return 1
        }
    else
        activation_journal_state_set backup-in-progress || return 1
        backup_lock_acquire || return 1
        activation_backup_reserve_probe "$AJ_ACTIVATION_ID" || result=$?

        if [[ $result -eq 0 ]]; then
            incomplete=$(activation_backup_find incomplete "$AJ_ACTIVATION_ID") || result=$?
        fi
        if [[ $result -eq 0 && -n "$incomplete" ]]; then
            if ! activation_backup_incomplete_valid "$incomplete" \
                    "$target_database_id" "$target_database_version"; then
                NVR_ACTIVATION_FAILURE_DETAIL="backup-incomplete-identity"
                NVR_ACTIVATION_FAILURE_STRUCTURAL=1
                result=1
            fi
            if [[ $result -eq 0 ]] \
                    && ! activation_backup_publication_valid "$incomplete"; then
                NVR_ACTIVATION_FAILURE_DETAIL="backup-incomplete-publication"
                activation_backup_tombstone "$incomplete" >/dev/null || result=$?
                incomplete=""
            fi
            if [[ $result -eq 0 && -n "$incomplete" ]] \
                    && ! facts=$(update_backup_archive_facts "$incomplete"); then
                NVR_ACTIVATION_FAILURE_DETAIL="backup-incomplete-payload"
                activation_backup_tombstone "$incomplete" >/dev/null || result=$?
                incomplete=""
            fi
            if [[ $result -eq 0 && -n "$incomplete" ]]; then
                bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
                checksum=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
                update_backup_write_metadata "$incomplete" \
                    "$AJ_SOURCE_BACKEND_ID" "$AJ_SOURCE_BACKEND_REVISION" \
                    "$AJ_TARGET_BACKEND_ID" "$AJ_TARGET_BACKEND_REVISION" \
                    "$AJ_SOURCE_DATABASE_ID" "$target_database_id" \
                    "$AJ_SOURCE_DATABASE_VERSION" "$target_database_version" \
                    "$bytes" "$checksum" "$AJ_ACTIVATION_ID" "$AJ_EDGE_ID" \
                    "$AJ_TARGET_DATABASE_DIGEST" "$AJ_COMPOSE_BUNDLE_SHA256" \
                    "$AJ_MIGRATION_GATE_VERSION" "$AJ_MANIFEST_SHA256" || result=$?
                archive="$incomplete"
            fi
        fi
        if [[ $result -eq 0 && -z "$archive" ]]; then
            NVR_LAST_BACKUP_OUTPUT=""
            backup_run_locked \
                "$AJ_SOURCE_BACKEND_ID" "$AJ_SOURCE_BACKEND_REVISION" \
                "$AJ_TARGET_BACKEND_ID" "$AJ_TARGET_BACKEND_REVISION" \
                "$AJ_SOURCE_DATABASE_ID" "$target_database_id" \
                "$AJ_SOURCE_DATABASE_VERSION" "$target_database_version" \
                "$AJ_ACTIVATION_ID" "$AJ_EDGE_ID" "$AJ_TARGET_DATABASE_DIGEST" \
                "$AJ_COMPOSE_BUNDLE_SHA256" "$AJ_MIGRATION_GATE_VERSION" \
                "$AJ_MANIFEST_SHA256" || result=$?
            archive="$NVR_LAST_BACKUP_DIR"
            if [[ $result -ne 0 ]]; then
                NVR_ACTIVATION_FAILURE_DETAIL="backup-command-$(printf '%s\n' "$NVR_LAST_BACKUP_OUTPUT" | activation_sha_stream)"
            fi
            if [[ $result -ne 0 && -n "$archive" \
                    && "$NVR_LAST_BACKUP_OUTPUT" =~ (ENOSPC|No[[:space:]]space[[:space:]]left[[:space:]]on[[:space:]]device) ]]; then
                activation_backup_tombstone "$archive" >/dev/null || true
                archive=""
            fi
            if [[ $result -eq 0 ]]; then
                [[ -n "$archive" ]] || result=1
                facts=$(update_backup_archive_facts "$archive") || result=$?
            fi
            if [[ $result -eq 0 ]]; then
                bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
                checksum=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
                update_backup_write_metadata "$archive" \
                    "$AJ_SOURCE_BACKEND_ID" "$AJ_SOURCE_BACKEND_REVISION" \
                    "$AJ_TARGET_BACKEND_ID" "$AJ_TARGET_BACKEND_REVISION" \
                    "$AJ_SOURCE_DATABASE_ID" "$target_database_id" \
                    "$AJ_SOURCE_DATABASE_VERSION" "$target_database_version" \
                    "$bytes" "$checksum" "$AJ_ACTIVATION_ID" "$AJ_EDGE_ID" \
                    "$AJ_TARGET_DATABASE_DIGEST" "$AJ_COMPOSE_BUNDLE_SHA256" \
                    "$AJ_MIGRATION_GATE_VERSION" "$AJ_MANIFEST_SHA256" || result=$?
            fi
        fi
        if [[ $result -eq 0 ]]; then
            facts=$(activation_backup_verify "$archive" "$target_database_id" \
                "$target_database_version") || result=$?
        fi
        backup_lock_release
        [[ $result -eq 0 ]] || return 1
    fi

    bytes=$(printf '%s\n' "$facts" | sed -n 's/^dump_bytes=//p')
    checksum=$(printf '%s\n' "$facts" | sed -n 's/^dump_sha256=//p')
    AJ_BACKUP_PATH="$archive"
    AJ_BACKUP_BYTES="$bytes"
    AJ_BACKUP_SHA256="$checksum"
    activation_keepers_create "$archive" || return 1
    activation_journal_state_set backup-verified
}

activation_recording_healthy() {
    # Ask mpengine for its own recording-health verdict instead of reinventing a
    # filesystem clip-growth probe. mpengine already runs the full recording
    # health machinery (record_terminal_health per camera: no_store, no_keyframe,
    # vmd_stall) and exposes it at GET /health/recording (loopback only; the
    # engine is host-networked, so 127.0.0.1 works from the host). The body is
    # {"status":"ok"} only when NO camera has a latched terminal recording fault.
    # A quiet motion camera (RECORD_STATE_QUIET: connected, packets flowing, VMD
    # running, no motion) is HEALTHY and reports ok, so this does not spuriously
    # fail a legitimately quiet site the way a clip-growth probe did (cs2619, a
    # motion-only LPR terminal activating at 2 AM with no traffic). The 503 status
    # code is gated by NVR_RECORDING_HEALTH_503 (off by default), so read the body
    # status, not the HTTP code.
    local enabled response body
    enabled=$(activation_enabled_camera_count) || return 1
    [[ "$enabled" =~ ^[0-9]+$ ]] || return 1
    [[ "$enabled" -gt 0 ]] || return 0
    response=$(activation_engine_recording_status) || return 1
    # Strip the HTTP headers (everything up to and including the blank line) and
    # match the top-level verdict at the START of the JSON body. mpengine always
    # emits the verdict as the first key ({"status":"ok"} when healthy), so a
    # nested per-camera "status":"ok" inside a terminal body can never satisfy
    # this anchored match the way a whole-response substring grep would.
    body=$(printf '%s' "$response" | awk 'body { print } /^\r?$/ { body = 1 }')
    [[ "$body" == '{"status":"ok"'* ]]
}

activation_enabled_camera_count() {
    $COMPOSE exec -T db mariadb -u root -N -B dtech -e \
        'SELECT COUNT(*) FROM Camera WHERE fEnable=1' 2>/dev/null
}

activation_engine_recording_status() {
    # Fetch mpengine's /health/recording response. Split out so tests can stub
    # the transport. The engine is host-networked, so 127.0.0.1 reaches it, and
    # the route is loopback-only. curl is not assumed on every host OS, so use a
    # bounded pure-bash /dev/tcp request (same pattern as activation_playback_reachable).
    timeout "$NVR_ACTIVATION_RECORDING_HEALTH_TIMEOUT" bash -c '
        exec 3<>/dev/tcp/127.0.0.1/'"$NVR_ENGINE_HTTP_PORT"' || exit 1
        printf "GET /health/recording HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" >&3
        cat <&3' 2>/dev/null
}

activation_playback_reachable() {
    timeout 5 bash -c ': < /dev/tcp/127.0.0.1/43203' >/dev/null 2>&1
}

activation_post_health() {
    local cid health
    [[ "$(mariadb_live_series 2>/dev/null || true)" == 10.11 ]] || return 1
    cid=$($COMPOSE ps -aq db 2>/dev/null | sed -n '1p')
    [[ -n "$cid" ]] || return 1
    health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null)
    [[ "$health" == healthy ]] || return 1
    [[ ! -e "$INSTALL_DIR/data/config/rda-db-update.failed" ]] || return 1
    activation_backend_mode_read || return 1
    [[ "$ABM_MODE" == activated && "$ABM_BACKEND_IMAGE_ID" == "$AJ_TARGET_BACKEND_ID" \
        && "$ABM_MANIFEST_SHA256" == "$AJ_MANIFEST_SHA256" ]] || return 1
    $COMPOSE exec -T db mariadb -u root -N -B -e 'SELECT 1' 2>/dev/null \
        | grep -Fqx 1 || return 1
    activation_videostore_identity_valid || return 1
    activation_recording_healthy || return 1
    activation_playback_reachable || return 1
    core_stack_converged "$(core_services)"
}

activation_target_database_started() { # <target-db-image-id>
    local cid image status exit_code marker_id
    if [[ -f "$MARIADB_OPEN_ATTEMPT_MARKER" && ! -L "$MARIADB_OPEN_ATTEMPT_MARKER" ]]; then
        marker_id=$(sed -n '1p' "$MARIADB_OPEN_ATTEMPT_MARKER" 2>/dev/null || true)
        [[ "$marker_id" == "$AJ_ACTIVATION_ID" ]] && return 0
        return 0
    fi
    cid=$($COMPOSE ps -aq db 2>/dev/null | sed -n '1p')
    [[ -n "$cid" ]] || return 1
    image=$(docker inspect --format '{{.Image}}' "$cid" 2>/dev/null || true)
    status=$(docker inspect --format '{{.State.Status}}' "$cid" 2>/dev/null || true)
    exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$cid" 2>/dev/null || true)
    [[ "$image" == "$1" ]] || return 1
    [[ "$status" == exited && "$exit_code" == 78 ]] && return 1
    [[ "$status" != created && -n "$status" ]]
}

activation_preboundary_abort() { # <reason> <structural>
    local reason="$1" structural="$2" core
    activation_record_failure "$reason" "$structural" staged || true
    activation_journal_read || return 1
    case "$AJ_STATE" in staged|blocked) ;; *) return 1 ;; esac
    activation_overlay_token_set absent || return 1
    rm -f "$MARIADB_OPEN_ATTEMPT_MARKER" 2>/dev/null || true
    mariadb_write_datadir_marker ready 10.5 10.5 NONE || return 1
    core=$(core_services)
    if ! converge_core_stack "$core"; then
        AJ_STATE=failed
        AJ_BLOCKED_REASON=compatibility-recovery
        AJ_FAILURE_FINGERPRINT=$(printf '%s\n' compatibility-recovery | activation_sha_stream)
        activation_journal_write || true
        return 1
    fi
    return 1
}

activation_restore_to_compatibility() { # <reason>
    local reason="$1" suffix failed_datadir partial_datadir timestamp db_pass archive_name
    local restored_archive restore_done=0
    activation_journal_read || return 1
    AJ_STATE=failed
    AJ_BLOCKED_REASON=restore-in-progress
    activation_journal_write || return 1
    restored_archive="$AJ_BACKUP_PATH.restored"
    if [[ -d "$AJ_BACKUP_PATH" && -d "$restored_archive" ]]; then
        activation_record_failure ambiguous-restore-archive 1 failed || true
        return 1
    elif [[ -d "$restored_archive" ]]; then
        activation_backup_verify_host "$restored_archive" \
            "$NVR_ACTIVATION_TARGET_DATABASE_ID" \
            "$NVR_ACTIVATION_TARGET_DATABASE_VERSION" >/dev/null || {
            activation_record_failure restored-backup-invalid 1 failed || true
            return 1
        }
        restore_done=1
    else
        activation_backup_verify_host "$AJ_BACKUP_PATH" \
            "$NVR_ACTIVATION_TARGET_DATABASE_ID" \
            "$NVR_ACTIVATION_TARGET_DATABASE_VERSION" >/dev/null || {
            activation_record_failure backup-lost-after-open 1 failed || true
            return 1
        }
    fi
    local core
    core=$(core_services)
    # shellcheck disable=SC2086
    $COMPOSE stop $core >/dev/null 2>&1 || true
    activation_overlay_token_set absent || return 1
    if [[ "$restore_done" -eq 0 ]]; then
        case "$MARIADB_DATADIR" in "$INSTALL_DIR/data/db_data") ;; *) return 1 ;; esac
        suffix=${AJ_ACTIVATION_ID:0:12}
        failed_datadir="$INSTALL_DIR/data/db_data.activation-failed-$suffix"
        if [[ ! -e "$failed_datadir" ]]; then
            [[ -d "$MARIADB_DATADIR" && ! -L "$MARIADB_DATADIR" ]] || return 1
            mv "$MARIADB_DATADIR" "$failed_datadir" || return 1
        elif [[ -e "$MARIADB_DATADIR" ]]; then
            timestamp=$(date -u +%Y%m%d%H%M%S)
            partial_datadir="$INSTALL_DIR/data/db_data.activation-restore-partial-$suffix-$timestamp"
            [[ ! -e "$partial_datadir" ]] || return 1
            mv "$MARIADB_DATADIR" "$partial_datadir" || return 1
        fi
        mkdir "$MARIADB_DATADIR" || return 1
        chmod 755 "$MARIADB_DATADIR" || return 1
    fi
    [[ -d "$MARIADB_DATADIR" && ! -L "$MARIADB_DATADIR" ]] || return 1
    mariadb_write_datadir_marker ready 10.5 10.5 NONE || return 1
    $COMPOSE up -d db || return 1
    converge_core_stack db || return 1
    if [[ "$restore_done" -eq 0 ]]; then
        db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2-)
        archive_name=${AJ_BACKUP_PATH##*/}
        docker run --rm --network host --pid host --privileged \
            --entrypoint /usr/bin/rda-db \
            -e "MYSQL_ROOT_PASSWORD=${db_pass:-lynn1094}" \
            -v "$INSTALL_DIR/data/config:/data" \
            -v /videostore:/videostore:rslave \
            -v /var/run/docker.sock:/var/run/docker.sock \
            "$AJ_SOURCE_BACKEND_ID" --restore-archive "$archive_name" || return 1
        [[ -d "$restored_archive" && ! -d "$AJ_BACKUP_PATH" ]] || return 1
    fi
    [[ "$(mariadb_live_series 2>/dev/null || true)" == 10.5 ]] || return 1
    $COMPOSE exec -T db mariadb -u root -N -B -e \
        "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='dtech' AND table_name IN ('Camera','Misc','Server');" \
        2>/dev/null | grep -Fqx 3 || return 1
    core=$(core_services)
    converge_core_stack "$core" || return 1
    activation_backend_mode_read || return 1
    [[ "$ABM_MODE" == compatibility ]] || return 1
    AJ_STATE=blocked
    AJ_BLOCKED_REASON=restore-complete
    AJ_FAILURE_COUNT=$((AJ_FAILURE_COUNT + 1))
    AJ_FAILURE_FINGERPRINT=$(printf '%s\n' "$reason" | activation_sha_stream)
    activation_journal_write || return 1
    activation_keepers_remove || true
    echo "ERROR: MariaDB 10.11 activation failed. The logical backup restored MariaDB 10.5." >&2
    return 1
}

activation_reconcile_committed() {
    activation_overlay_token_set present || return 1
    if [[ "$AJ_BACKUP_PATH" != NONE && -d "$AJ_BACKUP_PATH" ]]; then
        update_backup_set_transition_applied "$AJ_BACKUP_PATH" || \
            echo "WARN: committed activation backup is still protected" >&2
    fi
    activation_keepers_remove || return 1
}

activation_execute() {
    local target_database_id target_database_version core
    activation_journal_read || return 1
    case "$AJ_STATE" in
        committed)
            activation_reconcile_committed
            return $?
            ;;
        blocked)
            echo "ERROR: staged activation is held: $AJ_BLOCKED_REASON" >&2
            return 1
            ;;
        failed)
            target_database_id=$(activation_pull_target_database) || return 1
            target_database_version=$(update_target_database_version "$AJ_TARGET_DATABASE_DIGEST")
            NVR_ACTIVATION_TARGET_DATABASE_ID="$target_database_id"
            NVR_ACTIVATION_TARGET_DATABASE_VERSION="$target_database_version"
            activation_restore_to_compatibility "$AJ_BLOCKED_REASON"
            return $?
            ;;
    esac

    target_database_id=$(activation_pull_target_database) || {
        activation_record_failure target-database-pull 0 staged || true
        return 1
    }
    target_database_version=$(update_target_database_version "$AJ_TARGET_DATABASE_DIGEST")
    NVR_ACTIVATION_TARGET_DATABASE_ID="$target_database_id"
    NVR_ACTIVATION_TARGET_DATABASE_VERSION="$target_database_version"
    [[ "$(docker image inspect --format '{{.Id}}' "$AM_SOURCE_DATABASE_DIGESTS" 2>/dev/null || true)" \
        == "$AJ_SOURCE_DATABASE_ID" ]] || {
        activation_record_failure source-database-identity 1 staged || true
        return 1
    }
    activation_keepers_create "${AJ_BACKUP_PATH/NONE/}" || {
        activation_record_failure source-image-pin 1 staged || true
        return 1
    }
    if ! activation_prepare_backup "$target_database_id" "$target_database_version"; then
        activation_journal_read || return 1
        local failure_detail="${NVR_ACTIVATION_FAILURE_DETAIL:-backup-verification}"
        local failure_structural="${NVR_ACTIVATION_FAILURE_STRUCTURAL:-0}"
        if [[ "$AJ_STATE" == activating || "$AJ_STATE" == verifying ]]; then
            if [[ "$AJ_STATE" == verifying ]] \
                    || activation_target_database_started "$target_database_id"; then
                AJ_STATE=failed
                AJ_BLOCKED_REASON="$failure_detail"
                activation_journal_write || return 1
                activation_restore_to_compatibility "$failure_detail" || true
            else
                activation_preboundary_abort "$failure_detail" "$failure_structural" || true
            fi
        else
            activation_record_failure "$failure_detail" "$failure_structural" staged || true
        fi
        return 1
    fi
    activation_journal_read || return 1
    if ! activation_backup_verify "$AJ_BACKUP_PATH" "$target_database_id" \
            "$target_database_version" >/dev/null; then
        activation_preboundary_abort backup-missing-before-open 1 || true
        return 1
    fi

    if [[ "$AJ_STATE" == backup-verified ]]; then
        activation_journal_state_set activating || return 1
        mariadb_write_datadir_marker pending 10.5 10.11 "$AJ_BACKUP_PATH" \
            "$AJ_ACTIVATION_ID" "$AJ_MANIFEST_SHA256" || {
            activation_preboundary_abort activation-ticket 1 || true
            return 1
        }
    fi
    activation_overlay_token_set present || {
        activation_preboundary_abort activation-overlay 1 || true
        return 1
    }
    if ! converge_core_stack db; then
        if activation_target_database_started "$target_database_id"; then
            AJ_STATE=failed; AJ_BLOCKED_REASON="target-database"
            activation_journal_write || return 1
            activation_restore_to_compatibility target-database
        else
            activation_preboundary_abort target-database 0 || true
        fi
        return 1
    fi
    activation_journal_read || return 1
    activation_journal_state_set verifying || return 1
    core=$(core_services)
    if ! converge_core_stack "$core"; then
        AJ_STATE=failed; AJ_BLOCKED_REASON="target-convergence"
        activation_journal_write || return 1
        activation_restore_to_compatibility target-convergence
        return 1
    fi
    if ! activation_post_health; then
        AJ_STATE=failed; AJ_BLOCKED_REASON=post-health
        activation_journal_write || return 1
        activation_restore_to_compatibility post-health
        return 1
    fi
    mariadb_write_datadir_marker ready 10.11 10.11 "$AJ_BACKUP_PATH" \
        "$AJ_ACTIVATION_ID" "$AJ_MANIFEST_SHA256" || return 1
    activation_journal_state_set committed || return 1
    update_backup_set_transition_applied "$AJ_BACKUP_PATH" || \
        echo "WARN: activation backup remains protected until the next update" >&2
    activation_keepers_remove || return 1
    echo "Staged activation committed: $AJ_ACTIVATION_ID"
}

activation_should_run_now() { # <activate-now> <target-backend-id>
    local activate_now="$1" target_backend_id="$2"
    [[ "$activate_now" -eq 1 ]] && return 0
    [[ "${NVR_UPDATE_JOURNAL_PRESENT_AT_START:-0}" -eq 1 ]] && return 0
    [[ "${NVR_UPDATE_CHECKPOINT_OK:-0}" -eq 1 \
        && "${NVR_UPDATE_CHECKPOINT_BACKEND_ID:-}" == "$target_backend_id" ]]
}

# A shell process cannot adopt updater code extracted over its own file. The
# new installer therefore arms one generic, root-owned continuation whenever
# the running updater generation differs from the installed generation. The
# request is exact-image bound and expires after two hours. Every future update
# tail task belongs in cmd_update_monitored and automatically joins this handoff.
update_continuation_sha_valid() {
    [[ "$1" =~ ^[0-9a-f]{64}$ ]]
}

update_continuation_request_read() {
    local path="${1:-$NVR_UPDATE_CONTINUATION_REQUEST}"
    local before after line key value lines=0
    AC_FORMAT_VERSION=""; AC_PROTOCOL_VERSION=""; AC_TARGET_BACKEND_ID=""
    AC_TARGET_UPDATER_SHA256=""; AC_CREATED_EPOCH=""
    activation_file_identity_valid "$path" 600 || return 1
    before=$(mariadb_stat_identity "$path") || return 1
    while IFS= read -r line || [[ -n "$line" ]]; do
        lines=$((lines + 1))
        [[ "$line" == *=* && "$line" != *$'\r'* ]] || return 1
        key=${line%%=*}; value=${line#*=}
        activation_value_token_safe "$value" || return 1
        case "$key" in
            format_version) [[ -z "$AC_FORMAT_VERSION" ]] || return 1; AC_FORMAT_VERSION="$value" ;;
            protocol_version) [[ -z "$AC_PROTOCOL_VERSION" ]] || return 1; AC_PROTOCOL_VERSION="$value" ;;
            target_backend_image_id) [[ -z "$AC_TARGET_BACKEND_ID" ]] || return 1; AC_TARGET_BACKEND_ID="$value" ;;
            target_updater_sha256) [[ -z "$AC_TARGET_UPDATER_SHA256" ]] || return 1; AC_TARGET_UPDATER_SHA256="$value" ;;
            created_epoch) [[ -z "$AC_CREATED_EPOCH" ]] || return 1; AC_CREATED_EPOCH="$value" ;;
            *) return 1 ;;
        esac
    done < "$path"
    after=$(mariadb_stat_identity "$path") || return 1
    [[ "$before" == "$after" ]] || return 1
    case "$AC_FORMAT_VERSION" in
        1)
            [[ "$lines" -eq 4 && "$AC_PROTOCOL_VERSION" == "$NVR_ACTIVATION_PROTOCOL" \
                && -z "$AC_TARGET_UPDATER_SHA256" ]] || return 1
            AC_TARGET_UPDATER_SHA256=$(activation_sha_file "$INSTALL_DIR/nvr") || return 1
            ;;
        2)
            [[ "$lines" -eq 4 && -z "$AC_PROTOCOL_VERSION" ]] || return 1
            ;;
        *) return 1 ;;
    esac
    activation_digest_valid "$AC_TARGET_BACKEND_ID" || return 1
    update_continuation_sha_valid "$AC_TARGET_UPDATER_SHA256" || return 1
    [[ "$AC_CREATED_EPOCH" =~ ^[0-9]{1,12}$ ]]
}

update_continuation_request_write() { # <target-backend-image-id> <target-updater-sha256>
    local target="$1" updater_sha="$2" temporary uid now
    local destination="$NVR_UPDATE_CONTINUATION_REQUEST"
    activation_digest_valid "$target" || return 1
    update_continuation_sha_valid "$updater_sha" || return 1
    activation_ensure_root || return 1
    uid=$(activation_expected_uid) || return 1
    now=$(date +%s) || return 1
    (
        temporary=$(mktemp "$NVR_ACTIVATION_DIR/.continuation.new.XXXXXX") || exit 1
        trap 'rm -f "$temporary"' EXIT
        ( umask 077
          printf '%s\n' 'format_version=2' \
              "target_backend_image_id=$target" \
              "target_updater_sha256=$updater_sha" "created_epoch=$now" \
              > "$temporary"
        ) || exit 1
        chown "$uid" "$temporary" 2>/dev/null || true
        chmod 600 "$temporary" || exit 1
        update_continuation_request_read "$temporary" || exit 1
        sync
        mv -f "$temporary" "$destination" || exit 1
        sync
        trap - EXIT
    ) || return 1
    update_continuation_request_read
}

update_continuation_completed_read() {
    local path="${1:-$NVR_UPDATE_CONTINUATION_COMPLETED}"
    local before after line key value lines=0
    ACC_FORMAT_VERSION=""; ACC_UPDATER_SHA256=""; ACC_COMPLETED_EPOCH=""
    activation_file_identity_valid "$path" 600 || return 1
    before=$(mariadb_stat_identity "$path") || return 1
    while IFS= read -r line || [[ -n "$line" ]]; do
        lines=$((lines + 1))
        [[ "$line" == *=* && "$line" != *$'\r'* ]] || return 1
        key=${line%%=*}; value=${line#*=}
        activation_value_token_safe "$value" || return 1
        case "$key" in
            format_version) [[ -z "$ACC_FORMAT_VERSION" ]] || return 1; ACC_FORMAT_VERSION="$value" ;;
            updater_sha256) [[ -z "$ACC_UPDATER_SHA256" ]] || return 1; ACC_UPDATER_SHA256="$value" ;;
            completed_epoch) [[ -z "$ACC_COMPLETED_EPOCH" ]] || return 1; ACC_COMPLETED_EPOCH="$value" ;;
            *) return 1 ;;
        esac
    done < "$path"
    after=$(mariadb_stat_identity "$path") || return 1
    [[ "$before" == "$after" && "$lines" -eq 3 \
        && "$ACC_FORMAT_VERSION" == 1 ]] || return 1
    update_continuation_sha_valid "$ACC_UPDATER_SHA256" || return 1
    [[ "$ACC_COMPLETED_EPOCH" =~ ^[0-9]{1,12}$ ]]
}

update_continuation_completed_write() { # <updater-sha256>
    local updater_sha="$1" temporary uid now destination="$NVR_UPDATE_CONTINUATION_COMPLETED"
    update_continuation_sha_valid "$updater_sha" || return 1
    activation_ensure_root || return 1
    uid=$(activation_expected_uid) || return 1
    now=$(date +%s) || return 1
    (
        temporary=$(mktemp "$NVR_ACTIVATION_DIR/.continuation-completed.new.XXXXXX") || exit 1
        trap 'rm -f "$temporary"' EXIT
        ( umask 077
          printf '%s\n' 'format_version=1' "updater_sha256=$updater_sha" \
              "completed_epoch=$now" > "$temporary"
        ) || exit 1
        chown "$uid" "$temporary" 2>/dev/null || true
        chmod 600 "$temporary" || exit 1
        update_continuation_completed_read "$temporary" || exit 1
        sync
        mv -f "$temporary" "$destination" || exit 1
        sync
        trap - EXIT
    ) || return 1
    update_continuation_completed_read
}

update_continuation_cron_write() {
    local temporary
    temporary=$(mktemp "${NVR_UPDATE_CONTINUATION_CRON}.new.XXXXXX") || return 1
    if ! cat > "$temporary" <<'EOF'
# One-shot continuation for a newly installed NVR updater generation.
# Owned by /opt/dividia/nvr; removed after success, failure, or two hours.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
* * * * * root /opt/dividia/nvr update-continuation continue >/var/log/dividia-nvr-update-continuation.log 2>&1
EOF
    then
        rm -f "$temporary"
        return 1
    fi
    if ! chmod 644 "$temporary" \
            || ! mv -f "$temporary" "$NVR_UPDATE_CONTINUATION_CRON"; then
        rm -f "$temporary"
        return 1
    fi
}

update_continuation_cleanup() {
    rm -f "$NVR_UPDATE_CONTINUATION_REQUEST" \
        "$NVR_UPDATE_CONTINUATION_CRON"
}

update_continuation_schedule() {
    local target_ref target_id updater_sha
    [[ "${NVR_UPDATE_CONTINUATION_ACTIVE:-0}" != 1 ]] || return 0
    updater_sha=$(activation_sha_file "$INSTALL_DIR/nvr") || return 1
    update_continuation_sha_valid "$updater_sha" || return 1
    if [[ "${NVR_RUNNING_UPDATER_SHA256:-}" == "$updater_sha" ]]; then
        update_continuation_cleanup
        return 0
    fi
    if update_continuation_completed_read \
            && [[ "$ACC_UPDATER_SHA256" == "$updater_sha" ]]; then
        update_continuation_cleanup
        return 0
    fi
    target_ref=$(update_target_backend_image_ref 2>/dev/null) || return 1
    target_id=$(docker image inspect --format '{{.Id}}' "$target_ref" 2>/dev/null) || return 1
    activation_digest_valid "$target_id" || return 1
    update_continuation_request_write "$target_id" "$updater_sha" || return 1
    if ! update_continuation_cron_write; then
        rm -f "$NVR_UPDATE_CONTINUATION_REQUEST"
        return 1
    fi
    echo "Updater continuation scheduled within this update window."
}

update_continuation_target_ready() { # <target-backend-image-id>
    local target="$1" cid image running health
    cid=$($COMPOSE ps -aq backend 2>/dev/null | sed -n '1p')
    [[ -n "$cid" ]] || return 1
    image=$(docker inspect --format '{{.Image}}' "$cid" 2>/dev/null) || return 1
    running=$(docker inspect --format '{{.State.Running}}' "$cid" 2>/dev/null) || return 1
    health=$(docker inspect --format \
        '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
        "$cid" 2>/dev/null) || return 1
    [[ "$image" == "$target" && "$running" == true \
        && ( "$health" == healthy || "$health" == none ) ]]
}

update_continuation_run_locked() {
    local now age installed_sha result=0
    if ! update_continuation_request_read; then
        rm -f "$NVR_UPDATE_CONTINUATION_CRON"
        if [[ -e "$NVR_UPDATE_CONTINUATION_REQUEST" \
                || -L "$NVR_UPDATE_CONTINUATION_REQUEST" ]]; then
            echo "ERROR: invalid updater continuation request; automatic continuation disabled" >&2
            return 1
        fi
        return 0
    fi
    now=$(date +%s) || return 1
    age=$((now - AC_CREATED_EPOCH))
    if [[ "$age" -lt 0 || "$age" -gt "$NVR_UPDATE_CONTINUATION_MAX_AGE_SECONDS" ]]; then
        echo "WARN: updater continuation expired; the next normal update will retry" >&2
        update_continuation_cleanup
        return 0
    fi
    installed_sha=$(activation_sha_file "$INSTALL_DIR/nvr") || return 1
    if [[ "$installed_sha" != "$AC_TARGET_UPDATER_SHA256" ]]; then
        echo "WARN: updater continuation target changed; the next normal update will retry" >&2
        update_continuation_cleanup
        return 0
    fi
    if update_continuation_completed_read \
            && [[ "$ACC_UPDATER_SHA256" == "$AC_TARGET_UPDATER_SHA256" ]]; then
        update_continuation_cleanup
        return 0
    fi
    update_continuation_target_ready "$AC_TARGET_BACKEND_ID" || return 0
    # Backend health can become true before the old updater finishes its tail.
    # Treat a held lifecycle lock as another readiness wait, not as the one
    # allowed continuation attempt. Release the probe before cmd_update takes
    # the same lock with its normal bounded wait.
    take_lifecycle_lock_nonblocking || return 0
    release_lifecycle_lock
    local NVR_UPDATE_CONTINUATION_ACTIVE=1
    export NVR_UPDATE_CONTINUATION_ACTIVE
    cmd_update_monitored --activate-now || result=$?
    if [[ "$result" -eq 0 ]]; then
        update_continuation_completed_write "$AC_TARGET_UPDATER_SHA256" || result=$?
    fi
    # One automatic re-entry is enough. A real failure retains its normal
    # nightly retry path without leaving a minute-based failure loop armed.
    update_continuation_cleanup
    return "$result"
}

update_continuation_run() {
    exec 8>"$NVR_UPDATE_CONTINUATION_LOCK"
    flock -n 8 || return 0
    update_continuation_run_locked
}

# Existing cron files from the first ADR-110 release use these commands. Keep
# them as aliases until every transient two-hour request has naturally expired.
activation_continuation_request_read() { update_continuation_request_read; }
activation_continuation_request_write() {
    local updater_sha
    updater_sha=$(activation_sha_file "$INSTALL_DIR/nvr") || return 1
    update_continuation_request_write "$1" "$updater_sha"
}
activation_continuation_cron_write() { update_continuation_cron_write; }
activation_continuation_cleanup() { update_continuation_cleanup; }
activation_continuation_schedule() { update_continuation_schedule; }
activation_continuation_run_locked() { update_continuation_run_locked; }
activation_continuation_run() { update_continuation_run; }

activation_stage_after_compatibility() { # <target-backend-ref> <activate-now:0|1>
    local target_backend_ref="$1" activate_now="$2" live source_backend_id target_backend_id
    local source_backend_revision target_backend_revision source_database_id source_database_version
    local source_database_expected_id compose_sha activation_id existing=0 run_now=0

    live=$(mariadb_live_series 2>/dev/null || true)
    if [[ "$live" == 10.11 ]]; then
        if activation_journal_read && [[ "$AJ_STATE" == committed ]]; then
            activation_reconcile_committed
            return $?
        fi
        echo "ERROR: MariaDB 10.11 has no committed staged activation journal" >&2
        return 1
    fi
    [[ "$live" == 10.5 ]] || return 1
    source_backend_id=$(activation_service_image_id backend) || return 1
    source_database_id=$(activation_service_image_id db) || return 1
    target_backend_id=$(docker image inspect --format '{{.Id}}' "$target_backend_ref" 2>/dev/null) || return 1
    [[ "$source_backend_id" == "$target_backend_id" ]] || {
        echo "ERROR: compatibility checkpoint does not run the target backend image" >&2
        return 1
    }
    source_backend_revision=$(update_image_revision "$source_backend_id")
    target_backend_revision=$(update_image_revision "$target_backend_id")
    [[ "$target_backend_revision" == "$AM_TARGET_BACKEND_REVISION" ]] || {
        echo "ERROR: target backend revision does not match the activation manifest" >&2
        return 1
    }
    source_database_version=$(update_live_database_version) || return 1
    [[ "$source_database_version" =~ ^10\.5\. ]] || return 1
    source_database_expected_id=$(docker image inspect --format '{{.Id}}' \
        "$AM_SOURCE_DATABASE_DIGESTS" 2>/dev/null) || return 1
    [[ "$source_database_id" == "$source_database_expected_id" ]] || {
        echo "ERROR: source database image is outside the supported activation edge" >&2
        return 1
    }
    compose_sha="$AM_COMPOSE_BUNDLE_SHA256"
    activation_id=$(activation_compound_id "$source_backend_id" "$target_backend_id" \
        "$source_database_id" "$source_database_version" "$AM_TARGET_DATABASE_DIGEST" \
        "$compose_sha" "$AM_MIGRATION_GATE_VERSION" "$AM_MANIFEST_SHA256") || return 1

    if [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]]; then
        activation_journal_read || return 1
        existing=1
        if [[ "$AJ_ACTIVATION_ID" != "$activation_id" ]]; then
            activation_supersede_loaded_journal || return 1
            existing=0
        fi
    fi
    if [[ "$existing" -eq 0 ]]; then
        activation_journal_initialize "$source_backend_id" "$source_backend_revision" \
            "$target_backend_id" "$target_backend_revision" "$source_database_id" \
            "$source_database_version" "$compose_sha" || return 1
    fi
    activation_journal_read || return 1
    if [[ "$AJ_STATE" == blocked ]]; then
        echo "ERROR: staged activation is held: $AJ_BLOCKED_REASON" >&2
        return 1
    fi
    if activation_should_run_now "$activate_now" "$target_backend_id"; then
        run_now=1
    fi
    if [[ "$run_now" -eq 0 ]]; then
        echo "Compatibility checkpoint staged. MariaDB remains on 10.5 until the next update."
        return 0
    fi
    activation_execute
}

activation_resume_before_update() {
    [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]] || return 0
    activation_journal_read || return 1
    case "$AJ_STATE" in
        failed)
            activation_manifest_read || return 1
            [[ "$AM_MANIFEST_SHA256" == "$AJ_MANIFEST_SHA256" ]] || return 1
            activation_manifest_validate_bundle || return 1
            activation_execute
            return $?
            ;;
        activating|verifying)
            activation_manifest_read || return 1
            [[ "$AM_MANIFEST_SHA256" == "$AJ_MANIFEST_SHA256" ]] || return 1
            activation_manifest_validate_bundle || return 1
            activation_execute || return 1
            ;;
    esac
}

activation_last_update_write() { # <exit-status>
    local result="$1" temporary uid outcome
    activation_ensure_root || return 1
    uid=$(activation_expected_uid) || return 1
    [[ "$result" -eq 0 ]] && outcome=success || outcome=failed
    temporary=$(mktemp "$NVR_ACTIVATION_DIR/.last-update.new.XXXXXX") || return 1
    {
        printf '%s\n' 'format_version=1' "protocol_version=$NVR_ACTIVATION_PROTOCOL" \
            "completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            "exit_status=$result" "result=$outcome"
    } > "$temporary"
    chown "$uid" "$temporary" 2>/dev/null || true
    chmod 644 "$temporary" || return 1
    sync
    mv -f "$temporary" "$NVR_ACTIVATION_LAST_UPDATE" || return 1
    sync
}

cmd_update_monitored() {
    local result=0 auto_result=0
    cmd_update "$@" || result=$?
    if [[ $EUID -eq 0 ]]; then
        # The automatic migration owns the same lifecycle lock. Release the
        # update lock only after the core release has fully converged.
        release_lifecycle_lock
        if [[ $result -eq 0 ]]; then
            "$INSTALL_DIR/nvr-db-auto-migrate" || auto_result=$?
            if [[ $auto_result -ne 0 ]]; then
                echo "ERROR: update installed successfully, but automatic InnoDB work failed; the next nightly update will retry" >&2
                result=$auto_result
            fi
        fi
    fi
    activation_last_update_write "$result" || true
    return "$result"
}

activation_print_status() {
    local live db_image age now updated
    echo "Updater protocol: $NVR_ACTIVATION_PROTOCOL"
    live=$(mariadb_live_series 2>/dev/null || true)
    db_image=$(activation_service_image_id db 2>/dev/null || true)
    echo "Database series: ${live:-unavailable}"
    echo "Database image: ${db_image:-unavailable}"
    if activation_journal_read; then
        echo "Activation state: $AJ_STATE"
        echo "Activation ID: $AJ_ACTIVATION_ID"
        echo "Activation edge: $AJ_EDGE_ID"
        echo "Verified backup: $AJ_BACKUP_PATH"
        echo "Blocked reason: $AJ_BLOCKED_REASON"
        updated=$(date -u -d "$AJ_UPDATED_AT" +%s 2>/dev/null || true)
        now=$(date +%s)
        if [[ "$updated" =~ ^[0-9]+$ && "$now" -ge "$updated" ]]; then
            age=$((now - updated))
            echo "Activation age seconds: $age"
        else
            echo "Activation age seconds: unavailable"
        fi
    elif [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]]; then
        echo "Activation state: invalid"
    else
        echo "Activation state: none"
    fi
    if activation_backend_mode_read; then
        echo "Backend database mode: $ABM_MODE"
        echo "Backend mode image: $ABM_BACKEND_IMAGE_ID"
    else
        echo "Backend database mode: unavailable"
    fi
    if [[ -f "$NVR_ACTIVATION_LAST_UPDATE" && ! -L "$NVR_ACTIVATION_LAST_UPDATE" ]]; then
        echo "Last update:"
        sed 's/^/  /' "$NVR_ACTIVATION_LAST_UPDATE"
    else
        echo "Last update: unavailable"
    fi
}

cmd_activation() {
    local action="${1:-status}"
    case "$action" in
        status) activation_print_status ;;
        release)
            if [[ $EUID -ne 0 ]]; then
                if should_reexec_root; then nvr_try_elevate; fi
                echo "ERROR: nvr activation release requires root" >&2
                return 1
            fi
            activation_journal_read || return 1
            [[ "$AJ_STATE" == blocked ]] || {
                echo "ERROR: activation is not held" >&2
                return 1
            }
            AJ_STATE=staged
            AJ_BACKUP_PATH=NONE
            AJ_BACKUP_BYTES=0
            AJ_BACKUP_SHA256=NONE
            AJ_FAILURE_COUNT=0
            AJ_FAILURE_FINGERPRINT=NONE
            AJ_BLOCKED_REASON=NONE
            activation_journal_write
            echo "Activation hold released. The next update will retry from MariaDB 10.5."
            ;;
        schedule-continuation)
            [[ $EUID -eq 0 ]] || {
                echo "ERROR: update continuation scheduling requires root" >&2
                return 1
            }
            activation_continuation_schedule
            ;;
        continue)
            [[ $EUID -eq 0 ]] || {
                echo "ERROR: activation continuation requires root" >&2
                return 1
            }
            activation_continuation_run
            ;;
        *) echo "Usage: nvr activation {status|release}" >&2; return 2 ;;
    esac
}

cmd_update_continuation() {
    local action="${1:-}"
    case "$action" in
        schedule)
            [[ $EUID -eq 0 ]] || {
                echo "ERROR: update continuation scheduling requires root" >&2
                return 1
            }
            update_continuation_schedule
            ;;
        continue)
            [[ $EUID -eq 0 ]] || {
                echo "ERROR: updater continuation requires root" >&2
                return 1
            }
            update_continuation_run
            ;;
        *) echo "Usage: nvr update-continuation {schedule|continue}" >&2; return 2 ;;
    esac
}

cmd_channel() {
    if [[ -z "$1" ]]; then
        echo "Current channel: $(grep '^CHANNEL=' .env 2>/dev/null | cut -d= -f2)"
        echo ""
        echo "Usage: nvr channel <version|dev|dev-<suffix>>  (e.g., nvr channel 6.2)"
        return
    fi

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

    local target_mariadb_series
    target_mariadb_series=$(mariadb_channel_target_series "$1") || return 1
    mariadb_assert_target_compatible "$target_mariadb_series" || return 1

    sed -i "s/^CHANNEL=.*/CHANNEL=$1/" .env
    echo "Channel switched to: $1"
    echo "Run 'nvr update' to pull images from the new channel."
}

cmd_db() {
	local controller_action=""
	case "${1:-}" in
		status|inventory|row-count-contract)
			exec "$INSTALL_DIR/nvr-db-engine-controller" "$1"
			;;
		core-runtime|verify-engines|adopt-engines)
			local db_read_action="$1"
			if [[ $EUID -ne 0 ]]; then
				if should_reexec_root; then
					nvr_try_elevate
				fi
				echo "ERROR: nvr db $db_read_action requires root" >&2
				return 1
			fi
			exec "$INSTALL_DIR/nvr-db-engine-controller" "$db_read_action"
			;;
		test-restore)
			shift
			if [[ $EUID -ne 0 ]]; then
				if should_reexec_root; then
					nvr_try_elevate
				fi
				echo "ERROR: nvr db test-restore requires root" >&2
				return 1
			fi
			exec "$INSTALL_DIR/nvr-db-test-restore" "$@"
			;;
		convert-engine)
			case "${2:-}" in
				--status) exec "$INSTALL_DIR/nvr-db-engine-controller" status ;;
				--resume) controller_action=resume; shift 2 ;;
				--rollback) controller_action=rollback; shift 2 ;;
				*) controller_action=convert-engine; shift ;;
			esac
			if [[ $EUID -ne 0 ]]; then
				if should_reexec_root; then
					nvr_try_elevate
				fi
				echo "ERROR: nvr db convert-engine requires root" >&2
				return 1
			fi
			exec "$INSTALL_DIR/nvr-db-engine-controller" "$controller_action" "$@"
			;;
		resume|rollback)
			if [[ $EUID -ne 0 ]]; then
				if should_reexec_root; then
					nvr_try_elevate
				fi
				echo "ERROR: nvr db $1 requires root" >&2
				return 1
			fi
			exec "$INSTALL_DIR/nvr-db-engine-controller" "$@"
			;;
	esac
	local db_pass
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    $COMPOSE exec db mariadb -u root -p"${db_pass:-lynn1094}" "$@" dtech
}

# Docker replacement for rda-scripts/swap_cameras. This command runs on the
# host because VideoStore.sMountPoint paths name host recording directories.
# MariaDB still runs inside the db container, so every query goes through the
# same compose/credential path as `nvr db`.
CAMERA_SWAP_SENTINEL=100

camera_usage() {
    cat <<'EOF'
Usage:
  nvr camera swap <camera-1> <camera-2>
  nvr camera slide <from-camera> <to-camera>
  nvr camera restart

`swap` restarts backend, engine, and connector after one swap.
`slide` performs adjacent swaps without restarting services.
`restart` restarts the three affected services without changing cameras.
EOF
}

camera_numcams() {
    local value
    value=$(grep '^NUMCAMS=' "$INSTALL_DIR/data/config/dvs.conf" 2>/dev/null \
        | head -1 | cut -d= -f2- | tr -d '[:space:]') || true
    case "$value" in
        ''|*[!0-9]*|0*)
            echo "ERROR: NUMCAMS is missing or invalid in $INSTALL_DIR/data/config/dvs.conf" >&2
            return 1
            ;;
    esac
    if [[ "$value" -lt 1 || "$value" -ge "$CAMERA_SWAP_SENTINEL" ]]; then
        echo "ERROR: NUMCAMS must be between 1 and $((CAMERA_SWAP_SENTINEL - 1))" >&2
        return 1
    fi
    printf '%s\n' "$value"
}

camera_validate_pair() {
    local camera1="$1" camera2="$2" numcams
    case "$camera1" in
        ''|*[!0-9]*|0*) echo "ERROR: invalid camera number: $camera1" >&2; return 2 ;;
    esac
    case "$camera2" in
        ''|*[!0-9]*|0*) echo "ERROR: invalid camera number: $camera2" >&2; return 2 ;;
    esac
    numcams=$(camera_numcams) || return 1
    if [[ "$camera1" -lt 1 || "$camera1" -gt "$numcams" ]]; then
        echo "ERROR: camera $camera1 is out of range 1..$numcams" >&2
        return 2
    fi
    if [[ "$camera2" -lt 1 || "$camera2" -gt "$numcams" ]]; then
        echo "ERROR: camera $camera2 is out of range 1..$numcams" >&2
        return 2
    fi
    if [[ "$camera1" -eq "$camera2" ]]; then
        echo "ERROR: camera numbers must be different" >&2
        return 2
    fi
}

camera_db_sql() {
    local sql="$1" db_pass
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" \
        -N -B --raw dtech -e "$sql"
}

camera_db_preflight() {
    local camera1="$1" camera2="$2" camera_count sentinel_count
    camera_count=$(camera_db_sql \
        "SELECT COUNT(*) FROM Camera WHERE bID IN ($camera1,$camera2);") || {
        echo "ERROR: could not read camera rows from dtech" >&2
        return 1
    }
    camera_count=$(printf '%s' "$camera_count" | tr -d '[:space:]')
    case "$camera_count" in
        1|2) ;;
        *)
        echo "ERROR: dtech must contain at least one of Camera rows $camera1 and $camera2" >&2
        return 1
        ;;
    esac

    # The RPM script used camera 100 as its temporary value. Refuse to reuse it
    # if a prior interrupted run or unexpected data already occupies it.
    sentinel_count=$(camera_db_sql "
SELECT
    (SELECT COUNT(*) FROM Camera WHERE bID=$CAMERA_SWAP_SENTINEL) +
    (SELECT COUNT(*) FROM VmdWindow WHERE bCamera=$CAMERA_SWAP_SENTINEL) +
    (SELECT COUNT(*) FROM ObjectDetectConfig WHERE bCamera=$CAMERA_SWAP_SENTINEL) +
    (SELECT COUNT(*) FROM LprConfig WHERE bCamera=$CAMERA_SWAP_SENTINEL) +
    (SELECT COUNT(*) FROM PosType13 WHERE sOverlay='$CAMERA_SWAP_SENTINEL') +
    (SELECT COUNT(*) FROM Event WHERE bCamera=$CAMERA_SWAP_SENTINEL);") || {
        echo "ERROR: could not validate camera swap tables in dtech" >&2
        return 1
    }
    sentinel_count=$(printf '%s' "$sentinel_count" | tr -d '[:space:]')
    case "$sentinel_count" in
        ''|*[!0-9]*)
            echo "ERROR: invalid camera swap preflight response from dtech" >&2
            return 1
            ;;
    esac
    if [[ "$sentinel_count" -ne 0 ]]; then
        echo "ERROR: camera 100 temporary data already exists; repair the interrupted legacy swap before continuing" >&2
        return 1
    fi
}

camera_load_mountpoints() {
    local output row mountpoint device uuid label fstype existing duplicate
    local separator=$'\x1f'
    output=$(camera_db_sql "
SELECT CONCAT(
    sMountPoint, CHAR(31), sDevice, CHAR(31), sUUID, CHAR(31),
    sLabel, CHAR(31), sFSType
)
FROM VideoStore
WHERE sMountPoint <> ''
ORDER BY bID;") || {
        echo "ERROR: could not read VideoStore mount points from dtech" >&2
        return 1
    }

    CAMERA_MOUNTPOINTS=()
    while IFS= read -r row; do
        [[ -n "$row" ]] || continue
        IFS="$separator" read -r mountpoint device uuid label fstype <<< "$row"
        case "$mountpoint" in
            /*) ;;
            *)
                echo "ERROR: refusing non-absolute VideoStore mount point: $mountpoint" >&2
                return 1
                ;;
        esac
        # Match rda-backend lib.mount_check.requiresMount and docker-start
        # Phase 2. A block-backed row must be a live mount. Otherwise this
        # command could rename a same-named directory on the root filesystem
        # while the real recording disk is absent.
        if [[ "$fstype" != dir ]] && {
            [[ "$device" == /dev/* ]] ||
            { [[ -z "$device" ]] && [[ -n "$uuid" || -n "$label" ]]; }
        }; then
            if ! mountpoint -q "$mountpoint" 2>/dev/null; then
                echo "ERROR: VideoStore is not mounted: $mountpoint" >&2
                return 1
            fi
        fi
        duplicate=0
        for existing in "${CAMERA_MOUNTPOINTS[@]}"; do
            if [[ "$existing" == "$mountpoint" ]]; then
                duplicate=1
                break
            fi
        done
        [[ "$duplicate" -eq 1 ]] || CAMERA_MOUNTPOINTS+=("$mountpoint")
    done <<< "$output"
}

camera_path_exists() {
    [[ -e "$1" || -L "$1" ]]
}

camera_preflight_directories() {
    local camera1="$1" camera2="$2" mountpoint base temporary
    for mountpoint in "${CAMERA_MOUNTPOINTS[@]}"; do
        base="$mountpoint/dividia"
        temporary="$base/cam$CAMERA_SWAP_SENTINEL"
        if camera_path_exists "$temporary"; then
            echo "ERROR: temporary recording path already exists: $temporary" >&2
            return 1
        fi
        if camera_path_exists "$base/cam$camera1" || camera_path_exists "$base/cam$camera2"; then
            if [[ ! -d "$base" || ! -w "$base" ]]; then
                echo "ERROR: VideoStore recording directory is not writable: $base" >&2
                return 1
            fi
        fi
    done
}

# Swap one VideoStore's directory names. Missing camera directories are valid:
# a camera that has never recorded has no directory yet. On a failed move, put
# this store back before returning an error.
camera_swap_directory() {
    local mountpoint="$1" camera1="$2" camera2="$3"
    local base="$mountpoint/dividia"
    local path1="$base/cam$camera1" path2="$base/cam$camera2"
    local temporary="$base/cam$CAMERA_SWAP_SENTINEL"
    local had1=0 had2=0

    camera_path_exists "$path1" && had1=1
    camera_path_exists "$path2" && had2=1
    [[ "$had1" -eq 1 || "$had2" -eq 1 ]] || return 0

    if [[ "$had1" -eq 1 ]]; then
        mv "$path1" "$temporary" || return 1
    fi
    if [[ "$had2" -eq 1 ]]; then
        if ! mv "$path2" "$path1"; then
            [[ "$had1" -eq 0 ]] || mv "$temporary" "$path1" 2>/dev/null || true
            return 1
        fi
    fi
    if [[ "$had1" -eq 1 ]]; then
        if ! mv "$temporary" "$path2"; then
            [[ "$had2" -eq 0 ]] || mv "$path1" "$path2" 2>/dev/null || true
            mv "$temporary" "$path1" 2>/dev/null || true
            return 1
        fi
    fi
}

camera_rollback_directories() {
    local camera1="$1" camera2="$2" index mountpoint status=0
    index=${#CAMERA_SWAPPED_MOUNTPOINTS[@]}
    while [[ "$index" -gt 0 ]]; do
        index=$((index - 1))
        mountpoint="${CAMERA_SWAPPED_MOUNTPOINTS[$index]}"
        if ! camera_swap_directory "$mountpoint" "$camera1" "$camera2"; then
            echo "ERROR: could not roll back camera directories under $mountpoint" >&2
            status=1
        fi
    done
    return "$status"
}

camera_swap_database() {
    local camera1="$1" camera2="$2"
    camera_db_sql "
LOCK TABLES Camera WRITE, VmdWindow WRITE, ObjectDetectConfig WRITE, LprConfig WRITE, PosType13 WRITE, Event WRITE;
UPDATE Camera SET bID=$CAMERA_SWAP_SENTINEL WHERE bID=$camera1;
UPDATE Camera SET bID=$camera1 WHERE bID=$camera2;
UPDATE Camera SET bID=$camera2 WHERE bID=$CAMERA_SWAP_SENTINEL;
UPDATE VmdWindow SET bCamera=$CAMERA_SWAP_SENTINEL WHERE bCamera=$camera1;
UPDATE VmdWindow SET bCamera=$camera1 WHERE bCamera=$camera2;
UPDATE VmdWindow SET bCamera=$camera2 WHERE bCamera=$CAMERA_SWAP_SENTINEL;
UPDATE ObjectDetectConfig SET bCamera=$CAMERA_SWAP_SENTINEL WHERE bCamera=$camera1;
UPDATE ObjectDetectConfig SET bCamera=$camera1 WHERE bCamera=$camera2;
UPDATE ObjectDetectConfig SET bCamera=$camera2 WHERE bCamera=$CAMERA_SWAP_SENTINEL;
UPDATE LprConfig SET bCamera=$CAMERA_SWAP_SENTINEL WHERE bCamera=$camera1;
UPDATE LprConfig SET bCamera=$camera1 WHERE bCamera=$camera2;
UPDATE LprConfig SET bCamera=$camera2 WHERE bCamera=$CAMERA_SWAP_SENTINEL;
UPDATE PosType13 SET sOverlay='$CAMERA_SWAP_SENTINEL' WHERE sOverlay='$camera1';
UPDATE PosType13 SET sOverlay='$camera1' WHERE sOverlay='$camera2';
UPDATE PosType13 SET sOverlay='$camera2' WHERE sOverlay='$CAMERA_SWAP_SENTINEL';
UPDATE Event SET bCamera=$CAMERA_SWAP_SENTINEL WHERE bCamera=$camera1;
UPDATE Event SET bCamera=$camera1 WHERE bCamera=$camera2;
UPDATE Event SET bCamera=$camera2 WHERE bCamera=$CAMERA_SWAP_SENTINEL;
UNLOCK TABLES;"
}

camera_swap_once() {
    local camera1="$1" camera2="$2" mountpoint
    camera_db_preflight "$camera1" "$camera2" || return 1
    camera_load_mountpoints || return 1
    camera_preflight_directories "$camera1" "$camera2" || return 1

    CAMERA_SWAPPED_MOUNTPOINTS=()
    for mountpoint in "${CAMERA_MOUNTPOINTS[@]}"; do
        if ! camera_swap_directory "$mountpoint" "$camera1" "$camera2"; then
            echo "ERROR: could not swap recording directories under $mountpoint" >&2
            camera_rollback_directories "$camera1" "$camera2" || true
            return 1
        fi
        CAMERA_SWAPPED_MOUNTPOINTS+=("$mountpoint")
    done

    if ! camera_swap_database "$camera1" "$camera2"; then
        echo "ERROR: database camera swap failed; restoring recording directory names" >&2
        camera_rollback_directories "$camera1" "$camera2" || true
        return 1
    fi

    echo "Swapped Camera $camera1 <-> $camera2"
}

camera_slide() {
    local from="$1" to="$2" current next index status=0
    local -a completed_from=() completed_to=()

    if [[ "$from" -gt "$to" ]]; then
        echo "Sliding Camera $from up to $to"
        current="$from"
        while [[ "$current" -gt "$to" ]]; do
            next=$((current - 1))
            if ! camera_swap_once "$current" "$next"; then status=1; break; fi
            completed_from+=("$current")
            completed_to+=("$next")
            current="$next"
        done
    else
        echo "Sliding Camera $from down to $to"
        current="$from"
        while [[ "$current" -lt "$to" ]]; do
            next=$((current + 1))
            if ! camera_swap_once "$current" "$next"; then status=1; break; fi
            completed_from+=("$current")
            completed_to+=("$next")
            current="$next"
        done
    fi

    [[ "$status" -eq 0 ]] && return 0

    echo "ERROR: camera slide failed; rolling back completed adjacent swaps" >&2
    index=${#completed_from[@]}
    while [[ "$index" -gt 0 ]]; do
        index=$((index - 1))
        camera_swap_once "${completed_from[$index]}" "${completed_to[$index]}" || {
            echo "ERROR: camera slide rollback is incomplete" >&2
            return 1
        }
    done
    return 1
}

camera_restart_locked() {
    echo "Restarting backend, engine, and connector"
    $COMPOSE up -d --force-recreate backend engine connector
}

cmd_camera() {
    local action="${1:-}" camera1="${2:-}" camera2="${3:-}" status=0
    case "$action" in
        swap|slide)
            if [[ "$#" -ne 3 ]]; then camera_usage >&2; return 2; fi
            camera_validate_pair "$camera1" "$camera2" || return $?
            ;;
        restart)
            if [[ "$#" -ne 1 ]]; then camera_usage >&2; return 2; fi
            ;;
        *)
            camera_usage >&2
            return 2
            ;;
    esac

    take_lifecycle_lock || {
        echo "ERROR: cannot take lifecycle lock for camera operation" >&2
        return 1
    }

    if [[ "$action" == restart ]]; then
        camera_restart_locked || status=$?
        release_lifecycle_lock
        return "$status"
    fi

    take_videostore_lock || {
        echo "ERROR: cannot take VideoStore lock for camera operation" >&2
        release_lifecycle_lock
        return 1
    }

    if [[ "$action" == swap ]]; then
        camera_swap_once "$camera1" "$camera2" || status=$?
        if [[ "$status" -eq 0 ]]; then
            camera_restart_locked || status=$?
        fi
    else
        camera_slide "$camera1" "$camera2" || status=$?
    fi

    release_videostore_lock
    release_lifecycle_lock
    return "$status"
}

# Read the NVR's human name from dtech.Misc('system','name') — the same value
# the legacy /usr/local/bin/server_name script printed on RPM installs.
# `-N -B` = no column header, batch/tab mode, so callers get a bare value with
# no `grep -v sValue` post-processing (the legacy script only needed that
# because it didn't pass -N). `-T` (no pseudo-TTY) so the exec works when
# stdout is captured / run non-interactively (cron, ssh exec). Returns
# non-zero (and empty stdout, stderr swallowed) if the db container is down.
nvr_name() {
    local db_pass
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" -N -B dtech \
        -e "SELECT sValue FROM Misc WHERE sModule='system' AND sName='name'" 2>/dev/null
}

cmd_name() {
    local name
    if ! name=$(nvr_name); then
        echo "ERROR: could not read NVR name (is the db container running?)" >&2
        exit 1
    fi
    if [[ -z "$name" ]]; then
        echo "(name unset)"
    else
        echo "$name"
    fi
}

cmd_start() {
	take_lifecycle_lock || {
		echo "ERROR: cannot take lifecycle lock for start" >&2
		return 1
	}
	local status=0
	cmd_start_locked "$@" || status=$?
	release_lifecycle_lock
	return "$status"
}

cmd_start_locked() {
    # Reassert immediately before container startup as well as during update.
    # CentOS 6 boot does not reliably consume /etc/sysctl.d, and a reboot must
    # not reopen a window where an outbound socket can claim a service port.
    ensure_host_config || true
    ensure_videostore_mounts
    # Intentional bring-up: clear the heal stop marker so heal resumes after an
    # `nvr stop` (ADR-100 4.5). cmd_start is the ExecStart for both systemd
    # nvr.service and the CentOS 6 SysV /etc/init.d/nvr, so this is the one boot
    # path to cover; it holds the lifecycle lock (via cmd_start).
    heal_clear_stop_marker
    # One-way autoheal purge (autoheal removed entirely): drop any dangling
    # autoheal token + leftover sidecar BEFORE any compose bring-up below.
    autoheal_purge || true
    # Preserve a template held only in a dead connector before repair removes
    # that container and its writable layer.
    prepare_customer_assets_for_compose || return 1
    activation_overlay_reconcile || return 1
    repair_untracked_compose_containers
    # Converge the optional aiengine overlay to host intent before boot-time
    # startup. `|| true`: never let an add-on reconcile block core startup.
    aiengine_reconcile || true
    boot_storage_runtime_ready || return 1
    boot_storage_enforce_running_container_masks pre || return 1
    host_display_repair_cached_payload_pin \
        || echo "WARN: cached host dview payload pin repair failed" >&2
    # Core by name, add-on separately: an add-on image missing at boot (e.g.
    # registry unreachable on a cold start) must not fail core startup.
    local _core_svcs
    _core_svcs=$(core_services)
    if [[ -n "$_core_svcs" ]]; then
        # Converge + verify instead of a single fire-and-forget `up`. On a cold
        # boot MariaDB's InnoDB recovery can outlast compose's depends_on:
        # service_healthy wait, so a single `up` aborts with "dependency failed to
        # start: db is unhealthy" and strands engine/connector/viewer/playback/ptz
        # in `Created` until a human re-runs `nvr start` (cs989 RC15 2026-08-16).
        # converge_core_stack re-applies the desired state until every core service
        # is actually running (the SAME closed loop cmd_update uses, ADR-063), so
        # the common transient warmup self-heals within the deadline. nvr.service
        # ExecStart is `nvr start`, so this heals the boot path and manual starts
        # alike. If it CANNOT converge within NVR_UPDATE_CONVERGE_TIMEOUT (360s),
        # RETURN THE FAILURE: that is a genuinely broken boot (db never came up),
        # and surfacing it as a failed nvr.service is correct. Swallowing it would
        # mark the oneshot/RemainAfterExit unit "active" while the stack sits
        # stranded -- autoheal never restarts `Created` dependents and the unit has
        # no Restart policy, so nothing else would ever retry.
        converge_core_stack "$_core_svcs" || return 1
    else
        $COMPOSE up -d
    fi
    lpr_templates_refresh_consumers_if_needed || return 1
    if [[ "${LPR_TEMPLATES_REFRESHED:-0}" -eq 1 && -n "$_core_svcs" ]]; then
        converge_core_stack "$_core_svcs" || return 1
    fi
    aiengine_start_serialized \
        || echo "WARN: aiengine did not start; core services are up" >&2
    boot_storage_enforce_running_container_masks || return 1
}

# Mount every managed VideoStore. DO NOT DELETE THIS AS REDUNDANT WITH fstab.
#
# Since 2026-07-27 this is the PRIMARY VideoStore mounter, not a retry. The managed
# entry is `noauto`, so `mount -a` deliberately skips it (rc.sysinit on CentOS 6,
# local-fs.target on systemd) and nothing else on the box mounts a store. The reason
# is the shadow refusal below: `mount -a` knows nothing about it and would mount
# straight over root-backed video, burying footage that still fills root while
# thread_rollover deletes the Event rows pointing at it. Reproduced on a CentOS 6 box
# 2026-07-26 (80 MB buried); the refusal now runs at boot instead, because this
# function does the mounting. See ADR-045.
#
# ADR-045 moved VideoStore mounting host-side: docker/backend/docker-start Phase 2
# writes an /etc/fstab entry for every store whose mount it verified by identity.
# Those entries all carry `nofail`, which is mandatory (a missing disk must never
# strand a box at an emergency prompt) and which had a consequence people miss back
# when the entries were `auto`: systemd.mount(5) says nofail also removes the
# ordering, so the mount was only WANTED by local-fs.target and NOT ordered before
# it. Boot proceeded without waiting, a late-enumerating SATA/USB/HBA disk missed its
# window, and nothing tried again -- /videostore/vsN stayed a plain directory on root
# and mpengine recorded onto the root filesystem, which is the 2026-07-24 BCC
# failure. Under `noauto` that race is gone by construction (boot never attempts the
# mount at all), and this function is what closes it either way.
#
# `RequiresMountsFor=/videostore` in nvr.service does NOT cover this. It adds
# dependencies on the mounts required to REACH the path, and /videostore is a plain
# directory on root, so it resolves to `-.mount`. The per-store mounts live BELOW
# it. Covering them that way would mean enumerating vs1..vsN into the unit at
# install time, and stores get added later. So the mounting lives here instead: one
# place, no per-store knowledge, correct for a box that grows a third disk in 2028,
# and it also runs for an operator typing `nvr start` by hand. The CentOS 6 SysV
# script (61 of the 101 migrated BCC boxes have no systemd) carries the same logic
# inline because it calls compose directly rather than going through this CLI.
#
# It runs BEFORE compose on both init systems (nvr.service ExecStart here,
# /etc/init.d/nvr start on CentOS 6), which is what keeps ADR-045's mount-before-
# containers ordering under `noauto`.
#
# Only touches mount points that ALREADY have an fstab entry: it executes a decision
# Phase 2 already made and verified by identity, never a new mount of its own.
NVR_FSTAB="${NVR_FSTAB:-/etc/fstab}"
NVR_VIDEOSTORE_ROOT="${NVR_VIDEOSTORE_ROOT:-/videostore}"
# The marker docker-start Phase 2 writes above each entry it owns, as
# "<sentinel> <mountpoint>". Authorship is decided by this and nothing else.
NVR_FSTAB_SENTINEL="${NVR_FSTAB_SENTINEL:-# dividia-nvr videostore, managed}"
# Bytes of root-backed content above which mounting would hide real video. Must
# equal VIDEOSTORE_SHADOW_LIMIT_BYTES in docker/backend/docker-start and
# SHADOW_LIMIT_BYTES in rda-backend/src/lib/mount_check.py.
NVR_VIDEOSTORE_SHADOW_LIMIT_BYTES="${NVR_VIDEOSTORE_SHADOW_LIMIT_BYTES:-67108864}"
# Test seam for the host-only guard. Production leaves this at /.dockerenv.
NVR_DOCKERENV="${NVR_DOCKERENV:-/.dockerenv}"
NVR_VIDEOSTORE_INTENT_DIR="${NVR_VIDEOSTORE_INTENT_DIR:-$INSTALL_DIR/data/config/videostore-intent}"
NVR_REMOVABLE_VIDEOSTORE_CONFIG="${NVR_REMOVABLE_VIDEOSTORE_CONFIG:-$INSTALL_DIR/data/config/removable-videostores.json}"
NVR_VIDEOSTORE_LOCK="${NVR_VIDEOSTORE_LOCK:-/run/dividia-nvr/videostore.lock}"
# NVR_LIFECYCLE_LOCK is defined at the top of this file (ADR-100), so every
# consumer references the one named constant.
NVR_VIDEOSTORE_CONFIG_HELPER="${NVR_VIDEOSTORE_CONFIG_HELPER:-$INSTALL_DIR/removable-videostore-config.py}"
NVR_VIDEOSTORE_HOST_TOKEN_FILE="${NVR_VIDEOSTORE_HOST_TOKEN_FILE:-${NVR_VIDEOSTORE_CONTROL_TOKEN_FILE:-/etc/dividia-nvr/videostore-control-token}}"

prepare_videostore_lock() {
    local directory
    directory=$(dirname "$NVR_VIDEOSTORE_LOCK")
    [ ! -L "$directory" ] || return 1
    mkdir -p "$directory" 2>/dev/null || return 1
    if [[ $EUID -eq 0 ]]; then
        chown root:root "$directory" 2>/dev/null || return 1
        chmod 0755 "$directory" 2>/dev/null || return 1
    fi
    [ ! -L "$NVR_VIDEOSTORE_LOCK" ] || return 1
}

take_videostore_lock() {
	if [[ "${NVR_VIDEOSTORE_LOCK_HELD:-0}" == 1 ]]; then
		return 0
	fi
	prepare_videostore_lock || return 1
	exec 8>"$NVR_VIDEOSTORE_LOCK" || return 1
	flock -x 8 || { exec 8>&-; return 1; }
	NVR_VIDEOSTORE_LOCK_HELD=1
}

release_videostore_lock() {
	[[ "${NVR_VIDEOSTORE_LOCK_HELD:-0}" == 1 ]] || return 0
	flock -u 8 2>/dev/null || true
	exec 8>&-
	NVR_VIDEOSTORE_LOCK_HELD=0
}

take_lifecycle_lock() {
	local directory
	case "$NVR_LIFECYCLE_LOCK_TIMEOUT" in
		''|*[!0-9]*) return 1 ;;
	esac
	directory=$(dirname "$NVR_LIFECYCLE_LOCK")
	mkdir -p "$directory" 2>/dev/null || return 1
	[ ! -L "$NVR_LIFECYCLE_LOCK" ] || return 1
	exec 7>"$NVR_LIFECYCLE_LOCK" || return 1
	flock -w "$NVR_LIFECYCLE_LOCK_TIMEOUT" -x 7 || { exec 7>&-; return 1; }
}

release_lifecycle_lock() {
	flock -u 7 2>/dev/null || true
	exec 7>&-
}

# Non-blocking take of the SAME lifecycle lock (ADR-100). `nvr heal` uses this:
# if any lifecycle op (update/start/stop/restart) or an add-on operation holds
# the lock, that op converges or heals the stack anyway, so heal skips the tick
# rather than queuing behind it. Returns 0 on acquire, 1 if the lock is held.
# Uses the same FD 7 as take_lifecycle_lock; heal never takes the blocking one.
take_lifecycle_lock_nonblocking() {
	local directory
	directory=$(dirname "$NVR_LIFECYCLE_LOCK")
	mkdir -p "$directory" 2>/dev/null || return 1
	[ ! -L "$NVR_LIFECYCLE_LOCK" ] || return 1
	exec 7>"$NVR_LIFECYCLE_LOCK" || return 1
	flock -xn 7 || { exec 7>&-; return 1; }
}

videostore_config_query() {
	local python_cmd
	python_cmd=$(cloudapi_profile_python) || return 2
	[ -r "$NVR_VIDEOSTORE_CONFIG_HELPER" ] || return 2
	"$python_cmd" "$NVR_VIDEOSTORE_CONFIG_HELPER" "$@"
}

videostore_removable_uuid_for_path() { # <mountpoint>
	videostore_config_query lookup "$NVR_REMOVABLE_VIDEOSTORE_CONFIG" "$1"
}

videostore_live_uuid_mount() { # <mountpoint> <expected-uuid>
	local mp="$1" expected="$2" source actual
	mountpoint -q "$mp" 2>/dev/null || return 1
	source=$( findmnt -no SOURCE "$mp" 2>/dev/null | head -1 | sed 's/\[.*\]//' )
	actual=$( blkid -o value -s UUID "$source" 2>/dev/null || true )
	[ -n "$expected" ] && [ -n "$actual" ] && \
		[ "$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" = \
		  "$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" ]
}

videostore_stamp_identity() { # <mountpoint> <expected-uuid>
	local mp="$1" expected="$2" marker temporary
	marker="$mp/.dividia-videostore-uuid"
	videostore_live_uuid_mount "$mp" "$expected" || {
		rm -f "$marker"
		return 1
	}
	[ ! -L "$marker" ] || return 1
	temporary=$(mktemp "$mp/.identity.XXXXXX") || return 1
	if ! printf '%s\n' "$expected" > "$temporary" || \
	   ! chmod 0600 "$temporary" || ! mv -f "$temporary" "$marker"; then
		rm -f "$temporary"
		return 1
	fi
	sync -f "$mp" 2>/dev/null || sync
}

videostore_intent_suppresses() { # <uuid> <mountpoint>
	local rc=0
	videostore_config_query intent-blocks "$NVR_VIDEOSTORE_INTENT_DIR" \
		"$1" "$2" || rc=$?
	[ "$rc" = 1 ] && return 1
	return 0
}

ensure_videostore_mounts() {
    local fstab="$NVR_FSTAB"
    local root="$NVR_VIDEOSTORE_ROOT"
	local mp shadow uuid config_uuid config_rc fstab_uuid acquired_lock=0

    # HOST only. This script also ships inside the backend image (it is synced to
    # /opt/dividia from there), and the backend runs `privileged`, so a `nvr start`
    # typed inside the container really could mount. It must not: the /videostore
    # bind carries `propagation: slave`, so a container-side mount does NOT reach
    # the host -- it would create a mount only the backend can see, while the host
    # and every other writer keep looking at the empty root-backed directory. That
    # is a worse version of the bug this exists to prevent.
    if [ -f "$NVR_DOCKERENV" ]; then
        return 0
    fi

    [ -d "$root" ] || return 0
    [ -r "$fstab" ] || return 0

	if [[ "${NVR_VIDEOSTORE_LOCK_HELD:-0}" != 1 ]] && ! prepare_videostore_lock; then
		echo "WARNING: VideoStore mount lock path is not protected; skipping automatic mounts" >&2
		return 0
	fi
	if [[ "${NVR_VIDEOSTORE_LOCK_HELD:-0}" != 1 ]]; then
		exec 9>"$NVR_VIDEOSTORE_LOCK" || {
			echo "WARNING: cannot open VideoStore mount lock; skipping automatic mounts" >&2
			return 0
		}
		if ! flock -x 9; then
			echo "WARNING: cannot take VideoStore mount lock; skipping automatic mounts" >&2
			exec 9>&-
			return 0
		fi
		acquired_lock=1
	fi

    for mp in "$root"/vs*; do
        [ -d "$mp" ] || continue

        # OUR entry only, decided by the sentinel -- not by "some line names this
        # path". An operator's own line for the same mount point is theirs:
        # videostore_persist_mount explicitly declines to manage it, and mounting it
        # here would act on a decision Phase 2 refused to make.
        grep -qF "$NVR_FSTAB_SENTINEL $mp" "$fstab" 2>/dev/null || continue

		fstab_uuid=$( awk -v mp="$mp" '$1 ~ /^UUID=/ && $2 == mp { print substr($1, 6); exit }' "$fstab" 2>/dev/null )
		uuid="$fstab_uuid"
		config_uuid=""
		config_rc=0
		config_uuid=$(videostore_removable_uuid_for_path "$mp") || config_rc=$?
		if [ "$config_rc" -gt 1 ]; then
			echo "WARNING: invalid removable VideoStore config; NOT mounting $mp"
			continue
		fi
		if [ "$config_rc" = 0 ]; then
			if [ "$(printf '%s' "$fstab_uuid" | tr '[:upper:]' '[:lower:]')" != \
			     "$(printf '%s' "$config_uuid" | tr '[:upper:]' '[:lower:]')" ]; then
				echo "WARNING: NOT mounting $mp: managed fstab UUID differs from removable config"
				continue
			fi
			uuid="$config_uuid"
		fi
		if videostore_intent_suppresses "$uuid" "$mp"; then
            echo "Skipping VideoStore $mp: removable intent suppresses automatic mount"
            continue
        fi

	if mountpoint -q "$mp" 2>/dev/null; then
			if [ "$config_rc" = 0 ] && ! videostore_stamp_identity "$mp" "$config_uuid"; then
				rm -f "$mp/.dividia-videostore-uuid"
				echo "WARNING: mounted $mp does not match configured removable UUID"
			fi
			continue
		fi

        # Refuse to mount over root-backed video, the same refusal Phase 2 and the
        # 5-minute self-heal both make. Mounting would make those bytes unreachable
        # while they still fill root -- and rollover would then delete the Event
        # rows pointing at them, because their globs resolve under the new mount and
        # find nothing. cs170 / cs662 / cs1181 all had to be relocated by hand for
        # exactly this. -x so the count never descends into an already-mounted disk.
        shadow=$( du -sx --block-size=1 "$mp" 2>/dev/null | awk '{print $1+0; exit}' )
        if [ "${shadow:-0}" -gt "$NVR_VIDEOSTORE_SHADOW_LIMIT_BYTES" ]; then
            echo "WARNING: NOT mounting $mp: it holds ${shadow} bytes on the ROOT filesystem."
            echo "WARNING: Mounting would hide that video while it still fills root. Relocate it"
            echo "WARNING: onto the physical store first, then start the NVR again."
            continue
        fi

        # NOT "fstab entry did not take at boot". Since the managed entry became
        # `noauto` (2026-07-27) the OS is SUPPOSED to leave it alone, so this is the
        # normal path on every boot rather than a fault being recovered. Measured on
        # cs256: this line prints once per boot. The old wording read as an anomaly,
        # which is alarm fatigue on every box and would send a tech looking for a
        # broken fstab entry that is working exactly as designed.
        echo "Mounting VideoStore $mp (managed noauto entry; guarded mount before the stack)"
        # Never fatal. `nvr start` must still bring the stack up: mpengine now
        # refuses to record to an unmounted store rather than filling root
        # (ADR-045), and rda-backend retries the mount every 5 minutes and alarms
        # [51201]. A stopped NVR is strictly worse than a loud degraded one.
		if mount "$mp"; then
			if [ "$config_rc" = 0 ] && ! videostore_stamp_identity "$mp" "$config_uuid"; then
				echo "WARNING: mounted $mp failed removable UUID verification"
			fi
		else
			echo "WARNING: could not mount $mp; rda-backend will retry and alarm [51201]"
		fi
    done

	if [[ "$acquired_lock" == 1 ]]; then
		flock -u 9 || true
		exec 9>&-
	fi

    return 0
}

videostore_rpc() { # <method> [args...]
	local method="$1" python_cmd rpc_timeout
	shift
	case "$method" in
		config.videostore.prepareRemove)
			rpc_timeout="${NVR_VIDEOSTORE_PREPARE_RPC_TIMEOUT:-1900}"
			;;
		*)
			rpc_timeout="${NVR_VIDEOSTORE_RPC_TIMEOUT:-20}"
			;;
	esac
	python_cmd=$(cloudapi_profile_python) || return 1
	"$python_cmd" -c 'from __future__ import print_function
import json, os, re, socket, stat, sys
try:
    import xmlrpc.client as xmlrpc_client
except ImportError:
    import xmlrpclib as xmlrpc_client
try:
    timeout = float(sys.argv[4])
    if timeout <= 0:
        raise RuntimeError("VideoStore RPC timeout must be positive")
    socket.setdefaulttimeout(timeout)
    target = xmlrpc_client.ServerProxy(sys.argv[2], allow_none=True)
    method = target
    for part in sys.argv[1].split("."):
        method = getattr(method, part)
    fd = os.open(sys.argv[3], os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
    try:
        info = os.fstat(fd)
        # Reject only world (other) access. The token is created root-owned,
        # group=docker, mode 0640 by ensure_videostore_control_token so the
        # backend can read it, and mpengine/pbserver validate it with the same
        # group-permissive check (& 0007). Rejecting the group bit here (& 0077)
        # made every `nvr videostore` call fail on a correctly-installed box.
        if (not stat.S_ISREG(info.st_mode) or
                info.st_uid not in (0, os.geteuid()) or
                info.st_mode & 0o007):
            raise RuntimeError("VideoStore control token permissions are unsafe")
        token = os.read(fd, 66).decode("ascii").strip()
    finally:
        os.close(fd)
    if not re.match(r"^[0-9a-f]{64}$", token):
        raise RuntimeError("VideoStore control token is invalid")
    response = method(*([token] + sys.argv[5:]))
    if isinstance(response, (list, tuple)) and len(response) == 2:
        if response[0] is not True or response[1] == "noauth":
            raise RuntimeError(response[1])
        result = response[1]
    else:
        result = response
    sys.stdout.write(json.dumps(result, sort_keys=True, indent=2) + "\n")
except Exception as error:
    sys.stderr.write("ERROR: backend VideoStore RPC failed: %s\n" % error)
    sys.exit(1)' "$method" "$NVR_BACKEND_XMLRPC_URL" \
        "$NVR_VIDEOSTORE_HOST_TOKEN_FILE" "$rpc_timeout" "$@"
}

cmd_videostore() {
    local action="${1:-status}" selector="${2:-}" result
    case "$action" in
        status)
            if [ -n "$selector" ]; then
                videostore_rpc config.videostore.getRemovableStatus "$selector"
            else
                videostore_rpc config.videostore.getRemovableStatus
            fi
            ;;
        prepare-remove)
            [ -n "$selector" ] || {
                echo "Usage: nvr videostore prepare-remove <group-or-store>" >&2
                return 2
            }
            result=$(videostore_rpc config.videostore.prepareRemove "$selector") || return 1
            printf '%s\n' "$result"
            grep -q '"ok": true' <<<"$result" || return 1
            ;;
		mount)
            [ -n "$selector" ] || {
                echo "Usage: nvr videostore mount <group-or-store>" >&2
                return 2
            }
            result=$(videostore_rpc config.videostore.mountRemovable "$selector") || return 1
            printf '%s\n' "$result"
			grep -q '"ok": true' <<<"$result" || return 1
			;;
		configure)
			[ -n "$selector" ] && [ -f "$selector" ] || {
				echo "Usage: nvr videostore configure <config-file>" >&2
				return 2
			}
			local payload
			payload=$(base64 < "$selector" | tr -d '\r\n') || return 1
			result=$(videostore_rpc config.videostore.configureRemovable "$payload") || return 1
			printf '%s\n' "$result"
			grep -q '"ok": true' <<<"$result" || return 1
			;;
		*)
			echo "Usage: nvr videostore {status [group-or-store]|configure <config-file>|prepare-remove <group-or-store>|mount <group-or-store>}" >&2
            return 2
            ;;
    esac
}

# Remove containers whose name belongs to this compose project but whose
# com.docker.compose.project label is missing or wrong. They look like
# orphans to compose, so the next `compose up -d` collides with their
# names ("Conflict. The container name '/dividia-nvr-<svc>-1' is already
# in use") and aborts mid-recreate, leaving the stack half-broken until
# the operator manually `docker rm -f`s the offender.
#
# Why a container ends up in this state:
#   - Older watchtower releases recreate containers via the Docker API
#     without preserving compose's labels. The new container has the
#     right name and image but no `com.docker.compose.project` — invisible
#     to `compose ps` / `compose up`. cs2585 hit this 2026-05 after
#     watchtower's session updated engine + connector four days earlier.
#   - Manual `docker run --name dividia-nvr-...` (rare).
#   - A killed-mid-create container left behind by a prior failed up.
#
# Removal is safe: anything matching the project name prefix is by
# convention owned by this compose project. The next `compose up -d`
# recreates it cleanly, restoring the label set.
#
# The function sweeps THREE distinct blockers, all of which stall `compose up`:
#
#   (a) Mislabeled orphans -- correct name, missing/wrong compose-project
#       label (the watchtower / manual-run cases above). Compose sees a name
#       conflict and aborts.
#
#   (b) Dead-state containers -- correctly labeled, so invisible to (a)'s
#       label filter, but Docker CANNOT start a container in `Dead` state, so
#       the next `compose up -d` errors and aborts the whole bring-up. Docker
#       leaves a container Dead when it fails to tear down its filesystem
#       layer, which an unclean power loss reliably produces. cs8 (Rosa's Cafe
#       #18) hit this 2026-08-03: a site power cycle left dividia-nvr-playback-1
#       Dead, every `nvr start` (the systemd boot service included) exited 1,
#       and the box sat unplayable for 80 minutes until removed by hand. A Dead
#       container holds no running state and cannot be restarted, so
#       force-removing it is safe; compose recreates it cleanly.
#
#   (c) Lost-RW-layer containers -- correctly labeled AND not Dead (they sit
#       `exited`/`created`), so invisible to BOTH filters above, but their overlay
#       read-write layer is gone from the layer store, so Docker fails every start
#       with "RWLayer of container <id> is unexpectedly nil" (recorded in
#       .State.Error) and the next `compose up -d` aborts. Same unclean-power-loss
#       root cause as (b), a different torn-write outcome: the container record
#       survived but its layer-metadata entry did not. cs2616 (Trade Show / Test)
#       hit this 2026-08-05 after five power-cycles -- the viewer stranded and the
#       local video wall stayed black until manual removal. Force-removing it is
#       safe (a viewer/playback container holds no persistent state; config lives
#       in the DB and bind mounts) and compose recreates it with a fresh layer.
#       See the (c) block below for why detection uses .State.Error, not
#       .GraphDriver, on the containerd-snapshotter fleet.
#
#       The CentOS 6 SysV boot script already clears this with `docker compose
#       rm -f` + runc-state cleanup before its inline `compose up`; this is what
#       brings the systemd path (ExecStart=/opt/dividia/nvr start -> cmd_start)
#       to parity.
#
#       Scope: this removes the Dead container with `docker rm -f`, which
#       cleared it on cs8 (a systemd box) with no further action. It does NOT
#       replicate the SysV path's `rm -rf /run/containerd/.../moby/*` runtime
#       -state wipe -- that is a CentOS-6-kernel-4.4 hard-reboot workaround, and
#       blindly wiping task state under systemd's own containerd is unproven and
#       risky. If `docker rm -f` ever cannot clear a Dead container, the loop
#       below warns and `compose up` proceeds exactly as it did before this
#       change: never worse than baseline, strictly better for the observed case.
# True when a docker error/stderr string carries one of the recreate-clears-it
# corruption signatures (ADR-100). The signature list lives in ONE constant
# (HEAL_CORRUPT_SIGNATURES, pipe-delimited) so the classifier and `nvr heal`
# share it and cannot drift. Called from normal function bodies (never inline in
# a `$(...)` substitution), so the case pattern's bare `)` is safe on CO6's Bash
# 4.1 without the balancing leading `(` the old inline case needed.
heal_signature_match() { # <error-or-stderr-string>
    local err="$1" sig oldifs="$IFS"
    IFS='|'
    for sig in $HEAL_CORRUPT_SIGNATURES; do
        [ -n "$sig" ] || continue
        case "$err" in *"$sig"*) IFS="$oldifs"; return 0 ;; esac
    done
    IFS="$oldifs"
    return 1
}

# The ONE detector (ADR-100). Emits `reason|service|name` lines. Reasons:
#   dead       - status=dead, unconditional (no signature needed).
#   corrupt    - a passive .State.Error that matches a corruption signature.
#   mislabeled - a `^dividia-nvr-` name-prefix orphan whose
#                com.docker.compose.project label is wrong or missing.
#   missing    - a DESIRED service (from the caller's list) with no container at
#                all, e.g. a prior tick's `rm -f` succeeded but `up` failed. Only
#                computed when a desired-service list is passed (the heal path);
#                the repair path passes none, so its behavior is byte-for-byte
#                what it was before ADR-100.
#
# Two consumers: repair_untracked_compose_containers() force-removes the
# dead/corrupt/mislabeled set then lets the whole-stack `compose up -d` recreate
# them; `nvr heal` acts per-service with --no-deps and also handles `missing`.
# Every signature and the narrowness discipline live here, in one place.
#
# Sweeps (b)/(c)/(missing) identify OUR containers by the intact
# com.docker.compose.project LABEL, not the `^dividia-nvr-` name prefix (a),
# because the add-ons set a custom container_name (hme-stream, aiengine) that the
# prefix would miss -- the gap cs601 exposed 2026-08-05. Sweep (a) is the only
# one that can find a container whose project label is exactly what is gone, so
# it stays name-based.
#
# Every docker query is failure-tolerant: this runs bare (via repair) before
# `$COMPOSE up -d` in cmd_start (the systemd boot ExecStart) and cmd_update under
# `set -e`, so a transient docker failure at boot -- the daemon not yet accepting
# connections, the exact race the repair path exists to survive -- must not abort
# the bring-up. The function always returns 0.
classify_broken_containers() { # [desired-service-list]
    local desired="${1:-}"
    local project="dividia-nvr"
    local name svc err cid out rc
    # Each docker sweep captures its output into a var (NOT a bare pipe / `$()`
    # word-list) so a query FAILURE is detectable -- a pipe or command
    # substitution would mask the docker exit and, via the `|| true`, hide a
    # partial degradation. On any sweep failure, mark the tick degraded so heal
    # records `unavailable` instead of acting on / retiring against an incomplete
    # inspection (reviewers' P2). The repair-path caller (no desired list) ignores
    # the marker, so this is harmless there.

    # (a) Name-prefix orphans whose project label is wrong/missing. awk emits
    #     `name|service` (service is usually empty here -- the label is gone).
    rc=0
    out=$(docker ps -a \
        --filter "name=^${project}-" \
        --format '{{.Names}}|{{.Label "com.docker.compose.service"}}|{{.Label "com.docker.compose.project"}}' \
        2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && heal_mark_degraded
    printf '%s\n' "$out" | awk -F'|' -v p="$project" 'NF>=3 && $3 != p { print $1 "|" $2 }' \
        | while IFS='|' read -r name svc; do
            [ -n "$name" ] && printf 'mislabeled|%s|%s\n' "$svc" "$name"
        done

    # (b) Dead containers (status=dead), unconditional. Keyed on the project
    #     label so custom-named add-ons are covered.
    rc=0
    out=$(docker ps -a \
        --filter "label=com.docker.compose.project=${project}" \
        --filter "status=dead" \
        --format '{{.Names}}|{{.Label "com.docker.compose.service"}}' \
        2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && heal_mark_degraded
    printf '%s\n' "$out" | while IFS='|' read -r name svc; do
        [ -n "$name" ] && printf 'dead|%s|%s\n' "$svc" "$name"
    done

    # (c) Failed-to-start containers stranded by unclean power loss: they sit
    #     exited/created/restarting (NOT dead) with an intact project label and a
    #     .State.Error carrying one of the corruption signatures (lost RW layer,
    #     stale containerd task / stranded runc ID, and the cs183 stale-task
    #     strand). Detection keys off .State.Error, NOT .GraphDriver: Docker 23's
    #     containerd snapshotter leaves .GraphDriver null on HEALTHY containers
    #     too, so a null-GraphDriver test would force-remove the whole stack. The
    #     narrow signature match is the safety gate -- an unrelated start error (a
    #     port already allocated, an OOM kill) is never mistaken for corruption.
    rc=0
    out=$(docker ps -a \
        --filter "label=com.docker.compose.project=${project}" \
        --filter "status=exited" \
        --filter "status=created" \
        --filter "status=restarting" \
        --format '{{.Names}}' 2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && heal_mark_degraded
    for name in $out; do
        err=$(docker inspect -f '{{.State.Error}}' "$name" 2>/dev/null) || { heal_mark_degraded; continue; }
        if heal_signature_match "$err"; then
            svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$name" 2>/dev/null) || { heal_mark_degraded; svc=""; }
            printf 'corrupt|%s|%s\n' "$svc" "$name"
        fi
    done

    # (missing) Desired services with no container at all. Heal-only.
    #
    # CRITICAL: only emit `missing` when the docker query actually SUCCEEDED and
    # returned nothing. `set -e` has no pipefail, so a transient `docker ps`
    # failure piped into `head` would exit 0 with empty output -- and heal would
    # then `docker rm -f` + recreate a perfectly healthy running service (engine
    # included, bouncing recording). Capture the query exit code WITHOUT a pipe
    # (the pipe would mask it) and skip the service on any failure; it re-checks
    # next tick. A false "missing" here is destructive, so this fails SAFE.
    if [ -n "$desired" ]; then
        local rc
        for svc in $desired; do
            rc=0
            cid=$(docker ps -a \
                --filter "label=com.docker.compose.project=${project}" \
                --filter "label=com.docker.compose.service=${svc}" \
                --format '{{.Names}}' 2>/dev/null) || rc=$?
            # A failed per-service query means docker is partially degraded even
            # though the bare `docker ps` reachability probe passed. Flag the tick
            # so it records `unavailable` (not a healthy proof tick) instead of
            # treating the unqueryable service as `missing`.
            if [ "$rc" -ne 0 ]; then heal_mark_degraded; continue; fi
            [ -z "$cid" ] && printf 'missing|%s|\n' "$svc"
        done
    fi
    return 0
}

# The repair path (cmd_start / cmd_update): consume the classifier's remove-now
# reasons (dead/corrupt/mislabeled), force-remove each, and let the following
# whole-stack `$COMPOSE up -d` recreate them cleanly. It does NOT request
# `missing` (there is nothing to remove; the whole-stack up recreates an absent
# service on its own). Byte-for-byte the pre-ADR-100 behavior, now sourced from
# the shared classifier so it can never drift from `nvr heal`.
repair_untracked_compose_containers() {
    local candidates offenders name count
    candidates=$(classify_broken_containers) || true
    offenders=$(printf '%s\n' "$candidates" \
        | awk -F'|' '$1=="dead" || $1=="corrupt" || $1=="mislabeled" { print $3 }' \
        | awk 'NF' | sort -u)
    if [[ -z "$offenders" ]]; then
        return 0
    fi
    count=$(printf '%s\n' "$offenders" | wc -l | tr -d ' ')
    echo -e "${YELLOW}Detected $count container(s) with missing/stale compose labels, in Dead state, or with a lost RW layer / stale containerd task; removing so compose can recreate cleanly:${NC}" >&2
    while IFS= read -r name; do
        [[ -z "$name" ]] && continue
        echo "  - $name" >&2
        docker rm -f "$name" >/dev/null 2>&1 || \
            echo -e "    ${RED}WARN: failed to remove $name${NC}" >&2
    done <<< "$offenders"
}

################################################################################
# Native host-level container healer (ADR-100)
#
# `nvr heal` runs from cron every HEAL_INTERVAL_MIN minutes and recovers the two
# failure classes the on-box watchdogs miss:
#   Class 2  down / missing / corrupt containers (the cs183 class: a corrupt
#            containerd task that `restart: always` and the autoheal sidecar both
#            cannot recreate). Runs from the first release.
#   Class 1  running-but-unhealthy (formerly the autoheal sidecar's job). Runs
#            every tick; autoheal was removed entirely, so heal is the sole
#            unhealthy-container watchdog and there is no gate.
#
# Host-level is the whole point: a container watchdog dies with the runtime,
# which is exactly when it is needed. Every recreate is `--no-deps` so fixing one
# service never bounces mpengine (connector depends_on engine) and drops
# recording on the live-restore=false fleet.
################################################################################

heal_now() { date +%s 2>/dev/null || echo 0; }

# A tick is "degraded" when the bare `docker ps` reachability probe passed but a
# later per-service query (ps or inspect) FAILED, so the stack was not fully
# inspected. Any such tick must record `unavailable` (never a healthy proof tick)
# so a run of degraded ticks can never accumulate the retirement soak. Marker is
# a file so helpers running inside `$(...)` can still set it.
heal_mark_degraded() { : > "$HEAL_DEGRADED_MARK" 2>/dev/null || true; }
heal_is_degraded()   { [ -e "$HEAL_DEGRADED_MARK" ]; }
heal_clear_degraded() { rm -f "$HEAL_DEGRADED_MARK" 2>/dev/null || true; }

heal_ensure_state_dir() {
    mkdir -p "$HEAL_STATE_DIR" 2>/dev/null || true
    if [ -d "$HEAL_STATE_DIR" ] && [ "$EUID" -eq 0 ]; then
        chmod 0755 "$HEAL_STATE_DIR" 2>/dev/null || true
    fi
    mkdir -p "$(dirname "$HEAL_LOG")" 2>/dev/null || true
}

# Append-only action log. Silent on healthy ticks (only called when heal acts or
# retires). NEVER a truncating `>` -- a 5-minute cron would overwrite the
# evidence between ticks (ADR-100 4.7); logrotate bounds the history.
heal_log() {
    heal_ensure_state_dir
    printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || heal_now)" "$*" \
        >> "$HEAL_LOG" 2>/dev/null || true
}

# Heartbeat: the positive execution proof the external monitor reads. Written
# ONLY on a tick that actually reached and inspected Docker (status=healthy,
# including a healthy no-op tick) OR could not (status=unavailable). An
# unavailable tick never records a healthy heartbeat, so the reused detector's
# `|| true` cannot fake liveness. (The per-box autoheal-retirement soak that used
# to layer a continuous-streak file on top of this was removed with autoheal;
# this is now a plain latest-tick heartbeat.)
heal_write_heartbeat() {
    local status="$1" now
    now=$(heal_now)
    heal_ensure_state_dir
    printf '%s %s\n' "$now" "$status" > "$HEAL_HEARTBEAT_FILE" 2>/dev/null || true
    return 0
}

# --- stop marker (ADR-100 4.5) ---------------------------------------------
# Written by `nvr stop` (operator intent = stopped), cleared by every bring-up.
# Ephemeral (/run) so a reboot clears it. While present, heal no-ops entirely.
heal_stop_marker_present() { [ -e "$HEAL_STOP_MARKER" ]; }
heal_set_stop_marker() {
    # Returns non-zero if the marker could not be made durable, so cmd_stop can
    # warn: without the marker, heal would restart the stack after `nvr stop`.
    mkdir -p "$(dirname "$HEAL_STOP_MARKER")" 2>/dev/null || true
    printf '%s\n' "$(heal_now)" > "$HEAL_STOP_MARKER" 2>/dev/null || true
    [ -e "$HEAL_STOP_MARKER" ]
}
heal_clear_stop_marker() { rm -f "$HEAL_STOP_MARKER" 2>/dev/null || true; }

# --- desired-state authority: live compose config, cached (ADR-100 4.4/5) --
# Cached by an mtime signature of .env + every compose file in COMPOSE_FILE, so
# `compose config` (the one expensive call) stays off the hot path. A healthy
# tick is otherwise a bounded set of LOCAL docker socket queries (project-scoped
# `docker ps` sweeps + a handful of `docker inspect` per covered service), no
# image pull and no `compose up`. Returns non-zero when the desired list cannot
# be established (compose config fails, or an empty/corrupt cache); heal then
# records an unavailable tick and does NOT heal or retire against an empty scope.
#
# The cache is written ATOMICALLY and validated on read: a torn write or a crash
# between the list and .sig writes must never yield a valid .sig paired with an
# empty/partial list -- that would make every tick a no-op that still reports
# healthy and act on an empty/corrupt desired set (reviewers' P1). So: write the
# list to a temp file and rename (atomic), write the .sig only AFTER the list is
# durable, and REJECT an empty cached list (recompute) rather than trusting the
# sig alone.
heal_desired_services() {
    local env_file="$INSTALL_DIR/.env" f p sig cached_sig list tmp
    sig=""
    for f in "$env_file" $(compose_file_get 2>/dev/null | tr ':' ' '); do
        p="$f"; [ -f "$p" ] || p="$INSTALL_DIR/$f"
        [ -f "$p" ] && sig="$sig $(stat -c '%Y' "$p" 2>/dev/null || stat -f '%m' "$p" 2>/dev/null)"
    done
    if [ -f "$HEAL_DESIRED_CACHE" ] && [ -f "${HEAL_DESIRED_CACHE}.sig" ]; then
        cached_sig=$(cat "${HEAL_DESIRED_CACHE}.sig" 2>/dev/null)
        if [ "$cached_sig" = "$sig" ]; then
            list=$(cat "$HEAL_DESIRED_CACHE" 2>/dev/null)
            # Trust the cache ONLY if it is non-empty. An empty/partial cached list
            # with a matching sig (torn write) falls through to a fresh compute.
            if [ -n "$list" ]; then printf '%s\n' "$list"; return 0; fi
        fi
    fi
    list=$($COMPOSE config --services 2>/dev/null) || return 1
    [ -n "$list" ] || return 1
    heal_ensure_state_dir
    # Atomic: write the list to a temp file and rename it into place (a rename is
    # atomic, so the cache is never observed empty/partial), THEN write the sig,
    # so a crash before the sig write just forces a recompute next tick rather
    # than pairing a valid sig with a stale/empty list.
    tmp="${HEAL_DESIRED_CACHE}.new.$$"
    if printf '%s\n' "$list" > "$tmp" 2>/dev/null && mv -f "$tmp" "$HEAL_DESIRED_CACHE" 2>/dev/null; then
        printf '%s' "$sig" > "${HEAL_DESIRED_CACHE}.sig.new.$$" 2>/dev/null \
            && mv -f "${HEAL_DESIRED_CACHE}.sig.new.$$" "${HEAL_DESIRED_CACHE}.sig" 2>/dev/null || \
            rm -f "${HEAL_DESIRED_CACHE}.sig" "${HEAL_DESIRED_CACHE}.sig.new.$$" 2>/dev/null || true
    else
        rm -f "$tmp" 2>/dev/null || true
    fi
    printf '%s\n' "$list"
    return 0
}

# --- per-service helpers ----------------------------------------------------
heal_in_list() { # <needle> <space-list>
    local needle="$1" hay="$2" x
    for x in $hay; do [ "$x" = "$needle" ] && return 0; done
    return 1
}

# Emit the covered services in dependency order (db, backend, engine, then the
# rest), filtered to what is actually in the live compose config, then the
# enabled add-ons. <core-coverage-set> gates the core services; add-ons are
# always eligible when present (they join both classes when enabled).
heal_scope_ordered() { # <core-coverage-set> <desired>
    local cover="$1" desired="$2" svc
    for svc in $HEAL_DEP_ORDER; do
        heal_in_list "$svc" "$cover" || continue
        heal_in_list "$svc" "$desired" && echo "$svc"
    done
    for svc in $HEAL_ADDON_SET; do
        heal_in_list "$svc" "$desired" && echo "$svc"
    done
}

# The default single-replica container name for a service. Core services use the
# compose default `dividia-nvr-<svc>-1`; the add-ons set a custom container_name.
heal_container_name_for() { # <svc>
    case "$1" in
        hme)      echo "hme-stream" ;;
        aiengine) echo "aiengine" ;;
        *)        echo "dividia-nvr-$1-1" ;;
    esac
}

# Hardcoded from docker-compose.yml depends_on (test_heal_coverage.sh asserts it
# stays in sync). Deriving from `compose config` would need a YAML parser on CO6.
heal_service_deps() { # <svc>
    case "$1" in
        db)                 echo "" ;;
        backend|ptz)        echo "db" ;;
        engine)             echo "db backend" ;;
        connector)          echo "db backend engine" ;;
        playback|viewer)    echo "db backend" ;;
        hme|aiengine)       echo "db backend" ;;
        *)                  echo "" ;;
    esac
}

# These three read per-container state. Each captures the docker exit code and
# marks the tick degraded on a QUERY FAILURE (vs a genuine absent/false/none
# result), so an inspect/ps outage that the bare reachability probe missed still
# forces an `unavailable` tick and cannot let a run of half-inspected ticks reach
# the retirement soak (reviewers' P2). They still return the safe default on
# failure (absent / not-running / none) so callers degrade safely.
heal_container_exists() { # <name>
    local out rc=0
    out=$(docker ps -a --filter "name=^${1}$" --format '{{.Names}}' 2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && { heal_mark_degraded; return 1; }
    [ -n "$out" ]
}
heal_service_running() { # <name>
    local out rc=0
    out=$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && { heal_mark_degraded; return 1; }
    [ "$out" = "true" ]
}
heal_service_health() { # <name>  -> healthy | unhealthy | starting | none
    local out rc=0
    out=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && { heal_mark_degraded; echo none; return 0; }
    [ -n "$out" ] && echo "$out" || echo none
}
# Operator escape hatch: a `dividia.heal=false` label opts a container out of both
# classes. Takes the ACTUAL container NAME to inspect (the classifier's candidate,
# not the default -1 name), so an opted-out non-default orphan is honored too.
#
# Fails SAFE and UNCONDITIONALLY on any inspect error: mark the tick degraded and
# return 0 (opted-out => skip). Callers only invoke this for a container that is
# supposed to EXIST (the `missing` reason skips the opt-out check entirely and
# recreates from the compose spec), so an inspect failure here means docker is
# degraded, not that the container is absent -- and heal must never recreate a
# service whose `dividia.heal=false` label it could not read (reviewers' P1). The
# opt-out means "do not restart/recreate me", NOT "keep me deleted": a fully
# removed opted-out service is recreated once from its spec (which carries the
# label), then honored. To keep a service DOWN, use `nvr stop`.
heal_is_opted_out() { # <container-name>
    local val rc=0
    val=$(docker inspect -f '{{index .Config.Labels "dividia.heal"}}' "$1" 2>/dev/null) || rc=$?
    [ "$rc" -ne 0 ] && { heal_mark_degraded; return 0; }
    [ "$val" = "false" ]
}

# The classifier's reason + candidate container NAME for a service, mapping a
# mislabeled orphan back to its service by the `dividia-nvr-<svc>-<n>` name
# prefix. Emits `reason|name`; empty if not flagged. The NAME matters: a
# mislabeled/dead/corrupt candidate may be a non-default name (e.g. an orphan
# `dividia-nvr-engine-2` beside a healthy `-1`), and heal must recreate THAT
# container, not the default one (reviewers' P1).
heal_reason_for_service() { # <svc> <candidates>  -> reason|name
    printf '%s\n' "$2" | awk -F'|' -v s="$1" '
        {
            csvc=$2; cname=$3
            if (csvc=="" && cname ~ /^dividia-nvr-.*-[0-9]+$/) {
                d=cname; sub(/^dividia-nvr-/,"",d); sub(/-[0-9]+$/,"",d); csvc=d
            }
            if (csvc==s) { print $1 "|" cname; exit }
        }'
}

# --- daemon-wedge cooldown (ADR-100 4.6) ------------------------------------
# Daemon identity = (boot_id, daemon pid, daemon start time). All read without
# systemd or a modern `docker info`, so they work on CentOS 6. Field 22 of
# /proc/<pid>/stat is read AFTER stripping through the last ')' so a comm with
# spaces cannot shift the field index.
#
# PID source order: the daemon's own pidfile /var/run/docker.pid (written by
# EVERY docker version, incl. the CentOS 6 legacy `docker -d` daemon whose
# process is named `docker`, not `dockerd`), then `pgrep -x dockerd` / `pidof
# dockerd`. The pidfile is used FIRST and we never `pgrep -x docker` -- that
# would self-match heal's own `docker` CLI children (the pgrep self-match
# pitfall) and, on a legacy box, also miss the daemon behind a wrong pid.
heal_daemon_identity() {
    local boot_id pid starttime
    boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || boot_id=""
    pid=$(cat /var/run/docker.pid 2>/dev/null) || pid=""
    case "$pid" in ''|*[!0-9]*) pid="" ;; esac
    [ -n "$pid" ] && [ -r "/proc/$pid/stat" ] || pid=""
    [ -n "$pid" ] || { pid=$(pgrep -x dockerd 2>/dev/null | head -1) || pid=""; }
    [ -n "$pid" ] || { pid=$(pidof dockerd 2>/dev/null | awk '{print $1}') || pid=""; }
    starttime=""
    if [ -n "$pid" ] && [ -r "/proc/$pid/stat" ]; then
        starttime=$(sed -e 's/^.*) //' "/proc/$pid/stat" 2>/dev/null | awk '{print $20}') || starttime=""
    fi
    printf '%s|%s|%s' "$boot_id" "$pid" "$starttime"
}

heal_set_cooldown() { # <svc>
    heal_ensure_state_dir
    printf '%s\n%s\n' "$(heal_now)" "$(heal_daemon_identity)" \
        > "$HEAL_STATE_DIR/cooldown-$1" 2>/dev/null || true
}
heal_clear_cooldown() { rm -f "$HEAL_STATE_DIR/cooldown-$1" 2>/dev/null || true; }
heal_cooldown_active() { # <svc>
    local svc="$1" f="$HEAL_STATE_DIR/cooldown-$1" ts id now
    [ -f "$f" ] || return 1
    ts=$(sed -n 1p "$f" 2>/dev/null)
    id=$(sed -n 2p "$f" 2>/dev/null)
    case "$ts" in ''|*[!0-9]*) rm -f "$f" 2>/dev/null || true; return 1 ;; esac
    # Daemon identity changed -> the wedge is gone (reboot / dockerd restart).
    if [ "$id" != "$(heal_daemon_identity)" ]; then
        rm -f "$f" 2>/dev/null || true
        return 1
    fi
    now=$(heal_now)
    if [ $(( now - ts )) -ge "$HEAL_COOLDOWN" ]; then
        rm -f "$f" 2>/dev/null || true
        return 1
    fi
    return 0
}
# A recreate/restart itself failed: containerd is broadly wedged and only a
# docker daemon restart or reboot clears it (which drops recording under
# live-restore=false). Suppress this service for HEAL_COOLDOWN and log the
# customer-impact line ONCE per cooldown, not once per tick.
heal_daemon_wedge() { # <svc>
    local svc="$1"
    heal_cooldown_active "$svc" && return 0
    heal_set_cooldown "$svc"
    heal_log "CUSTOMER-IMPACT: $svc recreate/restart failed; docker/containerd appears wedged. Suppressing $svc heal for ${HEAL_COOLDOWN}s (only a docker daemon restart or reboot clears this, which drops recording under live-restore=false). The external monitor escalates."
}

# --- add-on serialization (ADR-100 4.8) -------------------------------------
# HME takes its own lock so heal never races hme_reconcile. AI Engine restore
# takes only the support lock, not the lifecycle lock heal holds. Heal must take
# that support lock without waiting and recover a journal before it can start or
# recreate the add-on. FD 6 is unused by the rest of the CLI.
heal_addon_lock_acquire() { # <svc> -> 0 acquired/not-needed, 1 held
    case "$1" in
        hme)
            # Fail SAFE: if the lock file cannot even be opened, SKIP the add-on
            # (return 1) rather than acting unlocked and racing hme_reconcile.
            exec 6>"$HME_LOCK" 2>/dev/null || return 1
            flock -xn 6 || { exec 6>&-; return 1; }
            HEAL_HME_LOCK_HELD=1 ;;
        aiengine)
            aiengine_support_lock_try || return 1
            if ! aiengine_support_restore_recover_locked; then
                aiengine_support_lock_release
                return 1
            fi
            HEAL_AIENGINE_LOCK_HELD=1 ;;
    esac
    return 0
}
heal_addon_lock_release() { # <svc>
    case "$1" in
        hme)
            if [ "${HEAL_HME_LOCK_HELD:-0}" = "1" ]; then
                flock -u 6 2>/dev/null || true
                exec 6>&-
                HEAL_HME_LOCK_HELD=0
            fi ;;
        aiengine)
            if [ "${HEAL_AIENGINE_LOCK_HELD:-0}" = "1" ]; then
                aiengine_support_lock_release
                HEAL_AIENGINE_LOCK_HELD=0
            fi ;;
    esac
}

# --- lock-free per-service actions (caller holds the add-on lock) ------------
# Recreate: `docker rm -f <name>` then `compose up -d --no-deps <svc>`. --no-deps
# is MANDATORY (connector depends_on engine; a plain up could recreate mpengine
# and drop recording).
heal_do_recreate() { # <svc> <name>
    local svc="$1" name="$2"
    docker rm -f "$name" >/dev/null 2>&1 || true
    if $COMPOSE up -d --no-deps "$svc" >/dev/null 2>&1; then
        heal_clear_cooldown "$svc"
        heal_log "$svc: recreated (rm -f $name + up -d --no-deps)"
    else
        heal_daemon_wedge "$svc"
    fi
}
# Missing desired service: `compose up -d --no-deps <svc>`. For a CORE service the
# default name is `dividia-nvr-<svc>-1`, which is unambiguously ours, so a
# defensive `rm -f` clears a mislabeled name-prefix orphan that would otherwise
# block the up. For an ADD-ON (custom container_name hme-stream / aiengine) we do
# NOT rm the name: an unlabeled hme-stream/aiengine is indistinguishable from a
# legacy foreign `docker run --name` container, and force-removing it would kill a
# healthy/opted-out legacy workload -- the exact discipline repair_untracked_
# compose_containers keeps for custom-named add-ons (reviewers' P2). Compose will
# adopt or recreate the add-on by service on `up`.
heal_do_up() { # <svc>
    local svc="$1" name
    name=$(heal_container_name_for "$svc")
    case "$name" in dividia-nvr-*) docker rm -f "$name" >/dev/null 2>&1 || true ;; esac
    if $COMPOSE up -d --no-deps "$svc" >/dev/null 2>&1; then
        heal_clear_cooldown "$svc"
        heal_log "$svc: started (up -d --no-deps; was missing)"
    else
        heal_daemon_wedge "$svc"
    fi
}
# Try-start-then-classify: the cs183 stale-task error appears on `docker start`
# stderr, not reliably in a passive .State.Error. A clean start means the service
# was merely down; a start that fails with a corruption signature is recreated.
heal_do_try_start() { # <svc> <name>
    local svc="$1" name="$2" err rc=0
    err=$(docker start "$name" 2>&1) || rc=$?
    if [ "$rc" -eq 0 ]; then
        heal_clear_cooldown "$svc"
        heal_log "$svc: started ($name was down, clean start)"
    elif heal_signature_match "$err"; then
        heal_log "$svc: start failed with a corruption signature; recreating"
        heal_do_recreate "$svc" "$name"
    else
        heal_log "$svc: start failed (not corruption, left for the operator): $(printf '%s' "$err" | head -1)"
    fi
}

# --- Class 2: down / missing / corrupt (from the first release) -------------
heal_class2() { # <desired>
    local desired="$1" candidates svc name reason act h
    # The classifier + the per-service query helpers below flag a degraded tick via
    # the HEAL_DEGRADED_MARK FILE, which survives the `$(...)` subshells these run
    # in, so this can safely command-substitute.
    candidates=$(classify_broken_containers "$desired") || candidates=""
    local entry cand_name
    for svc in $(heal_scope_ordered "$HEAL_DOWN_SET" "$desired"); do
        entry=$(heal_reason_for_service "$svc" "$candidates")
        reason=${entry%%|*}
        cand_name=${entry#*|}; [ "$cand_name" = "$entry" ] && cand_name=""
        # The container this iteration would act on: the classifier's ACTUAL
        # candidate for a fault (so a non-default orphan is the one removed), else
        # the default single-replica name.
        name=$(heal_container_name_for "$svc")
        case "$reason" in dead|corrupt|mislabeled) [ -n "$cand_name" ] && name="$cand_name" ;; esac
        # VERIFIED RECOVERY clears the daemon-wedge cooldown, BEFORE the acting-skip
        # below -- otherwise a service in cooldown is skipped entirely and its
        # cooldown can never be cleared by recovery, only by the window expiring
        # (reviewers' P2). Recovery = no fault AND running AND health exactly
        # `healthy` (NOT `none`/inspect-failure, NOT `starting`).
        if [ -z "$reason" ] && heal_container_exists "$name" && heal_service_running "$name"; then
            [ "$(heal_service_health "$name")" = "healthy" ] && heal_clear_cooldown "$svc"
        fi
        # Suppress ACTIONS while a cooldown is active.
        heal_cooldown_active "$svc" && continue
        # Opt-out check, on the ACTUAL container name, and ONLY for a container that
        # should exist (`missing` has none to inspect; it recreates from the spec,
        # which carries any dividia.heal label). Fails SAFE: an unreadable label
        # marks the tick degraded and skips. Reviewers' P1.
        [ "$reason" = "missing" ] || { heal_is_opted_out "$name" && continue; }
        act=""
        case "$reason" in
            missing)                  act="up" ;;
            dead|corrupt|mislabeled)  act="recreate" ;;
            *)
                if heal_container_exists "$name" && ! heal_service_running "$name"; then
                    act="try-start"
                fi ;;
        esac
        [ -n "$act" ] || continue
        # NEVER take a destructive action on a tick where any docker query has
        # already failed (partial degradation): a fail-open opt-out or a stale
        # classification could otherwise recreate a healthy/opted-out service. The
        # service is re-evaluated next tick once docker is responsive again. The
        # tick is recorded `unavailable` by _cmd_heal_locked. Reviewers' P1.
        heal_is_degraded && continue
        if ! heal_addon_lock_acquire "$svc"; then
            heal_log "$svc: add-on lock held (reconcile running); skipping this tick"
            continue
        fi
        case "$act" in
            up)        heal_do_up "$svc" ;;
            recreate)  heal_do_recreate "$svc" "$name" ;;
            try-start) heal_do_try_start "$svc" "$name" ;;
        esac
        heal_addon_lock_release "$svc"
    done
    return 0
}

# --- Class 1: running-but-unhealthy (autoheal parity) -----------------------
heal_unhealthy_since() { # <svc> -> epoch first seen unhealthy (stamps if new)
    local svc="$1" f="$HEAL_STATE_DIR/unhealthy-$1" ts now
    now=$(heal_now)
    if [ -f "$f" ]; then
        ts=$(cat "$f" 2>/dev/null)
        case "$ts" in ''|*[!0-9]*) ts="$now"; printf '%s\n' "$ts" > "$f" 2>/dev/null || true ;; esac
    else
        heal_ensure_state_dir
        ts="$now"; printf '%s\n' "$ts" > "$f" 2>/dev/null || true
    fi
    printf '%s' "$ts"
}
heal_clear_unhealthy_stamp() { rm -f "$HEAL_STATE_DIR/unhealthy-$1" 2>/dev/null || true; }
heal_flap_record() { heal_ensure_state_dir; printf '%s\n' "$(heal_now)" >> "$HEAL_STATE_DIR/flap-$1" 2>/dev/null || true; }
heal_flap_count() { # <svc>  (prunes entries outside the window, then counts)
    local svc="$1" f="$HEAL_STATE_DIR/flap-$1" now cutoff tmp
    [ -f "$f" ] || { echo 0; return 0; }
    now=$(heal_now); cutoff=$(( now - HEAL_FLAP_WINDOW ))
    tmp="${f}.new.$$"
    awk -v c="$cutoff" '$1 ~ /^[0-9]+$/ && $1>=c {print}' "$f" > "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$f" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; }
    wc -l < "$f" 2>/dev/null | tr -d ' '
}
heal_flap_reset() { rm -f "$HEAL_STATE_DIR/flap-$1" 2>/dev/null || true; }

# True when every declared dependency is present, running, and actually healthy
# (or has no healthcheck). A `starting` dependency counts as NOT ready: a
# just-recreated dependency is still in start_period, so the dependent is left
# for a later tick (multi-tick convergence, ADR-100 4.3), instead of being
# restarted against a dependency that is not up yet.
heal_deps_ok() { # <svc>
    local svc="$1" dep name h
    for dep in $(heal_service_deps "$svc"); do
        name=$(heal_container_name_for "$dep")
        heal_container_exists "$name" || return 1
        heal_service_running "$name" || return 1
        h=$(heal_service_health "$name")
        case "$h" in healthy|none) : ;; *) return 1 ;; esac
    done
    return 0
}

heal_class1() { # <desired>
    local desired="$1" svc name health first now flapcount
    for svc in $(heal_scope_ordered "$HEAL_UNHEALTHY_SET" "$desired"); do
        name=$(heal_container_name_for "$svc")
        heal_is_opted_out "$name" && continue
        heal_container_exists "$name" || continue
        heal_service_running "$name" || continue        # Class 2 owns the down case
        health=$(heal_service_health "$name")
        case "$health" in
            unhealthy) : ;;   # fall through and heal
            healthy)
                # Confirmed recovery: clear the unhealthy stamp AND the flap history.
                heal_clear_unhealthy_stamp "$svc"; heal_flap_reset "$svc"; continue ;;
            starting)
                # Mid-start_period after our own restart -- not a failure, so clear
                # the unhealthy stamp, but do NOT reset the flap history (the heal
                # has not been proven to have worked yet).
                heal_clear_unhealthy_stamp "$svc"; continue ;;
            *)
                # `none` = a covered service's health inspect FAILED (or, off the
                # unhealthy set, no healthcheck). Do NOT clear the persistence stamp:
                # a run of transient inspect failures would otherwise keep resetting
                # the HEAL_UNHEALTHY_PERSIST timer and delay healing indefinitely.
                # Skip this service this tick, keeping stamp + flap. Reviewers' P2.
                continue ;;
        esac
        # Dependency check FIRST: a service can be unhealthy because a dependency
        # died. Restarting it would bounce recording without fixing anything; heal
        # the dependency instead (it is earlier in dependency order this tick) and
        # leave the dependent for a later tick.
        if ! heal_deps_ok "$svc"; then
            heal_log "$svc: unhealthy but a dependency is not healthy; healing the dependency, leaving $svc for a later tick"
            continue
        fi
        first=$(heal_unhealthy_since "$svc")
        now=$(heal_now)
        [ $(( now - first )) -ge "$HEAL_UNHEALTHY_PERSIST" ] || continue
        heal_cooldown_active "$svc" && continue
        # Never restart/force-recreate on a tick where any docker query has already
        # failed (partial degradation): the health read that led here, or an earlier
        # service's query, may be stale. Defer to a later, clean tick. Reviewers' P2.
        heal_is_degraded && continue
        if ! heal_addon_lock_acquire "$svc"; then
            heal_log "$svc: add-on lock held (reconcile running); skipping this tick"
            continue
        fi
        # Count PRIOR failed heals (each recorded restart we still see unhealthy is
        # a failed heal). Escalate only AFTER HEAL_FLAP_MAX of them, i.e. on the
        # (MAX+1)th action -- ADR-100's "after 3 failed heals ... escalate". Count
        # BEFORE recording this attempt so the threshold is not off by one.
        flapcount=$(heal_flap_count "$svc")
        if [ "$flapcount" -ge "$HEAL_FLAP_MAX" ]; then
            heal_log "$svc: unhealthy and flapping ($flapcount failed heals in ${HEAL_FLAP_WINDOW}s); escalating to up -d --no-deps --force-recreate"
            $COMPOSE up -d --no-deps --force-recreate "$svc" >/dev/null 2>&1 || heal_daemon_wedge "$svc"
            heal_flap_reset "$svc"
        else
            heal_flap_record "$svc"
            heal_log "$svc: unhealthy past ${HEAL_UNHEALTHY_PERSIST}s; docker restart $name (failed heals so far: $flapcount/${HEAL_FLAP_MAX})"
            docker restart "$name" >/dev/null 2>&1 || heal_daemon_wedge "$svc"
        fi
        heal_addon_lock_release "$svc"
    done
    return 0
}

# --- autoheal purge (autoheal removed entirely; supersedes ADR-100's per-box
# --- self-gated retirement) -------------------------------------------------
# The willfarrell autoheal sidecar is gone; `nvr heal` owns both classes. This
# one-way cleanup runs on every `nvr update`: strip the (now dangling)
# docker-compose.autoheal.yml token from COMPOSE_FILE and force-remove any
# leftover sidecar. It NEVER adds the token back (unlike the old reconcile), so
# it converges every existing box exactly once and is a no-op thereafter.
#
# The strip splits COMPOSE_FILE on ':' and rejoins WITHOUT the autoheal token --
# robust for the token at any position (first/middle/last/only) and against
# adjacent duplicates, unlike a sed that can leave a stray/double colon. Same IFS
# discipline as the old reconcile: scope IFS=':' to JUST the split so a later
# array-slice expansion cannot collapse under a non-default IFS (Bash 3.2/4.x).
autoheal_purge() {
    local current parts=() kept=() part
    if update_source_guard_is_active; then
        current=""
    else
        current=$(compose_file_get)
    fi
    if [[ -n "$current" ]]; then
        # Split on ':' with `read -r -a` (not an unquoted `for part in $current`,
        # which would ALSO glob-expand a token against the cwd). Compose tokens
        # never contain glob chars today, but the safe split keeps it that way.
        IFS=':' read -r -a parts <<< "$current"
        for part in "${parts[@]}"; do
            [[ -z "$part" ]] && continue
            [[ "$part" == "docker-compose.autoheal.yml" ]] && continue
            kept+=("$part")
        done
        local joined
        joined=$(IFS=':'; echo "${kept[*]}")
        # A stale autoheal-only value strips to "" (a well-formed empty COMPOSE_FILE
        # falls back to the base docker-compose.yml in the install dir, which
        # exists; leaving the missing-file token would be worse). Never re-adds it.
        if [[ "$joined" != "$current" ]]; then
            env_write_var COMPOSE_FILE "$joined" \
                || echo "WARNING: autoheal_purge could not rewrite COMPOSE_FILE; a stale autoheal token may remain and break compose" >&2
        fi
    fi
    # Force-remove any leftover sidecar. `|| true` so an already-clean box (no
    # container) is a harmless no-op; --remove-orphans on the converge also
    # sweeps it, this just makes it immediate.
    docker rm -f dividia-nvr-autoheal-1 >/dev/null 2>&1 || true
    return 0
}

_cmd_heal_locked() {
    # Operator intent = stopped: no-op entirely.
    heal_stop_marker_present && return 0
    activation_overlay_reconcile || {
        heal_write_heartbeat unavailable
        return 1
    }
    # Docker-reachability precondition: an unreachable daemon records an
    # unavailable tick, never a healthy heartbeat.
    if ! docker ps >/dev/null 2>&1; then
        heal_write_heartbeat unavailable
        return 0
    fi
    local desired
    desired=$(heal_desired_services) || { heal_write_heartbeat unavailable; return 0; }

    # The HEAL_DEGRADED_MARK file is set by the classifier or any per-service query
    # helper when a docker query FAILED even though the bare `docker ps` above
    # succeeded (a partial daemon degradation). Such a tick did not fully inspect
    # the stack, so it must NOT count as a healthy proof tick, and Class 1 must
    # not act on a half-inspected view. Clear it, run Class 2, then if anything
    # flagged degradation record `unavailable` and stop. Reviewers' P1/P2.
    heal_clear_degraded
    heal_class2 "$desired" || true
    if heal_is_degraded; then
        heal_write_heartbeat unavailable
        return 0
    fi

    # Healthy proof tick for the external monitor. autoheal and its per-box
    # retirement soak were removed (autoheal is gone entirely); heal is now the
    # SOLE watchdog, so "did heal run" matters more, not less.
    heal_write_heartbeat healthy
    # Class 1 (running-but-unhealthy restart) runs EVERY tick now. autoheal used
    # to own this and heal gated its Class 1 off until autoheal retired per-box;
    # with autoheal removed, heal is the only unhealthy-container watchdog, so
    # there is no gate and no double-restart risk.
    heal_class1 "$desired" || true
    # If Class 1's own inspects flagged degradation, downgrade this tick's
    # heartbeat to `unavailable` so the external monitor sees an accurate
    # (incomplete-inspection) record.
    heal_is_degraded && heal_write_heartbeat unavailable
    return 0
}

cmd_heal() {
    heal_ensure_state_dir
    # 4.0 Guard: non-blocking take of the shared lifecycle lock. If any
    # update/start/stop/restart or add-on op holds it, that op converges/heals the
    # stack anyway -- skip this tick, exit 0, no cron mail.
    if ! take_lifecycle_lock_nonblocking; then
        return 0
    fi
    local rc=0
    _cmd_heal_locked || rc=$?
    release_lifecycle_lock
    return "$rc"
}

# Idempotent install of the heal cron + logrotate, matching ensure_update_cron /
# ensure_prune_cron. Landed here (not just install-nvr.sh) so boxes installed
# before heal existed pick it up on their next update.
ensure_heal_cron() {
    local cron_body logrotate_body
    IFS= read -r -d '' cron_body <<EOF || true
# Native NVR container healer (ADR-100). Every ${HEAL_INTERVAL_MIN} minutes,
# recover down/corrupt containers AND running-but-unhealthy ones from the HOST
# -- a watchdog that survives the
# runtime corruption a container watchdog cannot. Skips itself while any nvr
# lifecycle op holds the lock, and no-ops after \`nvr stop\`. Silent on healthy
# ticks; the action log is append-only and rotated. Cron stdout appends (never
# truncates) so a 5-minute cadence cannot overwrite the evidence between ticks.
# Owned by /opt/dividia/nvr ensure_heal_cron; do not edit by hand.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/${HEAL_INTERVAL_MIN} * * * * root /opt/dividia/nvr heal >>${HEAL_LOG} 2>&1
EOF
    IFS= read -r -d '' logrotate_body <<EOF || true
# Owned by /opt/dividia/nvr ensure_heal_cron; do not edit by hand.
${HEAL_LOG} {
    weekly
    rotate 8
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
}
EOF
    # Write with `printf '%s'`, NOT `echo`. `read -r -d ''` already captured the
    # heredoc's trailing newline, so `echo` would append a SECOND one, making the
    # file one byte longer than install_heal_cron's `cat <<'CRON'` output. That
    # would trip the cmp below and rewrite the cron on the first update after a
    # fresh install (churn), contradicting the byte-identical contract that
    # test_heal_cron_contract asserts. `printf '%s'` produces exactly one trailing
    # newline == the installer's output.
    if [[ $EUID -ne 0 ]]; then
        if ! sudo -n true 2>/dev/null; then return 0; fi
        if ! { [[ -f "$HEAL_CRON" ]] && sudo cmp -s <(printf '%s' "$cron_body") "$HEAL_CRON"; }; then
            printf '%s' "$cron_body" | sudo tee "$HEAL_CRON" >/dev/null && sudo chmod 0644 "$HEAL_CRON"
        fi
        if ! { [[ -f "$HEAL_LOGROTATE" ]] && sudo cmp -s <(printf '%s' "$logrotate_body") "$HEAL_LOGROTATE"; }; then
            printf '%s' "$logrotate_body" | sudo tee "$HEAL_LOGROTATE" >/dev/null && sudo chmod 0644 "$HEAL_LOGROTATE"
        fi
    else
        if ! { [[ -f "$HEAL_CRON" ]] && cmp -s <(printf '%s' "$cron_body") "$HEAL_CRON"; }; then
            printf '%s' "$cron_body" > "$HEAL_CRON" && chmod 0644 "$HEAL_CRON"
        fi
        if ! { [[ -f "$HEAL_LOGROTATE" ]] && cmp -s <(printf '%s' "$logrotate_body") "$HEAL_LOGROTATE"; }; then
            printf '%s' "$logrotate_body" > "$HEAL_LOGROTATE" && chmod 0644 "$HEAL_LOGROTATE"
        fi
    fi
}

cmd_stop() {
	take_lifecycle_lock || {
		echo "ERROR: cannot take lifecycle lock for stop" >&2
		return 1
	}
	# Write the heal stop marker BEFORE `compose down`, while holding the lock,
	# so a crash mid-stop still suppresses heal (operator intent was stop) and a
	# reboot clears /run. See ADR-100 4.5. Warn loudly if it cannot be made
	# durable: heal would otherwise restart the stack after this `nvr stop`.
	heal_set_stop_marker || echo -e "${RED}WARN: could not write heal stop marker $HEAL_STOP_MARKER; 'nvr heal' may restart the stack. Check /run permissions.${NC}" >&2
	if ! update_source_state_capture && ! update_source_state_read >/dev/null; then
		echo "WARN: could not save installed image identities before stopping the stack" >&2
	fi
	local status=0
	$COMPOSE down || status=$?
	release_lifecycle_lock
	return "$status"
}

cmd_restart() {
    # `compose up -d --force-recreate`, NOT `compose restart`.
    #
    # `compose restart` bounces containers in place and does NOT evaluate
    # depends_on conditions, so `nvr restart engine` can bring mpengine back
    # against a backend that is still starting. That is the ordering bypass
    # behind the 2026-07-24 root-recording incident (and cs1666, where a plain
    # restart did not clear a wedged recorder but --force-recreate did).
    # `up -d` honors `condition: service_healthy`, so the engine waits for
    # backend, which waits for the DB.
    #
    # --force-recreate is required, not decoration: on an unchanged config a
    # bare `up -d` is a NO-OP, so `nvr restart` would silently do nothing.
    # Recreating also re-applies the compose spec, which is how the per-service
    # json-file log cap (70aff728c) reaches a container at all; `restart` keeps
    # whatever LogConfig the container was created with.
    #
    # Recreation is safe here: every piece of state lives in a bind mount or a
    # named volume, never in the container's writable layer.
	take_lifecycle_lock || {
		echo "ERROR: cannot take lifecycle lock for restart" >&2
		return 1
	}
    # Intentional bring-up: clear the heal stop marker so heal resumes (ADR-100
    # 4.5), and purge any dangling autoheal token/sidecar (autoheal removed).
    heal_clear_stop_marker
    autoheal_purge || true
    if ! activation_overlay_reconcile; then
        release_lifecycle_lock
        return 1
    fi
    local status=0
    # One atomic recreate, per the contract above. A `stop; rm -f; up -d` split
    # leaves the services DOWN between steps and, if `up` fails, deletes the
    # containers outright -- a worse failure than the in-place recreate this does.
    $COMPOSE up -d --force-recreate "$@" || status=$?
	release_lifecycle_lock
	return "$status"
}

cmd_version() {
    echo -e "${BLUE}=== NVR Image Versions ===${NC}"
    for svc in $($COMPOSE config --services 2>/dev/null); do
        local cid
        cid=$($COMPOSE ps -q "$svc" 2>/dev/null)
        if [[ -n "$cid" ]]; then
            local ver commit channel
            ver=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$cid" 2>/dev/null || echo "?")
            commit=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$cid" 2>/dev/null || echo "?")
            channel=$(docker inspect --format '{{index .Config.Labels "channel"}}' "$cid" 2>/dev/null || echo "?")
            printf "  %-12s version=%-8s commit=%-10s channel=%s\n" "$svc" "$ver" "$commit" "$channel"
        else
            printf "  %-12s (not running)\n" "$svc"
        fi
    done
}

cmd_shell() {
    local svc="${1:-backend}"
    $COMPOSE exec "$svc" bash
}

cmd_vm_shell() {
    # Called by the Windows nvr.cmd `nvr shell` branch. The Windows-side
    # admin-key passphrase prompt is what authenticated the tech; by the
    # time we reach this function, SSH has already accepted the admin
    # key and `sudo` has run without prompt. We drop back to the ssh
    # login user with `sudo -u dividia -i` — root would give an unexpected
    # shell with the wrong home/prompt.
    #
    # Hardcoded to `dividia` (not `${SUDO_USER}`) because cloud-init only
    # provisions the `dividia` account. Trusting SUDO_USER would silently
    # drop into whichever account sudo happened to be invoked from — if
    # ops later adds another sudoer (e.g. a `deployer` service account),
    # `nvr shell` would become identity-laundering. Fail loud instead.
    exec sudo -u dividia -i
}

cmd_migrate_scalewatcher() {
    # Import a legacy 2014 Windows Scale Watcher backup zip into this NVR.
    # $1 must be an absolute path to the zip file, either:
    #   - inside the backend container's /videostore mount (customer placed
    #     the zip on the Windows-host SMB share at C:\Dividia\VideoStore\
    #     migrate-staging\ — shows up as /videostore/vs1/migrate-staging/
    #     inside the container), OR
    #   - any Windows-host path — we docker cp it into the container
    # $2+ optional --dry-run flag.
    local zip_path="$1"
    local dry_flag=""
    local source_mount="" source_uuid="" source_rc=0
    shift || true
    for arg in "$@"; do
        case "$arg" in
            --dry-run) dry_flag="--dry-run" ;;
            *)
                echo -e "${RED}ERROR: unknown flag: $arg${NC}" >&2
                exit 1
                ;;
        esac
    done

    if [[ -z "$zip_path" ]]; then
        echo -e "${RED}ERROR: usage: nvr migrate-scalewatcher <path-to-zip> [--dry-run]${NC}" >&2
        exit 1
    fi

    # Defense against shell-injection via zip_path: reject anything that
    # isn't plain filesystem-path-looking (letters, digits, /, \, :, ., _,
    # -, space).  We do NOT shell-interpolate this value into bash -c
    # below, but keeping a strict charset avoids pushing the problem to
    # downstream tools that may be less careful.
    if [[ "$zip_path" =~ [\`\$\;\"\'\&\|\<\>] ]]; then
        echo -e "${RED}ERROR: zip path contains shell metacharacters${NC}" >&2
        exit 1
    fi
    # Defense against path-traversal: reject any ../ component.  Charset
    # guard above allows dots, so '/videostore/../etc/passwd' passes the
    # prefix check below.  realpath-based canonicalization would be
    # stronger but realpath(1) is inconsistent across BSD/GNU; the explicit
    # ..-rejection is portable and sufficient.
    if [[ "$zip_path" == *'/..'* || "$zip_path" == *'..'/* || "$zip_path" == '..' || "$zip_path" == *'/../'* ]]; then
        echo -e "${RED}ERROR: zip path contains .. traversal components${NC}" >&2
        exit 1
    fi

    if ! $COMPOSE ps --format '{{.Service}}' 2>/dev/null | grep -q backend; then
        echo -e "${RED}ERROR: backend container is not running${NC}" >&2
        exit 1
    fi

    # A VideoStore-backed import is itself a long-lived reader and writer. Hold
    # the same host lock as safe removal for the complete import. Verify a
    # configured removable source before the backend can open it.
    case "$zip_path" in
        /videostore/vs[1-9]/*|/videostore/vs[1-9][0-9]/*)
            source_mount="/videostore/$(printf '%s' "$zip_path" | cut -d/ -f3)" ;;
        /mnt/videostore/*)
            source_mount="/videostore/vs1" ;;
    esac
    if [ -n "$source_mount" ]; then
        take_videostore_lock || {
            echo -e "${RED}ERROR: cannot take VideoStore lock for migration${NC}" >&2
            exit 1
        }
        source_uuid=$(videostore_removable_uuid_for_path "$source_mount") || source_rc=$?
        if [ "$source_rc" -gt 1 ]; then
            echo -e "${RED}ERROR: removable VideoStore config is invalid${NC}" >&2
            release_videostore_lock
            exit 1
        fi
        if [ "$source_rc" = 0 ]; then
            if videostore_intent_suppresses "$source_uuid" "$source_mount"; then
                echo -e "${RED}ERROR: $source_mount has active removal intent${NC}" >&2
                release_videostore_lock
                exit 1
            fi
            if ! videostore_stamp_identity "$source_mount" "$source_uuid"; then
                echo -e "${RED}ERROR: $source_mount does not match its configured removable UUID${NC}" >&2
                release_videostore_lock
                exit 1
            fi
        fi
    fi

    # Stage inside the backend container so cleanup is bounded to one mount.
    local stage_dir="/tmp/migrate-scalewatcher-$$"
    $COMPOSE exec -T backend mkdir -p "$stage_dir"

    # Resolve the in-container path for the zip.  Inside the backend
    # container the SMB-mapped VideoStore share is mounted at /videostore/
    # vs1/ (the host-side path /mnt/videostore/ documented in the plan
    # does NOT exist inside the container).  If the caller handed us a
    # /videostore path, it's already container-local.  Everything else
    # goes through docker cp.
    local container_zip
    if [[ "$zip_path" == /videostore/* ]]; then
        container_zip="$zip_path"
    elif [[ "$zip_path" == /mnt/videostore/* ]]; then
        # Rewrite the documented host alias to the real container path
        container_zip="/videostore/vs1/${zip_path#/mnt/videostore/}"
    else
        # Arbitrary host path: docker cp into the stage dir
        container_zip="$stage_dir/scalewatcher-backup.zip"
        $COMPOSE cp "$zip_path" "backend:$container_zip" || {
            echo -e "${RED}ERROR: cannot copy $zip_path into backend container${NC}" >&2
            [ -z "$source_mount" ] || release_videostore_lock
            exit 1
        }
    fi

    # Pass zip path via env, not string interpolation — prevents shell
    # injection even if earlier guards are bypassed.  `bash -c '<script>' _
    # arg1 arg2` style with "$1"/"$2" inside the script is the one safe way
    # to forward user input through bash -c.
    # Use `|| result=$?` so `set -e` on the outer script doesn't abort
    # before we can capture the exit status and clean up stage_dir.  The
    # naive `cmd; local result=$?` idiom is dead code under set -e: if cmd
    # fails, the script exits immediately and the cleanup + pretty-error
    # block never run, leaving /tmp/migrate-scalewatcher-<pid> behind on
    # the backend container.
    local result=0
    $COMPOSE exec -T \
        -e MIGRATE_STAGE="$stage_dir" \
        -e MIGRATE_ZIP="$container_zip" \
        -e MIGRATE_DRY_FLAG="$dry_flag" \
        backend bash -c '
            set -e
            cd "$MIGRATE_STAGE"
            unzip -o "$MIGRATE_ZIP" -d unpacked/
            # Zip unpacks to a single top-level directory containing
            # manifest.json + dtech.sql.  Pin the exact expected shape
            # (exactly one top-level dir, with a manifest).
            mapfile -t export_dirs < <(find unpacked -maxdepth 1 -mindepth 1 -type d)
            if [[ ${#export_dirs[@]} -eq 0 ]]; then
                export_dir="unpacked"
            elif [[ ${#export_dirs[@]} -eq 1 ]]; then
                export_dir="${export_dirs[0]}"
            else
                echo "ERROR: zip contains multiple top-level directories" >&2
                exit 1
            fi
            if [[ ! -f "$export_dir/manifest.json" ]]; then
                echo "ERROR: manifest.json not found under $export_dir" >&2
                exit 1
            fi
            # Quote MIGRATE_DRY_FLAG to prevent word-splitting surprises if
            # parent-side validation ever loosens.  Empty string is a valid
            # argv that Python getopt/argparse rejects cleanly.
            if [[ -n "$MIGRATE_DRY_FLAG" ]]; then
                rda-db --migrate-scalewatcher "$export_dir" "$MIGRATE_DRY_FLAG"
            else
                rda-db --migrate-scalewatcher "$export_dir"
            fi
        ' || result=$?

    # Cleanup — always, even on failure, to avoid /tmp buildup.
	$COMPOSE exec -T backend rm -rf "$stage_dir" 2>/dev/null || true
	[ -z "$source_mount" ] || release_videostore_lock

    if [[ $result -ne 0 ]]; then
        echo -e "${RED}ERROR: migrate-scalewatcher failed (exit $result)${NC}" >&2
        exit 1
    fi

    if [[ -z "$dry_flag" ]]; then
        echo -e "${GREEN}Migration complete. Restarting services so new Camera/Device/POS config takes effect...${NC}"
        # Full restart: engine/playback/viewer all cache dvs.conf + DB
        # rows at startup and won't see imported data otherwise.  Skip db
        # (kept up).
        #
        # up -d --force-recreate rather than `compose restart`, for the same
        # reason as cmd_restart: `restart` ignores depends_on conditions, so the
        # engine could come back ahead of the backend it depends on.
        $COMPOSE up -d --force-recreate backend engine connector playback viewer ptz
    fi
}

cmd_find() {
    # Discover hosts on the local network using arp-scan in the backend
    # container.  Default mode auto-detects physical IPv4 NICs (eth*, en*,
    # bond*, br0) and runs arp-scan --localnet against each.  --interface
    # and --cidr override.  Vendor names come from the IEEE OUI database
    # bundled with the arp-scan package; duplicates are NOT deduplicated
    # so IP conflicts are visible.
    local iface=""
    local cidr=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            -i|--interface)
                if [[ -z "${2:-}" || "${2:0:1}" == "-" ]]; then
                    echo -e "${RED}ERROR: --interface requires a value${NC}" >&2
                    return 1
                fi
                iface="$2"; shift 2 ;;
            -c|--cidr)
                if [[ -z "${2:-}" || "${2:0:1}" == "-" ]]; then
                    echo -e "${RED}ERROR: --cidr requires a value${NC}" >&2
                    return 1
                fi
                cidr="$2"; shift 2 ;;
            -h|--help)
                cat <<'FINDHELP'
Usage: nvr find [options]

Discover hosts on the local network using arp-scan. Shows IP, MAC,
and vendor (looked up from the IEEE OUI database). Duplicates are
NOT deduplicated -- that's how you spot IP conflicts.

Options:
  -i, --interface IFACE   Scan only the named interface
  -c, --cidr CIDR         Scan a specific subnet (e.g. 192.168.0.0/24)
  -h, --help              Show this help

With no options, scans every IPv4-bearing physical NIC (eth*, en*,
bond*, br0). Docker bridges, veth, VPN tunnels are skipped.

Examples:
  nvr find                       # all physical NICs
  nvr find -i eth0               # eth0 only
  nvr find -c 10.0.0.0/24        # specific subnet on default iface
  nvr find -i eth1 -c 10.0.0.0/24
FINDHELP
                return 0 ;;
            *)
                echo -e "${RED}ERROR: unknown option: $1${NC}" >&2
                cmd_find --help >&2
                return 1 ;;
        esac
    done

    # Validate option values.  docker compose exec passes argv directly,
    # so shell-injection isn't possible -- this is defense in depth and
    # gives the user a clearer error than arp-scan's own complaint.
    # Iface regex anchors the first char to alnum so a value like `-rf`
    # can't survive validation and end up parsed as a flag by arp-scan.
    if [[ -n "$iface" && ! "$iface" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then
        echo -e "${RED}ERROR: interface name contains illegal characters${NC}" >&2
        return 1
    fi
    if [[ -n "$cidr" ]]; then
        if [[ ! "$cidr" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$ ]]; then
            echo -e "${RED}ERROR: --cidr must be a dotted-quad with optional /mask${NC}" >&2
            return 1
        fi
        # Bounds-check octets and prefix.  Loose regex would accept
        # 999.999.999.999/99 and let arp-scan emit a less actionable
        # error 100ms later.
        local cidr_addr="${cidr%%/*}"
        local cidr_pfx="${cidr#*/}"
        [[ "$cidr_pfx" == "$cidr" ]] && cidr_pfx=""
        local IFS_save="$IFS"; IFS=.
        local octets=($cidr_addr)
        IFS="$IFS_save"
        for o in "${octets[@]}"; do
            if (( o > 255 )); then
                echo -e "${RED}ERROR: CIDR octet $o > 255${NC}" >&2
                return 1
            fi
        done
        if [[ -n "$cidr_pfx" ]] && (( cidr_pfx > 32 )); then
            echo -e "${RED}ERROR: CIDR prefix /$cidr_pfx > 32${NC}" >&2
            return 1
        fi
    fi

    # `grep -qx backend` (exact-line match) so a future service named
    # `backend-foo` doesn't accidentally satisfy the gate.
    if ! $COMPOSE ps --format '{{.Service}}' 2>/dev/null | grep -qx backend; then
        echo -e "${RED}ERROR: backend container is not running${NC}" >&2
        exit 1
    fi

    if [[ -n "$cidr" && -n "$iface" ]]; then
        $COMPOSE exec -T backend arp-scan -I "$iface" --plain "$cidr"
    elif [[ -n "$cidr" ]]; then
        $COMPOSE exec -T backend arp-scan --plain "$cidr"
    elif [[ -n "$iface" ]]; then
        $COMPOSE exec -T backend arp-scan -I "$iface" --localnet --plain
    else
        # Default: physical NICs only.  Filter is name-prefix + IPv4-
        # bearing.  Track per-iface success so an all-failed run exits
        # 1 (e.g. NET_RAW missing) -- partial success still exits 0.
        # No user input reaches the bash -c body; shell-quoting is fine.
        $COMPOSE exec -T backend bash -c '
            set -u
            mapfile -t candidates < <(
                ip -4 -o addr show \
                    | awk "{print \$2}" \
                    | sort -u \
                    | grep -E "^(eth|en|bond|br0)" \
                    | grep -vE "^(docker|veth|br-|tun|wg|virbr)"
            )
            if [[ ${#candidates[@]} -eq 0 ]]; then
                echo "ERROR: no physical IPv4 interfaces found (looking for eth*, en*, bond*, br0)" >&2
                exit 1
            fi
            ok=0
            first=1
            for i in "${candidates[@]}"; do
                [[ $first -eq 1 ]] || echo
                echo "=== $i ==="
                if arp-scan -I "$i" --localnet --plain; then
                    ok=$((ok+1))
                fi
                first=0
            done
            if [[ $ok -eq 0 ]]; then
                echo "ERROR: every interface scan failed (NET_RAW missing? arp-scan absent?)" >&2
                exit 1
            fi
            exit 0
        '
    fi
}

################################################################################
# Optional aiengine add-on lifecycle
#
# The whole add-on is a compose overlay gated on durable host intent. `enable`
# appends the add-on after the host overlays and starts the service; `disable`
# drops the overlay and removes the container; reconcile (from update, start,
# boot, and the CLI) converges live state to intent. Only root, holding the
# existing `nvr update` lock, mutates .env / intent / the overlay / containers.
# A non-root caller is read-only: it detects drift and warns, never writes.
#
# Image build, model files, the device-ID generator, key validation, and the
# healthcheck all ship from the EXTERNAL aiengine repository. This code never
# reimplements the licensing algorithm.
################################################################################

# Copy the existing file's owner + mode onto a temp replacement. A fresh temp
# file lands as root:root 0644 under root's umask; renaming that over .env would
# DOWNGRADE it from the dividia:docker 0640 contract and expose
# MYSQL_ROOT_PASSWORD to every local user. `--reference` on GNU coreutils (all
# four supported hosts); stat/octal fallback keeps it working on a dev Mac test.
env_preserve_perms() {
    local ref="$1" tmp="$2"
    chmod --reference="$ref" "$tmp" 2>/dev/null \
        || chmod "$(stat -c '%a' "$ref" 2>/dev/null || echo 640)" "$tmp" 2>/dev/null || true
    chown --reference="$ref" "$tmp" 2>/dev/null || true
}

# Atomic single-variable rewrite of the host .env. Preserves every other line
# (unknown vars, comments, blanks) byte-for-byte, then swaps in via rename so a
# crash mid-write can never truncate .env (which would strand COMPOSE_FILE and
# break every subsequent compose call). Replaces the first matching KEY= line;
# appends if absent. Keys here are fixed identifiers, so the anchor is safe.
# The value is passed through ENVIRON (not `awk -v`) so a backslash in a value
# is never interpreted as an escape sequence.
env_write_var() {
    local key="$1" value="$2"
    [[ -f "$AIENGINE_ENV_FILE" ]] || { echo "ERROR: $AIENGINE_ENV_FILE missing" >&2; return 1; }
    local tmp="${AIENGINE_ENV_FILE}.new.$$"
    AENV_K="$key" AENV_V="$value" awk '
        BEGIN { k=ENVIRON["AENV_K"]; v=ENVIRON["AENV_V"]; done=0 }
        !done && index($0, k "=") == 1 { print k "=" v; done=1; next }
        { print }
        END { if (!done) print k "=" v }
    ' "$AIENGINE_ENV_FILE" > "$tmp" || { rm -f "$tmp"; return 1; }
    env_preserve_perms "$AIENGINE_ENV_FILE" "$tmp"
    mv -f "$tmp" "$AIENGINE_ENV_FILE"
}

# Atomic removal of a KEY= line from .env (same temp+rename discipline).
env_remove_var() {
    local key="$1"
    [[ -f "$AIENGINE_ENV_FILE" ]] || return 0
    local tmp="${AIENGINE_ENV_FILE}.new.$$"
    AENV_K="$key" awk 'BEGIN { k=ENVIRON["AENV_K"] } index($0, k "=") == 1 { next } { print }' \
        "$AIENGINE_ENV_FILE" > "$tmp" || { rm -f "$tmp"; return 1; }
    env_preserve_perms "$AIENGINE_ENV_FILE" "$tmp"
    mv -f "$tmp" "$AIENGINE_ENV_FILE"
}

compose_file_get() {
    sed -n 's/^COMPOSE_FILE=//p' "$AIENGINE_ENV_FILE" 2>/dev/null | head -1
}

activation_overlay_files() { # <compose-files> <present|absent>
    local current="$1" want="$2" part joined boot_seen=0
    local -a parts=() rebuilt=()
    [[ -n "$current" ]] || return 1
    IFS=':' read -r -a parts <<< "$current"
    for part in "${parts[@]}"; do
        [[ -n "$part" ]] || continue
        [[ "$part" != "$NVR_ACTIVATION_OVERLAY" ]] || continue
        if [[ "$part" == "$BOOT_STORAGE_COMPOSE_OVERLAY" ]]; then
            boot_seen=1
            continue
        fi
        rebuilt+=("$part")
    done
    [[ "$want" != present ]] || rebuilt+=("$NVR_ACTIVATION_OVERLAY")
    [[ "$boot_seen" -eq 0 ]] || rebuilt+=("$BOOT_STORAGE_COMPOSE_OVERLAY")
    joined=$(IFS=':'; echo "${rebuilt[*]}")
    printf '%s\n' "$joined"
}

activation_overlay_token_set() { # present|absent
    local want="$1" current joined
    update_source_guard_is_active && return 0
    current=$(compose_file_get)
    [[ -n "$current" ]] || {
        echo "ERROR: COMPOSE_FILE not set in $AIENGINE_ENV_FILE" >&2
        return 1
    }
    joined=$(activation_overlay_files "$current" "$want") || return 1
    [[ "$joined" == "$current" ]] || env_write_var COMPOSE_FILE "$joined"
}

activation_overlay_reconcile() {
    local required=0 contract="" minimum="" marker_state="" journal_state=NONE journal_valid=0
    activation_import_legacy_ready_marker "" || return 1
    if [[ -e "$MARIADB_DATADIR_MARKER" || -L "$MARIADB_DATADIR_MARKER" ]]; then
        contract=$(mariadb_marker_contract) || {
            echo "ERROR: invalid MariaDB floor marker blocks activation overlay reconciliation" >&2
            return 1
        }
        minimum=$(printf '%s\n' "$contract" | awk -F '\t' '{print $1}')
        marker_state=$(printf '%s\n' "$contract" | awk -F '\t' '{print $2}')
        [[ "$minimum" != 10.11 ]] || required=1
    fi
    if [[ -e "$NVR_ACTIVATION_JOURNAL" || -L "$NVR_ACTIVATION_JOURNAL" ]]; then
        if activation_journal_read; then
            journal_valid=1
            journal_state="$AJ_STATE"
            case "$journal_state" in activating|verifying|committed) required=1 ;; esac
        else
            echo "ERROR: invalid activation journal blocks automatic overlay repair" >&2
        fi
    fi
    # The journal advances before the separate pending floor write. If power
    # fails between them, return to the verified pre-open state on MariaDB 10.5.
    if [[ "$minimum:$marker_state:$journal_valid:$journal_state" == \
            10.5:ready:1:activating \
            && ! -e "$MARIADB_OPEN_ATTEMPT_MARKER" \
            && ! -L "$MARIADB_OPEN_ATTEMPT_MARKER" ]]; then
        AJ_STATE="backup-verified"
        AJ_BLOCKED_REASON=NONE
        activation_journal_write || return 1
        journal_state="backup-verified"
        required=0
    fi
    # A pre-open rollback records the safe journal state before it lowers the
    # separate floor marker. Repair an interruption between those two writes.
    if [[ "$minimum:$marker_state:$journal_valid" == 10.11:pending:1 \
            && ! -e "$MARIADB_OPEN_ATTEMPT_MARKER" \
            && ! -L "$MARIADB_OPEN_ATTEMPT_MARKER" ]]; then
        case "$journal_state" in
            staged|backup-in-progress|backup-verified|blocked)
                mariadb_write_datadir_marker ready 10.5 10.5 NONE || return 1
                required=0
                ;;
        esac
    fi
    # Lost backup media before the open-attempt record exists is still on the
    # reversible side. Persist the blocked state, lower the floor, and boot 10.5.
    if [[ "$minimum:$marker_state:$journal_valid:$journal_state" == \
            10.11:pending:1:activating \
            && ! -e "$MARIADB_OPEN_ATTEMPT_MARKER" \
            && ! -L "$MARIADB_OPEN_ATTEMPT_MARKER" ]] \
            && { [[ "$AJ_BACKUP_PATH" == NONE || ! -d "$AJ_BACKUP_PATH" ]] \
                || ! activation_videostore_identity_valid >/dev/null 2>&1; }; then
        activation_record_failure backup-media-lost-before-open 1 staged || true
        activation_journal_read || return 1
        mariadb_write_datadir_marker ready 10.5 10.5 NONE || return 1
        journal_state="$AJ_STATE"
        required=0
    fi
    if [[ "$required" -eq 1 ]]; then
        [[ -f "$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY" && ! -L "$INSTALL_DIR/$NVR_ACTIVATION_OVERLAY" ]] || {
            echo "ERROR: MariaDB 10.11 floor requires the missing activation overlay" >&2
            return 1
        }
        activation_overlay_token_set present
    else
        activation_overlay_token_set absent
    fi
}

# Recompute COMPOSE_FILE so the aiengine overlay appears exactly once after the
# host overlay. Configured boot-storage masks remain last. `want` is present or
# absent. Deduplicates a doubly-appended overlay as a side effect.
compose_overlay_reconcile() {
    local want="$1"
    local current
    if update_source_guard_is_active; then
        echo "ERROR: finish the pending nvr update before changing Compose overlays" >&2
        return 1
    fi
    current=$(compose_file_get)
    [[ -n "$current" ]] || { echo "ERROR: COMPOSE_FILE not set in $AIENGINE_ENV_FILE" >&2; return 1; }
    local rebuilt=() part
    local IFS=':'
    for part in $current; do
        [[ "$part" == "$AIENGINE_OVERLAY" || "$part" == "$BOOT_STORAGE_COMPOSE_OVERLAY" ]] && continue
        [[ -n "$part" ]] && rebuilt+=("$part")
    done
    # (autoheal overlay removed entirely; nothing to preserve here.) The aiengine
    # token still lands last-before-boot-storage.
    [[ "$want" == "present" ]] && rebuilt+=("$AIENGINE_OVERLAY")
    [[ -e "$BOOT_STORAGE_CONFIG" ]] && rebuilt+=("$BOOT_STORAGE_COMPOSE_OVERLAY")
    local joined
    joined=$(IFS=':'; echo "${rebuilt[*]}")
    # Skip the rewrite when COMPOSE_FILE already matches. On a steady-state boot
    # the overlay state is already correct, so reconcile does not touch .env at
    # all — which also shrinks the (lockless) boot-reconcile vs update-cron race
    # to the rare moment the overlay actually has to change.
    [[ "$joined" == "$current" ]] && return 0
    env_write_var COMPOSE_FILE "$joined"
}

# True when the aiengine overlay is currently listed in COMPOSE_FILE.
compose_overlay_present() {
    compose_file_get | tr ':' '\n' | grep -qxF "$AIENGINE_OVERLAY"
}

aiengine_support_name_valid() {
    local name="$1"
    local part rest
    case "$name" in
        ''|/*|*//*|./*|*/./*|*/.|../*|*/../*|*/..) return 1 ;;
    esac
    rest="$name"
    while [[ "$rest" == */* ]]; do
        part=${rest%%/*}
        rest=${rest#*/}
        case "$part" in
            ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) return 1 ;;
        esac
    done
    case "$rest" in
        ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) return 1 ;;
    esac
    case "$rest" in
        *.cfg|*.weights|*.names|*.patterns|*.data) return 0 ;;
        *) return 1 ;;
    esac
}

aiengine_support_parent_safe() {
    local base="$1" rest="$2" part path="$1"
    while [[ "$rest" == */* ]]; do
        part=${rest%%/*}
        rest=${rest#*/}
        path="$path/$part"
        [[ ! -L "$path" ]] || return 1
        [[ ! -e "$path" || -d "$path" ]] || return 1
    done
}

aiengine_runtime_restore_from_journal() {
    local journal="$1" state="$1/runtime.state"
    local exists="" running="" restart="" retry="" key value current policy
    # Journals created before runtime quiescing have no state file. Preserve
    # backward-compatible recovery for those already-durable transactions.
    [[ -e "$state" || -L "$state" ]] || return 0
    [[ -f "$state" && ! -L "$state" ]] || return 1
    while IFS='=' read -r key value || [[ -n "$key$value" ]]; do
        case "$key" in
            exists) [[ -z "$exists" ]] || return 1; exists="$value" ;;
            running) [[ -z "$running" ]] || return 1; running="$value" ;;
            restart) [[ -z "$restart" ]] || return 1; restart="$value" ;;
            retry) [[ -z "$retry" ]] || return 1; retry="$value" ;;
            *) return 1 ;;
        esac
    done < "$state"
    [[ "$exists" == 0 || "$exists" == 1 ]] || return 1
    [[ "$running" == 0 || "$running" == 1 ]] || return 1
    case "$restart" in no|always|unless-stopped|on-failure) ;; *) return 1 ;; esac
    case "$retry" in ''|*[!0-9]*) return 1 ;; esac
    [[ "$exists" == 1 ]] || return 0
    docker info >/dev/null 2>&1 || return 1
    # The transaction must not recreate a container removed by another
    # recovery action. With no container, no mixed model process can start.
    docker inspect aiengine >/dev/null 2>&1 || return 0
    policy="$restart"
    [[ "$restart" == "on-failure" && "$retry" -gt 0 ]] \
        && policy="on-failure:$retry"
    docker update --restart="$policy" aiengine >/dev/null || return 1
    current=$(docker inspect --format '{{.State.Running}}' aiengine 2>/dev/null) || return 1
    if [[ "$running" == 1 && "$current" != true ]]; then
        docker start aiengine >/dev/null || return 1
    elif [[ "$running" == 0 && "$current" == true ]]; then
        docker stop aiengine >/dev/null || return 1
    fi
}

# A journal can become visible before the restore process stops AI Engine.
# Recovery must stop any surviving process before it rewrites model files.
aiengine_runtime_quiesce_from_journal() {
    local journal="$1" state="$1/runtime.state"
    local exists="" running="" restart="" retry="" key value current
    [[ -e "$state" || -L "$state" ]] || return 0
    [[ -f "$state" && ! -L "$state" ]] || return 1
    while IFS='=' read -r key value || [[ -n "$key$value" ]]; do
        case "$key" in
            exists) [[ -z "$exists" ]] || return 1; exists="$value" ;;
            running) [[ -z "$running" ]] || return 1; running="$value" ;;
            restart) [[ -z "$restart" ]] || return 1; restart="$value" ;;
            retry) [[ -z "$retry" ]] || return 1; retry="$value" ;;
            *) return 1 ;;
        esac
    done < "$state"
    [[ "$exists" == 0 || "$exists" == 1 ]] || return 1
    [[ "$running" == 0 || "$running" == 1 ]] || return 1
    case "$restart" in no|always|unless-stopped|on-failure) ;; *) return 1 ;; esac
    case "$retry" in ''|*[!0-9]*) return 1 ;; esac
    [[ "$exists" == 1 ]] || return 0
    docker info >/dev/null 2>&1 || return 1
    docker inspect aiengine >/dev/null 2>&1 || return 0
    docker update --restart=no aiengine >/dev/null || return 1
    current=$(docker inspect --format '{{.State.Running}}' aiengine 2>/dev/null) || return 1
    [[ "$current" != true ]] || docker stop aiengine >/dev/null
}

aiengine_identity_name_valid() {
    case "$1" in env|intent|key) return 0 ;; *) return 1 ;; esac
}

aiengine_identity_path() {
    case "$1" in
        env) printf '%s\n' "$AIENGINE_ENV_FILE" ;;
        intent) printf '%s\n' "$AIENGINE_INTENT_FILE" ;;
        key) printf '%s\n' "$AIENGINE_CONFIG_DIR/aiengine-key" ;;
        *) return 1 ;;
    esac
}

aiengine_identity_parent_prepare() {
    case "$1" in
        env)
            [[ -d "$INSTALL_DIR" && ! -L "$INSTALL_DIR" ]] ;;
        intent)
            [[ ! -L "$AIENGINE_INTENT_DIR" ]] || return 1
            mkdir -p "$AIENGINE_INTENT_DIR" || return 1
            chmod 0750 "$AIENGINE_INTENT_DIR" 2>/dev/null || true
            chown root:docker "$AIENGINE_INTENT_DIR" 2>/dev/null || true ;;
        key)
            [[ ! -L "$AIENGINE_CONFIG_DIR" ]] || return 1
            mkdir -p "$AIENGINE_CONFIG_DIR" || return 1
            chmod 0710 "$AIENGINE_CONFIG_DIR" 2>/dev/null || true
            chown root:docker "$AIENGINE_CONFIG_DIR" 2>/dev/null || true ;;
        *) return 1 ;;
    esac
}

# Restore the pre-transaction .env, intent, and key generation. Older support
# journals do not contain these lists and remain valid.
aiengine_identity_journal_validate() {
    local journal="$1"
    local originals="$journal/identity.original.names"
    local targets="$journal/identity.target.names"
    local rollback="$journal/rollback/identity"
    local name
    if [[ ! -e "$originals" && ! -L "$originals" \
            && ! -e "$targets" && ! -L "$targets" ]]; then
        return 0
    fi
    [[ -f "$originals" && ! -L "$originals" \
        && -f "$targets" && ! -L "$targets" ]] || return 1
    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_identity_name_valid "$name" || return 1
    done < "$targets"
    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_identity_name_valid "$name" || return 1
        grep -qxF "$name" "$targets" || return 1
        [[ -f "$rollback/$name" && ! -L "$rollback/$name" ]] || return 1
    done < "$originals"
}

aiengine_identity_restore_from_journal() {
    local journal="$1"
    local originals="$journal/identity.original.names"
    local targets="$journal/identity.target.names"
    local rollback="$journal/rollback/identity"
    local name target tmp
    aiengine_identity_journal_validate "$journal" || return 1
    [[ -e "$targets" || -L "$targets" ]] || return 0
    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_identity_name_valid "$name" || return 1
        target=$(aiengine_identity_path "$name") || return 1
        aiengine_identity_parent_prepare "$name" || return 1
        rm -f "$target" || return 1
    done < "$targets"
    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_identity_name_valid "$name" || return 1
        [[ -f "$rollback/$name" && ! -L "$rollback/$name" ]] || return 1
        target=$(aiengine_identity_path "$name") || return 1
        aiengine_identity_parent_prepare "$name" || return 1
        tmp="${target}.aiengine-rollback.$$"
        cp -p "$rollback/$name" "$tmp" || return 1
        mv -f "$tmp" "$target" || return 1
    done < "$originals"
}

# A restore publishes this journal before it changes any live model file.
# Recover the saved pre-restore set after a process failure or power loss.
aiengine_support_restore_recover_locked() {
    local journal="$AIENGINE_SUPPORT_RESTORE_JOURNAL"
    local committed="$AIENGINE_SUPPORT_RESTORE_COMMITTED"
    local rolled_back="$AIENGINE_SUPPORT_RESTORE_ROLLED_BACK"
    local rollback="$journal/rollback"
    local originals="$journal/original.names"
    local targets="$journal/target.names"
    local name tmp marker_tmp target target_dir
    if [[ ! -e "$journal" && ! -L "$journal" \
            && ! -e "$committed" && ! -L "$committed" \
            && ! -e "$rolled_back" && ! -L "$rolled_back" ]]; then
        return 0
    fi
    [[ $EUID -eq 0 ]] || return 1
    if [[ -e "$committed" || -L "$committed" ]]; then
        [[ -f "$committed" && ! -L "$committed" ]] || return 1
        # The committed data generation is durable. Restore runtime before
        # journal cleanup, so an orphaned marker needs only removal.
        [[ ! -e "$journal" && ! -L "$journal" ]] \
            || aiengine_runtime_restore_from_journal "$journal" || return 1
        sync || return 1
        rm -rf "$journal" || return 1
        rm -f "$committed" || return 1
        rm -f "$rolled_back" || return 1
        sync || return 1
        echo "Finalized committed AI Engine support-file restore."
        return 0
    fi
    # Recovery writes this marker after every old file is durable, then restores
    # runtime. A repeat can safely restore runtime again before cleanup.
    if [[ -e "$rolled_back" || -L "$rolled_back" ]]; then
        [[ -f "$rolled_back" && ! -L "$rolled_back" ]] || return 1
        [[ ! -e "$journal" && ! -L "$journal" ]] \
            || aiengine_runtime_restore_from_journal "$journal" || return 1
        rm -rf "$journal" || return 1
        rm -f "$rolled_back" || return 1
        sync || return 1
        echo "Finalized rolled-back AI Engine support-file restore."
        return 0
    fi
    if [[ ! -d "$journal" || -L "$journal" \
            || ! -d "$rollback" || -L "$rollback" \
            || ! -f "$originals" || -L "$originals" \
            || ! -f "$targets" || -L "$targets" ]]; then
        echo "ERROR: invalid AI Engine support restore journal: $journal" >&2
        return 1
    fi

    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_support_name_valid "$name" || {
            echo "ERROR: invalid AI Engine restore target name" >&2
            return 1
        }
        aiengine_support_parent_safe "$AIENGINE_CONFIG_DIR" "$name" || {
            echo "ERROR: unsafe AI Engine restore target parent: $name" >&2
            return 1
        }
    done < "$targets"
    while IFS= read -r name || [[ -n "$name" ]]; do
        aiengine_support_name_valid "$name" || {
            echo "ERROR: invalid AI Engine restore rollback name" >&2
            return 1
        }
        aiengine_support_parent_safe "$rollback" "$name" || {
            echo "ERROR: unsafe AI Engine restore rollback parent: $name" >&2
            return 1
        }
        aiengine_support_parent_safe "$AIENGINE_CONFIG_DIR" "$name" || {
            echo "ERROR: unsafe AI Engine restore destination parent: $name" >&2
            return 1
        }
        [[ -f "$rollback/$name" && ! -L "$rollback/$name" ]] || {
            echo "ERROR: incomplete AI Engine support rollback file: $name" >&2
            return 1
        }
    done < "$originals"

    aiengine_identity_journal_validate "$journal" || return 1
    aiengine_runtime_quiesce_from_journal "$journal" || return 1
    mkdir -p "$AIENGINE_CONFIG_DIR" || return 1
    while IFS= read -r name || [[ -n "$name" ]]; do
        rm -f "$AIENGINE_CONFIG_DIR/$name" || return 1
    done < "$targets"
    while IFS= read -r name || [[ -n "$name" ]]; do
        # Keep the rollback source intact until every old file is live and an
        # external completion marker exists. A second crash can replay safely.
        target="$AIENGINE_CONFIG_DIR/$name"
        target_dir=$(dirname "$target") || return 1
        mkdir -p "$target_dir" || return 1
        tmp="${target}.aiengine-rollback.$$"
        cp -p "$rollback/$name" "$tmp" || return 1
        mv -f "$tmp" "$target" || return 1
    done < "$originals"
    aiengine_identity_restore_from_journal "$journal" || return 1
    # Persist the complete old generation before publishing the external
    # rollback marker. Runtime resumes only after that marker is durable.
    sync || return 1
    marker_tmp="${rolled_back}.new.$$"
    rm -f "$marker_tmp" || return 1
    ( umask 077; : > "$marker_tmp" ) || return 1
    mv -f "$marker_tmp" "$rolled_back" || return 1
    sync || return 1
    aiengine_runtime_restore_from_journal "$journal" || return 1
    rm -rf "$journal" || return 1
    rm -f "$rolled_back" || return 1
    sync || return 1
    echo "Recovered interrupted AI Engine support-file restore."
}

aiengine_support_lock_take() {
    if [[ "${NVR_AIENGINE_SUPPORT_LOCK_HELD:-0}" == "1" ]]; then
        if [[ "${AIENGINE_SUPPORT_LOCK_OWNED:-0}" == "1" ]]; then
            AIENGINE_SUPPORT_LOCK_DEPTH=$(( ${AIENGINE_SUPPORT_LOCK_DEPTH:-1} + 1 ))
        fi
        return 0
    fi
    mkdir -p "$(dirname "$AIENGINE_SUPPORT_RESTORE_LOCK")" || return 1
    [[ ! -L "$AIENGINE_SUPPORT_RESTORE_LOCK" ]] || return 1
    case "$AIENGINE_SUPPORT_LOCK_TIMEOUT" in
        ''|*[!0-9]*) return 1 ;;
    esac
    # Start, boot, update, and add-on commands already hold lifecycle FD 7 or 9
    # before reaching this lock. Backup and restore take only this support lock.
    # No support-lock holder can wait for lifecycle, so the order has no cycle.
    exec 5>"$AIENGINE_SUPPORT_RESTORE_LOCK" || return 1
    flock -w "$AIENGINE_SUPPORT_LOCK_TIMEOUT" -x 5 \
        || { exec 5>&-; return 1; }
    NVR_AIENGINE_SUPPORT_LOCK_HELD=1
    AIENGINE_SUPPORT_LOCK_OWNED=1
    AIENGINE_SUPPORT_LOCK_DEPTH=1
}

# Heal already holds the lifecycle lock. It must never wait for a restore that
# holds only this support lock, because that would stall every lifecycle action.
aiengine_support_lock_try() {
    if [[ "${NVR_AIENGINE_SUPPORT_LOCK_HELD:-0}" == 1 ]]; then
        return 0
    fi
    mkdir -p "$(dirname "$AIENGINE_SUPPORT_RESTORE_LOCK")" || return 1
    [[ ! -L "$AIENGINE_SUPPORT_RESTORE_LOCK" ]] || return 1
    exec 5>"$AIENGINE_SUPPORT_RESTORE_LOCK" || return 1
    flock -xn 5 || { exec 5>&-; return 1; }
    NVR_AIENGINE_SUPPORT_LOCK_HELD=1
    AIENGINE_SUPPORT_LOCK_OWNED=1
    AIENGINE_SUPPORT_LOCK_DEPTH=1
}

aiengine_support_lock_release() {
    [[ "${AIENGINE_SUPPORT_LOCK_OWNED:-0}" == "1" ]] || return 0
    if [[ "${AIENGINE_SUPPORT_LOCK_DEPTH:-1}" -gt 1 ]]; then
        AIENGINE_SUPPORT_LOCK_DEPTH=$((AIENGINE_SUPPORT_LOCK_DEPTH - 1))
        return 0
    fi
    flock -u 5 2>/dev/null || true
    exec 5>&-
    AIENGINE_SUPPORT_LOCK_DEPTH=0
    AIENGINE_SUPPORT_LOCK_OWNED=0
    NVR_AIENGINE_SUPPORT_LOCK_HELD=0
}

aiengine_with_support_lock() {
    local result=0
    aiengine_support_lock_take || {
        echo "ERROR: cannot take AI Engine support lock" >&2
        return 1
    }
    "$@" || result=$?
    aiengine_support_lock_release
    return "$result"
}

aiengine_support_restore_recover() {
    local lock_taken=0 result=0
    if [[ "${NVR_AIENGINE_SUPPORT_LOCK_HELD:-0}" != "1" ]]; then
        aiengine_support_lock_take || return 1
        lock_taken=1
    fi
    aiengine_support_restore_recover_locked || result=$?
    if [[ $lock_taken -eq 1 ]]; then
        aiengine_support_lock_release
    fi
    return "$result"
}

# Print the intent: enabled | legacy-provisioned | disabled. An absent or
# unrecognized file reads as disabled (fail-safe off on a fresh NVR).
aiengine_intent_read() {
    local v=""
    if [[ -f "$AIENGINE_INTENT_FILE" ]]; then
        v=$(tr -d '[:space:]' < "$AIENGINE_INTENT_FILE" 2>/dev/null) || true
    fi
    case "$v" in
        enabled|legacy-provisioned|disabled) echo "$v" ;;
        *) echo "disabled" ;;
    esac
}

# enabled and legacy-provisioned both mean "run the local container".
aiengine_intent_configured_on() {
    local v; v=$(aiengine_intent_read)
    [[ "$v" == "enabled" || "$v" == "legacy-provisioned" ]]
}

aiengine_intent_is_on() {
    [[ ! -e "$AIENGINE_SUPPORT_RESTORE_JOURNAL" \
        && ! -L "$AIENGINE_SUPPORT_RESTORE_JOURNAL" \
        && ! -e "$AIENGINE_SUPPORT_RESTORE_COMMITTED" \
        && ! -L "$AIENGINE_SUPPORT_RESTORE_COMMITTED" \
        && ! -e "$AIENGINE_SUPPORT_RESTORE_ROLLED_BACK" \
        && ! -L "$AIENGINE_SUPPORT_RESTORE_ROLLED_BACK" ]] || return 1
    aiengine_intent_configured_on
}

# Atomic intent write (root-only mutation). Directory root:docker 0750; file
# 0640 so a non-root `nvr update` reconcile can still READ existing intent.
aiengine_intent_write() {
    local value="$1"
    mkdir -p "$AIENGINE_INTENT_DIR"
    chown root:docker "$AIENGINE_INTENT_DIR" 2>/dev/null || true
    chmod 0750 "$AIENGINE_INTENT_DIR" 2>/dev/null || true
    local tmp="${AIENGINE_INTENT_FILE}.new.$$"
    printf '%s\n' "$value" > "$tmp" || { rm -f "$tmp"; return 1; }
    chmod 0640 "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$AIENGINE_INTENT_FILE"
}

aiengine_require_root() {
    if should_reexec_root; then nvr_try_elevate; return $?; fi
    [[ $EUID -eq 0 ]] && return 0
    echo -e "${RED}ERROR: run 'sudo nvr addon aiengine $1'${NC}" >&2
    return 1
}

# Take the SAME lock as `nvr update` so an operator `nvr addon` and the nightly
# update cron cannot race on .env / intent / containers. Non-blocking: a second
# holder exits cleanly rather than piling up.
aiengine_lock() {
    exec 9>"$NVR_LIFECYCLE_LOCK"
    flock -n 9 || { echo "another nvr update/addon operation is in progress" >&2; return 1; }
}

# Core-service pull, isolated from optional add-on images. The whole-project
# `$COMPOSE pull` / `up --quiet-pull` would try to fetch aiengine too once its
# overlay is in COMPOSE_FILE, so an add-on registry outage would block core
# updates. Pull required core services BY NAME instead; the add-on image is
# pulled separately in aiengine_reconcile. Required-core pull failure stays
# fatal, exactly as the prior whole-project pull was.
core_services() {
    # Exclude the aiengine add-on: it has its OWN guarded `up` step (its image
    # can lag/be-absent, and must never take the core lifecycle down). hme stays
    # in the core set on purpose — hme_reconcile_config runs BEFORE the core up
    # and only leaves the hme overlay in COMPOSE_FILE when its image is proven
    # pullable, so the by-name core up is the step that starts a validated hme.
    $COMPOSE config --services 2>/dev/null | grep -vxF "$AIENGINE_SERVICE" || true
}

core_service_pull() {
    local svcs
    # This pull runs BEFORE hme_reconcile_config has validated hme's tag this
    # cycle, so also drop hme here (core_services keeps it for the post-reconcile
    # up). A lagging/absent hme tag (built out of band, independent of ${CHANNEL})
    # would otherwise abort the whole
    # pre-reconcile core pull under `set -e` and block core updates fleet-wide.
    # hme is pulled+started later by the validated by-name core up; aiengine is
    # already excluded by core_services and pulled in aiengine_reconcile.
    svcs=$(core_services | grep -vxF "hme")
    # Fall back to the whole-project pull only if the service list is
    # unreadable (compose config failed) — never silently pull nothing.
    if [[ -z "$svcs" ]]; then
        $COMPOSE pull --quiet
        return
    fi
    # Intentional word split of the service list.
    # shellcheck disable=SC2086
    $COMPOSE pull --quiet $svcs
}

# Pull ONLY the aiengine image, in its own step. Never folded into the core
# pull. A failure here is a warning to the caller, not a fatal core error.
aiengine_pull() {
    $COMPOSE pull --quiet "$AIENGINE_SERVICE"
}

# The single reconcile entry point, called from update, start, boot, and the
# CLI. Converges COMPOSE_FILE + the add-on image to host intent.
#   enabled | legacy-provisioned -> overlay present, add-on image pulled
#   disabled | absent            -> overlay absent
# Root mutates; non-root is READ-ONLY (drift warning only). The `up` that
# actually creates/removes the container is owned by the caller (cmd_update /
# cmd_start / cmd_addon_aiengine), so reconcile stays side-effect-light and can
# run from every boot path including CentOS 6.
aiengine_reconcile() {
    local on=0

    if [[ $EUID -ne 0 ]]; then
        aiengine_intent_is_on && on=1
        local have=0
        compose_overlay_present && have=1
        if [[ $on -ne $have ]]; then
            echo "WARN: aiengine intent ($(aiengine_intent_read)) does not match COMPOSE_FILE overlay state; run 'sudo nvr addon aiengine status'" >&2
        fi
        return 0
    fi

    aiengine_support_restore_recover || return 1
    aiengine_intent_is_on && on=1

    if [[ $on -eq 1 ]]; then
        # Cred-gate, matching aiengine_env_normalize. Intent alone is NOT
        # enough: without the license key the container crash-loops keyless
        # every update and boot. Gate on the KEY only -- the key is the license;
        # the device-id is derived from hardware and its file is optional (a
        # legacy image regenerates it at start and never ships a file writer).
        # Requiring a device-id file here would wrongly strip the add-on on
        # every boot/update of a legacy-image box.
        if [[ ! -f "$AIENGINE_CONFIG_DIR/aiengine-key" ]]; then
            echo "WARN: aiengine intent is '$(aiengine_intent_read)' but aiengine-key is missing; leaving the add-on off" >&2
            compose_overlay_reconcile absent || return 1
            return 0
        fi
        compose_overlay_reconcile present || return 1
        # Optional-image pull in its OWN step. On failure keep any prior
        # healthy image/container: an add-on registry outage must never block
        # the core update. First `enable` is transactional (see cmd below).
        aiengine_pull || echo "WARN: aiengine image pull failed; keeping any existing image/container" >&2
    else
        compose_overlay_reconcile absent || return 1
    fi
}

# Re-check intent and recover journals while holding the same support lock as
# backup and restore. Callers already hold the lifecycle lock first. Restore
# never takes lifecycle, so this fixed order cannot form a circular wait.
aiengine_start_serialized() {
    local result=0
    aiengine_support_lock_take || {
        echo "ERROR: cannot take AI Engine support lock before startup" >&2
        return 1
    }
    if ! aiengine_reconcile; then
        result=1
    elif aiengine_intent_is_on; then
        $COMPOSE up -d "$AIENGINE_SERVICE" || result=$?
    fi
    aiengine_support_lock_release
    return "$result"
}

# Restore normalizer. A restored .env can carry an aiengine overlay reference or
# AIENGINE_* vars onto a box whose intent or credential files are missing, which
# would re-enable a broken container. Recompute COMPOSE_FILE from intent and
# strip AIENGINE_* when intent is disabled or the credentials are absent. Restore
# never re-enables aiengine implicitly. Invoked after any .env restore
# (rda-db/src/backup/dvs30.py and install-nvr.sh .env.save copy-back).
aiengine_env_normalize() {
    # Gate on the KEY only (the license); the device-id file is optional and
    # derived from hardware, so a legacy-image box has a valid key but no
    # device-id file. Matches aiengine_reconcile.
    local creds_ok=0 intent_on=0
    [[ -f "$AIENGINE_CONFIG_DIR/aiengine-key" ]] && creds_ok=1
    if [[ "${NVR_AIENGINE_SUPPORT_LOCK_HELD:-0}" == 1 ]]; then
        aiengine_intent_configured_on && intent_on=1
    else
        aiengine_intent_is_on && intent_on=1
    fi
    if [[ $intent_on -eq 1 && $creds_ok -eq 1 ]]; then
        compose_overlay_reconcile present || return 1
    else
        compose_overlay_reconcile absent || return 1
        env_remove_var AIENGINE_TAG || true
        env_remove_var AIENGINE_REGISTRY || true
        env_remove_var AIENGINE_IMAGE || true
    fi
}

# Boot-time compose bring-up used by the CentOS 6 SysV init script and the
# migration finalize. Same reconcile + pull isolation as cmd_update/cmd_start,
# but WITHOUT the host-config/videostore self-heal the SysV script already runs
# inline before it. Core up by name so a missing add-on image cannot fail the
# boot; add-on started separately and non-fatally.
cmd_boot_up() {
	take_lifecycle_lock || {
		echo "ERROR: cannot take lifecycle lock for boot-up" >&2
		return 1
	}
	local status=0
	cmd_boot_up_locked "$@" || status=$?
	release_lifecycle_lock
	return "$status"
}

cmd_boot_up_locked() {
	ensure_videostore_mounts
    prepare_customer_assets_for_compose || return 1
    activation_overlay_reconcile || return 1
    aiengine_reconcile || true
    boot_storage_runtime_ready || return 1
    boot_storage_enforce_running_container_masks pre || return 1
    host_display_repair_cached_payload_pin \
        || echo "WARN: cached host dview payload pin repair failed" >&2
    local _core_svcs
    _core_svcs=$(core_services)
    if [[ -n "$_core_svcs" ]]; then
        # Intentional word split of the service list.
        # shellcheck disable=SC2086
        $COMPOSE up -d $_core_svcs || return 1
    else
        $COMPOSE up -d || return 1
    fi
    lpr_templates_refresh_consumers_if_needed || return 1
    aiengine_start_serialized \
        || echo "WARN: aiengine did not start; core services are up" >&2
    boot_storage_enforce_running_container_masks || return 1
}

cmd_ensure_videostore_mounts() {
	ensure_videostore_mounts
}

# --- Local database demand ----------------------------------------------------
# Detect whether configured LPR/object work can call a LOCAL endpoint. Reads
# BOTH config tables (never Camera.fLPR or a bare DeviceType 61 row). Prints one
# "kind|bCamera|bID|sName|sIP|bPort" line per enabled row referencing a type-61
# Device. bPort is read as-is (no COALESCE 88): a NULL bPort is a broken config
# at runtime today, not implicit-88 local demand. Best-effort: an unreachable DB
# yields no rows and the caller decides (fail-open lives in migration, not here).
aiengine_demand_rows() {
    cmd_db --skip-column-names -B -e "
        SELECT 'lpr', L.bCamera, D.bID, D.sName, D.sIP, D.bPort
        FROM LprConfig L JOIN Device D ON D.bID = L.bDeviceID
        WHERE L.fEnable = 1 AND D.bType = 61 AND D.bPort IS NOT NULL
        UNION ALL
        SELECT 'object', O.bCamera, D.bID, D.sName, D.sIP, D.bPort
        FROM ObjectDetectConfig O JOIN Device D ON D.bID = O.bDeviceID
        WHERE O.fEnable = 1 AND D.bType = 61 AND D.bPort IS NOT NULL;
    " 2>/dev/null || true
}

# Classify an address as local: loopback, ::1, this host's name(s), or an
# address on a current interface. Empty/unresolvable is NOT local.
aiengine_addr_is_local() {
    local addr="$1"
    [[ -z "$addr" ]] && return 1
    case "$addr" in
        localhost|127.*|::1) return 0 ;;
    esac
    local host fqdn
    host=$(hostname 2>/dev/null) || host=""
    fqdn=$(hostname -f 2>/dev/null) || fqdn=""
    [[ -n "$host" && "$addr" == "$host" ]] && return 0
    [[ -n "$fqdn" && "$addr" == "$fqdn" ]] && return 0
    # An address on a current interface.
    if command -v ip >/dev/null 2>&1; then
        ip -o addr show 2>/dev/null | grep -qw "$addr" && return 0
    fi
    return 1
}

# Print local direct-demand camera IDs (unique). Used by disable/status.
aiengine_local_camera_ids() {
    local rows kind cam bid name ip port
    rows=$(aiengine_demand_rows)
    [[ -n "$rows" ]] || return 0
    while IFS=$'\t' read -r kind cam bid name ip port; do
        [[ -z "$kind" ]] && continue
        if aiengine_addr_is_local "$ip"; then
            echo "$cam"
        fi
    done <<< "$rows" | sort -un
}

aiengine_has_local_demand() {
    [[ -n "$(aiengine_local_camera_ids)" ]]
}

# --- CLI: nvr addon aiengine <prepare|enable|disable|status> ------------------

cmd_addon() {
    local sub="${1:-}"; shift 2>/dev/null || true
    case "$sub" in
        aiengine) cmd_addon_aiengine "$@" ;;
        hme)      cmd_addon_hme "$@" ;;
        ""|help|-h|--help)
            cat <<'EOF'
Usage: nvr addon <aiengine|hme> <command> [options]

Manage the optional local add-ons. Mutating commands require root and
coordinate with updates through add-on locks.

aiengine   LPR / object detection on the NVR (at most two cameras):
  prepare [--tag TAG]                   Pull image, create the credential dir,
                                        create+print device-id (never the key)
  enable [--key-file PATH] [--tag TAG]  Install key, enable intent, start service
  disable [--force]                     Stop and drop the overlay (keeps identity)
  status                                Report intent, image, health, demand

hme        HME drive-thru timer stream:
  {enable|disable [--force]|auto|status}
EOF
            ;;
        *) echo "Unknown addon: $sub (known: aiengine, hme)" >&2; return 1 ;;
    esac
}

cmd_addon_aiengine() {
    local action="${1:-status}"; shift || true
    case "$action" in
        prepare) cmd_addon_aiengine_prepare "$@" ;;
        enable)  cmd_addon_aiengine_enable "$@" ;;
        disable) cmd_addon_aiengine_disable "$@" ;;
        status)  cmd_addon_aiengine_status "$@" ;;
        # Hidden: print local direct-demand camera IDs (one per line, empty if
        # none). RPM migration uses it to choose enabled vs legacy-provisioned.
        local-demand) aiengine_local_camera_ids ;;
        *) echo "Unknown: nvr addon aiengine $action" >&2; return 1 ;;
    esac
}

# Validate host architecture: the local NVR image is amd64 only.
aiengine_require_amd64() {
    local arch
    arch=$(uname -m 2>/dev/null) || arch=""
    case "$arch" in
        x86_64|amd64) return 0 ;;
        *) echo -e "${RED}ERROR: aiengine local add-on requires x86_64/amd64 (host is '$arch'). Use a Jetson for arm.${NC}" >&2; return 1 ;;
    esac
}

# Create the credential directory (root:docker 0710). The group-execute bit lets
# a non-root `nvr update` stat known paths without letting the docker group list
# the directory or read the key.
aiengine_ensure_config_dir() {
    mkdir -p "$AIENGINE_CONFIG_DIR"
    chown root:docker "$AIENGINE_CONFIG_DIR" 2>/dev/null || true
    chmod 0710 "$AIENGINE_CONFIG_DIR" 2>/dev/null || true
}

# Create device-id via the EXTERNAL image's canonical generator, ONLY when
# absent. Never regenerate half of an existing (device-id, aiengine-key) pair —
# that would disable the licensed engine. The NVR never computes the ID itself.
aiengine_create_device_id() {
    local tag="$1"
    # Full image ref (honor a digest pin). One-shot read-write mount, used ONLY
    # here; the compose service mounts the same directory read-write (the Track 1
    # image rewrites device-id every boot), so this one-shot mount just seeds the
    # id before the first licensed start so `prepare` can print it for key issuance.
    local image="${AIENGINE_IMAGE:-${AIENGINE_REGISTRY:-docker.io}/dividia/aiengine:${tag:-stable}}"
    # The tool WRITES the inode-encrypted /srv/data/device-id (the engine reads
    # it) AND prints {"dev_id":"..."} to stdout. Idempotent: the id is derived
    # from the host MAC, so re-running yields the same id. We capture stdout and
    # return the human-readable id, because the stored file is encrypted binary
    # and can never be cat'd for display.
    local out
    if out=$(docker run --rm --network host -v "$AIENGINE_CONFIG_DIR:/srv/data" \
            --entrypoint /usr/local/bin/aiengine-make-device-id "$image" 2>/dev/null); then
        chmod 0600 "$AIENGINE_CONFIG_DIR/device-id" 2>/dev/null || true
        chown root:root "$AIENGINE_CONFIG_DIR/device-id" 2>/dev/null || true
    else
        # Legacy image without the make-device-id helper: read the id via the
        # engine's own -d flag. No file is written; the engine regenerates the
        # id from hardware at start (so the licensed pair still matches).
        # --network host so it sees the real host NICs, not a container veth.
        # NOTE: `aiengine -d` prints the id and then exit(1) BY DESIGN, so do
        # NOT gate on the exit code -- capture stdout regardless.
        out=$(docker run --rm --network host "$image" -d x 2>/dev/null) || true
    fi
    # Extract the 12-hex device id from the JSON (or a bare token) and return it.
    local id
    id=$(printf '%s' "$out" | grep -oE '[0-9A-Fa-f]{12}' | head -1)
    [[ -n "$id" ]] && printf '%s\n' "$id"
    return 0
}

cmd_addon_aiengine_prepare() {
    aiengine_require_root prepare || return 1
    local tag="stable"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --tag) tag="$2"; shift 2 ;;
            *) echo "Unknown prepare option: $1" >&2; return 1 ;;
        esac
    done
    aiengine_lock || return 1
    aiengine_require_amd64 || return 1

    aiengine_with_support_lock cmd_addon_aiengine_prepare_locked "$tag"
}

cmd_addon_aiengine_prepare_locked() {
    local tag="$1"

    # Record the requested tag so reconcile/compose render the same image.
    env_write_var AIENGINE_TAG "$tag" || return 1

    echo "Pulling aiengine:$tag ..."
    aiengine_pull || { echo -e "${YELLOW}WARN: image pull failed; a later pull may still succeed${NC}" >&2; }

    aiengine_ensure_config_dir
    local dev_id
    dev_id=$(aiengine_create_device_id "$tag") || dev_id=""

    if [[ -n "$dev_id" ]]; then
        echo ""
        echo "Device ID (needed for out-of-band key issuance):"
        echo "  $dev_id"
        case "$dev_id" in
            000000000000|DEADBEEFDEAD)
                echo -e "${YELLOW}  WARNING: this is not a valid device id. The image could not read a hardware MAC.${NC}" >&2 ;;
        esac
    else
        echo -e "${YELLOW}No device-id could be generated. Is the pulled image the amd64 build that ships aiengine-make-device-id?${NC}"
    fi
    # NEVER print an existing product key.
}

# Install a supplied key file atomically at 0600, rejecting symlinks,
# directories, and group/other-readable sources (a key must be secret).
aiengine_install_key() {
    local src="$1"
    [[ -e "$src" ]] || { echo -e "${RED}ERROR: key file not found: $src${NC}" >&2; return 1; }
    if [[ -L "$src" ]]; then
        echo -e "${RED}ERROR: refusing a symlink key file: $src${NC}" >&2; return 1
    fi
    if [[ -d "$src" ]]; then
        echo -e "${RED}ERROR: key path is a directory: $src${NC}" >&2; return 1
    fi
    # Reject a group/other-readable source to avoid adopting a leaked key.
    local mode
    mode=$(stat -c '%a' "$src" 2>/dev/null || stat -f '%A' "$src" 2>/dev/null) || mode=""
    if [[ -n "$mode" && "${mode: -2}" != "00" ]]; then
        echo -e "${RED}ERROR: key file $src is group/other-readable (mode $mode); tighten to 0600 first${NC}" >&2
        return 1
    fi
    aiengine_ensure_config_dir
    local dest="$AIENGINE_CONFIG_DIR/aiengine-key"
    local tmp="${dest}.new.$$"
    cp -f "$src" "$tmp" || { rm -f "$tmp"; return 1; }
    chmod 0600 "$tmp" 2>/dev/null || true
    chown root:root "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$dest"
}

cmd_addon_aiengine_enable() {
    aiengine_require_root enable || return 1
    local tag="" key_file=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --key-file) key_file="$2"; shift 2 ;;
            --tag) tag="$2"; shift 2 ;;
            *) echo "Unknown enable option: $1" >&2; return 1 ;;
        esac
    done
    aiengine_lock || return 1
    aiengine_require_amd64 || return 1

    aiengine_with_support_lock cmd_addon_aiengine_enable_locked "$tag" "$key_file"
}

cmd_addon_aiengine_enable_locked() {
    local tag="$1" key_file="$2"

    [[ -f "$INSTALL_DIR/$AIENGINE_OVERLAY" ]] || {
        echo -e "${RED}ERROR: $AIENGINE_OVERLAY not on disk; run 'nvr update' first to receive the overlay${NC}" >&2
        return 1
    }

    # prepare idempotently (pull, config dir, device-id).
    local eff_tag="${tag:-$(sed -n 's/^AIENGINE_TAG=//p' "$AIENGINE_ENV_FILE" 2>/dev/null | head -1)}"
    eff_tag="${eff_tag:-stable}"
    env_write_var AIENGINE_TAG "$eff_tag" || return 1
    aiengine_pull || echo -e "${YELLOW}WARN: image pull failed${NC}" >&2
    aiengine_ensure_config_dir
    aiengine_create_device_id "$eff_tag" || true

    if [[ -n "$key_file" ]]; then
        aiengine_install_key "$key_file" || return 1
    fi

    # The key is required. The device-id FILE is optional: a legacy image
    # without the make-device-id helper regenerates the id from hardware at
    # start, so the licensed pair still matches without a stored file.
    [[ -f "$AIENGINE_CONFIG_DIR/aiengine-key" ]] || { echo -e "${RED}ERROR: aiengine-key missing; pass --key-file PATH${NC}" >&2; return 1; }
    [[ -f "$AIENGINE_CONFIG_DIR/device-id" ]] || echo -e "${YELLOW}NOTE: no device-id file; the image will regenerate it from hardware at start.${NC}" >&2

    # Transactional first enable: capture prior compose/intent to roll back if
    # the container never comes up healthy.
    local prev_compose prev_intent
    prev_compose=$(compose_file_get)
    prev_intent=$(aiengine_intent_read)

    aiengine_intent_write enabled || return 1
    compose_overlay_reconcile present || return 1

    echo "Starting aiengine ..."
    # `up -d` returns 0 as soon as the container is CREATED, even if it then
    # crash-loops OR stays up but never loads its key/model (unhealthy). Wait for
    # the healthcheck to RESOLVE before committing intent, and roll back on
    # unhealthy/exited/timeout so a broken first enable never leaves
    # intent=enabled to retry every boot. Accept a container with NO healthcheck
    # once it is Running (the external image may not ship the probe yet). The
    # bound (~180s) exceeds the overlay's 120s start_period. v1-compatible poll
    # (no `--wait`).
    local _i=0 _running="" _health="" _ok=0
    if $COMPOSE up -d "$AIENGINE_SERVICE"; then
        while [[ $_i -lt 90 ]]; do
            _running=$(docker inspect -f '{{.State.Running}}' "$AIENGINE_SERVICE" 2>/dev/null || true)
            [[ "$_running" == "true" ]] || { _ok=0; break; }   # exited -> fail
            _health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AIENGINE_SERVICE" 2>/dev/null || true)
            case "$_health" in
                healthy|none) _ok=1; break ;;
                unhealthy)    _ok=0; break ;;
                *)            : ;;              # starting -> keep waiting
            esac
            sleep 2; _i=$((_i+1))
        done
    fi
    if [[ $_ok -ne 1 ]]; then
        echo -e "${RED}ERROR: aiengine did not become healthy (state=${_running:-gone} health=${_health:-n/a}); rolling back${NC}" >&2
        docker logs --tail 20 "$AIENGINE_SERVICE" 2>&1 | head -20 || true
        env_write_var COMPOSE_FILE "$prev_compose" || true
        aiengine_intent_write "$prev_intent" || true
        $COMPOSE rm -fs "$AIENGINE_SERVICE" >/dev/null 2>&1 || true
        return 1
    fi
    echo "aiengine enabled (tag $eff_tag). Configure an 'AI Engine' Device at 127.0.0.1:88 in dview LPR/object setup."
}

cmd_addon_aiengine_disable() {
    aiengine_require_root disable || return 1
    local force=0
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --force) force=1; shift ;;
            *) echo "Unknown disable option: $1" >&2; return 1 ;;
        esac
    done
    aiengine_lock || return 1

    aiengine_with_support_lock cmd_addon_aiengine_disable_locked "$force"
}

cmd_addon_aiengine_disable_locked() {
    local force="$1"

    local intent; intent=$(aiengine_intent_read)
    if [[ $force -ne 1 ]]; then
        local ids; ids=$(aiengine_local_camera_ids)
        if [[ -n "$ids" ]]; then
            echo -e "${YELLOW}Refusing: local LPR/object cameras still use this engine: $(echo "$ids" | tr '\n' ' ')${NC}" >&2
            echo "Inbound clients on OTHER NVRs cannot be detected locally. Re-run with --force to disable anyway." >&2
            return 1
        fi
        if [[ "$intent" == "legacy-provisioned" ]]; then
            echo -e "${YELLOW}Refusing: intent is legacy-provisioned (a licensed install another NVR may use). Re-run with --force.${NC}" >&2
            return 1
        fi
    fi

    aiengine_intent_write disabled || return 1
    compose_overlay_reconcile absent || return 1
    # Drop the container; --remove-orphans clears it now that the overlay is
    # out of COMPOSE_FILE. Customer DB rows are never touched.
    $COMPOSE up -d --remove-orphans >/dev/null 2>&1 || true
    $COMPOSE rm -fs "$AIENGINE_SERVICE" >/dev/null 2>&1 || true
    # Identity (device-id, aiengine-key, tag) is preserved for a safe re-enable.
    echo "aiengine disabled. Credential identity preserved."
    local ids; ids=$(aiengine_local_camera_ids)
    [[ -n "$ids" ]] && echo -e "${YELLOW}NOTE: local type-61 Device rows remain; recorder LPR fallback can still select a stopped endpoint.${NC}"
}

cmd_addon_aiengine_status() {
    local intent overlay
    intent=$(aiengine_intent_read)
    overlay="absent"; compose_overlay_present && overlay="present"

    echo -e "${BLUE}=== aiengine add-on ===${NC}"
    echo "Intent:        $intent"
    echo "Overlay:       $overlay in COMPOSE_FILE"
    local tag pinned
    tag=$(sed -n 's/^AIENGINE_TAG=//p' "$AIENGINE_ENV_FILE" 2>/dev/null | head -1)
    pinned=$(sed -n 's/^AIENGINE_IMAGE=//p' "$AIENGINE_ENV_FILE" 2>/dev/null | head -1)
    echo "Requested tag: ${tag:-<unset, defaults to stable>}"
    [[ -n "$pinned" ]] && echo "Pinned image:  $pinned (digest pin from migration)"

    # Image + container state, best-effort.
    local img_id digest
    img_id=$(docker image inspect --format '{{.Id}}' "${AIENGINE_REGISTRY:-docker.io}/dividia/aiengine:${tag:-stable}" 2>/dev/null) || img_id=""
    digest=$(docker image inspect --format '{{join .RepoDigests ","}}' "${AIENGINE_REGISTRY:-docker.io}/dividia/aiengine:${tag:-stable}" 2>/dev/null) || digest=""
    echo "Image ID:      ${img_id:-<not present locally>}"
    [[ -n "$digest" ]] && echo "Digest:        $digest"

    local cstate health restarts
    cstate=$(docker inspect --format '{{.State.Status}}' "$AIENGINE_SERVICE" 2>/dev/null) || cstate="(no container)"
    health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$AIENGINE_SERVICE" 2>/dev/null) || health="n/a"
    restarts=$(docker inspect --format '{{.RestartCount}}' "$AIENGINE_SERVICE" 2>/dev/null) || restarts="?"
    echo "Container:     $cstate (health $health, restarts $restarts)"

    # Credential presence + mode ONLY — never the key contents or hash.
    local did_mode key_mode
    did_mode=$(stat -c '%a' "$AIENGINE_CONFIG_DIR/device-id" 2>/dev/null || echo "absent")
    key_mode=$(stat -c '%a' "$AIENGINE_CONFIG_DIR/aiengine-key" 2>/dev/null || echo "absent")
    echo "device-id:     $did_mode"
    echo "aiengine-key:  $key_mode"

    # Direct local demand.
    local ids; ids=$(aiengine_local_camera_ids)
    if [[ -n "$ids" ]]; then
        local count; count=$(echo "$ids" | grep -c .)
        echo "Local cameras: $(echo "$ids" | tr '\n' ' ')(count $count)"
        [[ "$count" -gt 2 ]] && echo -e "${YELLOW}NOTE: more than two local cameras; the CPU image targets at most two.${NC}"
    else
        echo "Local cameras: none detected"
    fi
    echo "(Remote AI Engine / Jetson endpoints are unchanged and not managed here.)"
}

cmd_help() {
    # Quoted heredoc terminator (<<'EOF') disables variable expansion AND
    # command substitution inside the body. The line below referencing
    # `nvr update` in backticks would otherwise be evaluated as a command
    # substitution by bash on every `nvr help` invocation, recursively
    # invoking `nvr update` (= docker compose pull/up) for every help-text
    # render. Verified via `bash -x /opt/dividia/nvr help` showing
    # `++ nvr update` mid-cat. Don't drop the quotes again.
    cat <<'EOF'
NVR Docker Management CLI

Usage: nvr <command> [args...]

Commands:
  status              Show service status and configuration
  logs [svc] [-f]     View logs (pass-through to docker compose logs)
  update              Pull latest images, extract files, and converge services
  update --activate-now
                      Run the staged activation without the normal second pass
  activation          Show or release a staged activation hold: status | release
  backup              Backup NVR to VideoStore
  channel [name]      Show or set update channel (setter gated on Windows
                      via admin-key passphrase; open on Linux)
  db [args...]        Connect to MariaDB (dtech database)
  name                Show this NVR's name (like the legacy server_name)
  camera              Swap or slide camera numbers: swap A B | slide A B | restart
  videostore          Manage removable groups: status, configure, prepare-remove, mount
  start               Start all services
  stop                Stop all services
  restart [svc]       Restart all or specific service
  heal                Recover down/corrupt AND running-but-unhealthy containers.
                      Runs from cron every few minutes;
                      run by hand to force a tick. Recreates one service at a
                      time with --no-deps, so recording is never bounced to fix
                      another service. NOTE: per-service manual `docker stop` of
                      a core service is NOT supported (heal restarts it). Take a
                      service down with a `dividia.heal=false` label, or the
                      whole stack down with `nvr stop`.
  version             Show image version labels
  display [action]    Manage optional CO6/CO7 host dview: enable, disable, status
  cloudapi-profile    Show or select the prod/staging cloud trust profile
  shell [svc]         Open a shell in a container
  find [options]      Scan local network for hosts (vendor + dup detection)
  prune               Reclaim disk from unused images >7 days old
                      (also runs automatically at the end of `nvr update`)
  addon aiengine ...  Manage the optional local aiengine add-on
                      (prepare | enable | disable | status)
  migrate-scalewatcher <zip> [--dry-run]
                      Import legacy 2014 Scale Watcher export (see runbook)
  addon hme <action>  Manage the optional HME drive-thru timer container:
                      enable | disable [--force] | auto | status
  boot-storage        Verify or adopt a pre-Docker VideoStore bind:
                      status | adopt SOURCE TARGET
  help                Show this help message

Examples:
  nvr status
  nvr logs backend -f --tail=50
  nvr update
  nvr activation status
  nvr backup
  nvr channel dev
  nvr channel dev-smartrec       # per-branch test channel
  sudo nvr display enable        # preflight + enable local host dview on CO6/CO7
  sudo nvr display disable
  nvr cloudapi-profile show
  sudo nvr cloudapi-profile staging
  sudo nvr cloudapi-profile prod
  nvr boot-storage status
  sudo nvr boot-storage adopt /videostore/vs1/.dividia-opt /opt
  nvr db -e "SELECT COUNT(*) FROM Camera"
  nvr name
  nvr videostore status
  nvr videostore prepare-remove enclosure-1
  nvr videostore mount enclosure-1
  nvr restart engine
  nvr shell backend
  nvr find
  nvr find -i eth0
  nvr find -c 192.168.0.0/24
  nvr prune                                          # reclaim disk now
  nvr migrate-scalewatcher /videostore/vs1/migrate-staging/scalewatcher-backup.zip --dry-run
  sudo nvr addon hme enable       # provision + start the HME drive-thru timer
  sudo nvr addon hme disable      # stop it and remove DeviceType 64 (orphan-guarded)
  nvr addon hme status

EOF
}

################################################################################
# HME drive-thru addon
#
# Converts the retired hme-stream RPM (+ init/systemd unit + watchprog cron)
# into an optional per-system compose service. Three separate signals, kept
# distinct on purpose:
#
#   1. DeviceType 64 'HME Stream' row  — provisioning marker "this box supports
#      HME". Seeded by `enable` (idempotent, name-gated), removed by `disable`.
#      Drives dview's camera-type dropdown, so it must exist before a tech can
#      add an HME camera.
#   2. Device (bType 64) row           — the configured camera + store ID. The
#      run trigger and the conf source (ip/port/storeid).
#   3. Host marker (HME_MARKER_FILE)   — short-term operator override.
#
# Precedence for whether the container runs: marker=disabled > marker=enabled >
# DB derive (a Device 64 exists) > off. Reconcile is FAIL-OPEN: any DB error
# keeps the prior state, and an auto-mode turn-off is debounced across two
# consecutive successful "absent" reads. Reconcile NEVER deletes DB rows.
################################################################################

# --- host marker + debounce state ------------------------------------------

hme_get_marker() {
    # Echoes enabled|disabled|"" (empty == auto).
    [[ -f "$HME_MARKER_FILE" ]] || return 0
    head -n1 "$HME_MARKER_FILE" 2>/dev/null | tr -d '[:space:]'
}

hme_set_marker() {
    # $1 = enabled|disabled|auto
    mkdir -p "$HME_STATE_DIR" 2>/dev/null || true
    case "$1" in
        enabled|disabled) printf '%s\n' "$1" > "$HME_MARKER_FILE" ;;
        auto)             rm -f "$HME_MARKER_FILE" 2>/dev/null || true ;;
    esac
}

hme_get_streak() {
    local s=0
    [[ -f "$HME_ABSENT_STREAK_FILE" ]] && s=$(tr -cd '0-9' < "$HME_ABSENT_STREAK_FILE" 2>/dev/null)
    [[ "$s" =~ ^[0-9]+$ ]] && echo "$s" || echo 0
}

hme_set_streak() {
    mkdir -p "$HME_STATE_DIR" 2>/dev/null || true
    printf '%s\n' "${1:-0}" > "$HME_ABSENT_STREAK_FILE" 2>/dev/null || true
}

hme_record_error() {
    mkdir -p "$HME_STATE_DIR" 2>/dev/null || true
    printf '%s\n' "$1" > "$HME_LAST_ERROR_FILE" 2>/dev/null || true
    echo -e "${YELLOW}HME reconcile: $1${NC}" >&2
}

hme_clear_error() { rm -f "$HME_LAST_ERROR_FILE" 2>/dev/null || true; }

# --- DB signals -------------------------------------------------------------

# Echo the winning HME Device row as "sIP<TAB>bPort<TAB>sPath"; return mariadb's
# exit code (non-zero == DB unreadable, which the caller treats as fail-open).
# ORDER BY D.bID picks the lowest-bID Device deterministically when a box has
# more than one (the retired launcher took "whatever row order returned").
hme_query_device() {
    local db_pass
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" -N -B dtech \
        -e "SELECT D.sIP, D.bPort, D.sPath FROM Device D, DeviceType T WHERE D.bType = T.bID AND T.sName = 'HME Stream' ORDER BY D.bID LIMIT 1" 2>/dev/null
}

# Seed DeviceType 64 'HME Stream' — idempotent + name-gated, mirroring the
# retired RPM %post. INSERT only when bID 64 is ABSENT, so it never overwrites a
# pre-2020 'Object Engine' 64 on a field box and never rewrites an existing HME
# row's sPath. The 5 explicit columns match the RPM; the rest take their
# dtech.sql defaults, so the row is identical to existing HME field boxes.
hme_seed_devicetype() {
    local db_pass
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" dtech -e "
        INSERT INTO DeviceType (bID, sName, sPath, bNumDevice, bNumInput)
        SELECT 64, 'HME Stream', 'rtsp://localhost:8554/timer', 1, 1 FROM DUAL
        WHERE NOT EXISTS (SELECT 1 FROM DeviceType WHERE bID = 64);" 2>/dev/null
}

# Remove DeviceType 64, ORPHAN-GUARDED. If a live Device of bType 64 still
# exists, refuse (deleting the type would strand a live camera — the hazard
# test_update_integration.py guards) unless $1 == --force, which deletes the
# Device rows first. Returns non-zero when it refused OR when the count read
# failed (fail-SAFE: an unreadable count must never be treated as zero).
hme_remove_devicetype() {
    local force="${1:-}" db_pass count
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    count=$($COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" -N -B dtech \
        -e "SELECT COUNT(*) FROM Device WHERE bType = 64" 2>/dev/null | tr -cd '0-9')
    if [[ -z "$count" ]]; then
        echo -e "${YELLOW}WARN: could not read Device count (db down?); leaving DeviceType 64 in place${NC}" >&2
        return 1
    fi
    if [[ "$count" -gt 0 && "$force" != "--force" ]]; then
        echo -e "${RED}Refusing to remove DeviceType 64: $count live 'HME Stream' Device(s) still exist.${NC}" >&2
        echo    "Remove the HME camera(s) in dview Setup first, or re-run with --force to delete them." >&2
        return 1
    fi
    if [[ "$count" -gt 0 ]]; then
        # Delete the Device rows FIRST, and only proceed to drop the type if that
        # succeeded — otherwise a failed Device delete + a successful type delete
        # would strand a live Device 64 on a missing type (the exact orphan the
        # guard above exists to prevent) while reporting success.
        if ! $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" dtech \
            -e "DELETE FROM Device WHERE bType = 64" 2>/dev/null; then
            echo -e "${YELLOW}WARN: failed to delete Device 64 rows; leaving DeviceType 64 in place${NC}" >&2
            return 1
        fi
    fi
    if ! $COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" dtech \
        -e "DELETE FROM DeviceType WHERE bID = 64" 2>/dev/null; then
        echo -e "${YELLOW}WARN: failed to delete DeviceType 64${NC}" >&2
        return 1
    fi
    return 0
}

# --- pure decision ----------------------------------------------------------

# Given the host marker, the DB query exit code, whether a Device is present,
# and the current debounce streak, decide the desired container state WITHOUT
# touching anything. Echoes "<state> <new_streak>" where state is on|off|keep.
# Debounce applies ONLY to auto-mode turn-off (two consecutive successful
# "absent" reads); marker=disabled turns off immediately.
hme_decide() {
    local marker="$1" query_rc="$2" device_present="$3" streak="$4"
    # Pinning resets the streak: a stale count left from before a pin must not
    # shorten the auto-off debounce below two reads after a later return to auto.
    case "$marker" in
        disabled) echo "off 0"; return 0 ;;
        enabled)  echo "on 0";  return 0 ;;
    esac
    # auto (marker empty)
    if [[ "$query_rc" -ne 0 ]]; then
        # DB error: FAIL-OPEN. Reset the streak — an unreadable DB is not a
        # "successful absent read", so it breaks the consecutive chain and a
        # flaky DB can never auto-off HME.
        echo "keep 0"; return 0
    fi
    if [[ "$device_present" -eq 1 ]]; then
        echo "on 0"; return 0                  # device present: on, reset streak
    fi
    streak=$(( streak + 1 ))                    # absent + query OK: debounce
    if [[ "$streak" -ge 2 ]]; then
        echo "off $streak"; return 0
    fi
    echo "keep $streak"; return 0               # first absent read: hold
}

# --- conf + overlay ---------------------------------------------------------

# Generate data/config/hme-stream.conf from a Device row ("sIP<TAB>bPort<TAB>sPath")
# and write it atomically IF the content changed (sets HME_CONF_CHANGED=1).
# Mirrors the legacy start-hme-stream conf format. Returns non-zero WITHOUT
# creating the file if the row lacks a usable ip/port, so the caller never
# enables the overlay against a broken conf (Docker would auto-create a
# directory at the read-only bind path).
hme_write_conf() {
    local row="$1" ip port storeid new
    ip=$(printf '%s' "$row" | cut -f1)
    port=$(printf '%s' "$row" | cut -f2)
    storeid=$(printf '%s' "$row" | cut -f3)
    # mariadb -N prints a literal "NULL" for SQL NULL.
    [[ "$storeid" == "NULL" ]] && storeid=""
    if [[ -z "$ip" || "$ip" == "NULL" || -z "$port" || "$port" == "NULL" ]]; then
        return 1
    fi
    new=$(printf '[server]\nip = %s\nport = %s\nstoreid = %s\ndebug = off' "$ip" "$port" "$storeid")
    if [[ "$new" == "$(cat "$HME_CONF_PATH" 2>/dev/null || true)" ]]; then
        return 0    # unchanged
    fi
    mkdir -p "$(dirname "$HME_CONF_PATH")" 2>/dev/null || true
    local tmp="${HME_CONF_PATH}.new.$$"
    printf '%s\n' "$new" > "$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
    chmod 640 "$tmp" 2>/dev/null || true
    chown dividia:docker "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$HME_CONF_PATH" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
    HME_CONF_CHANGED=1
    return 0
}

# Add ($1=on) or drop ($1=off) docker-compose.hme.yml in COMPOSE_FILE. Keeps the
# base file and any OS overlay first, then HME. A configured boot-storage mask
# remains last. Idempotent; rewrites .env atomically.
hme_set_overlay() {
    local want="$1" env_file="$INSTALL_DIR/.env"
    if update_source_guard_is_active; then
        echo "ERROR: finish the pending nvr update before changing the HME overlay" >&2
        return 1
    fi
    [[ -f "$env_file" ]] || return 0
    local current rebuilt="" part
    current=$(grep -E '^COMPOSE_FILE=' "$env_file" 2>/dev/null | head -1)
    current="${current#COMPOSE_FILE=}"
    [[ -z "$current" ]] && current="docker-compose.yml"
    local IFS_save="$IFS" ; IFS=':'
    local -a parts=($current)
    IFS="$IFS_save"
    for part in "${parts[@]}"; do
        [[ -z "$part" || "$part" == "$HME_OVERLAY_FILE" || "$part" == "$BOOT_STORAGE_COMPOSE_OVERLAY" ]] && continue
        if [[ -z "$rebuilt" ]]; then rebuilt="$part"; else rebuilt="$rebuilt:$part"; fi
    done
    [[ "$want" == "on" ]] && rebuilt="${rebuilt:+$rebuilt:}$HME_OVERLAY_FILE"
    # (autoheal overlay removed entirely; nothing to preserve here.) The hme
    # token still lands last-before-boot-storage.
    [[ -e "$BOOT_STORAGE_CONFIG" ]] && rebuilt="${rebuilt:+$rebuilt:}$BOOT_STORAGE_COMPOSE_OVERLAY"
    [[ "$rebuilt" == "$current" ]] && return 0
    local tmp="${env_file}.new.$$"
    if grep -qE '^COMPOSE_FILE=' "$env_file"; then
        sed "s|^COMPOSE_FILE=.*|COMPOSE_FILE=$rebuilt|" "$env_file" > "$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
    else
        { cat "$env_file"; echo "COMPOSE_FILE=$rebuilt"; } > "$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
    fi
    chmod 640 "$tmp" 2>/dev/null || true
    chown dividia:docker "$tmp" 2>/dev/null || true
    mv -f "$tmp" "$env_file" || { rm -f "$tmp" 2>/dev/null || true; return 1; }
    return 0
}

hme_overlay_is_on() {
    grep -qE "^COMPOSE_FILE=.*${HME_OVERLAY_FILE//./\\.}" "$INSTALL_DIR/.env" 2>/dev/null
}

hme_container_running() {
    docker inspect -f '{{.State.Running}}' hme-stream 2>/dev/null | grep -q true
}

hme_stack_is_up() {
    # True if any CORE dividia-nvr container is running, i.e. the stack was not
    # intentionally stopped (`nvr stop`) and is past boot (`nvr start`). Used to
    # stop the 2-minute cron from resurrecting hme against an operator stop.
    # `grep -qvx hme-stream`: succeeds only if some running project container is
    # NOT hme itself (so a lone hme container does not count as "stack up").
    docker ps --filter label=com.docker.compose.project=dividia-nvr \
        --filter status=running --format '{{.Names}}' 2>/dev/null \
        | grep -qvx 'hme-stream'
}

hme_container_is_managed() {
    # True only when the hme-stream container is OUR compose service (carries the
    # project label), NOT a leftover foreign `docker run --name hme-stream` from
    # the RPM init or the pre-addon restore path.
    [[ "$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' hme-stream 2>/dev/null || true)" == "dividia-nvr" ]]
}

# Resolve the hme image ref the overlay would pull (must match the
# ${HME_STREAM_IMAGE}/${HME_STREAM_TAG} pins in docker-compose.hme.yml). hme-stream
# is versioned INDEPENDENTLY of the NVR ${CHANNEL} (built out of band in its own
# repo, like aiengine), so this resolves its own tag (default :latest), never the
# NVR channel. Used to prove the image is present BEFORE evicting a working
# foreign container.
hme_image_ref() {
    local img reg tag
    img=$(grep '^HME_STREAM_IMAGE=' .env 2>/dev/null | cut -d= -f2- | tr -d '[:space:]')
    if [[ -n "$img" ]]; then echo "$img"; return 0; fi
    reg=$(grep '^HME_STREAM_REGISTRY=' .env 2>/dev/null | cut -d= -f2- | tr -d '[:space:]'); reg=${reg:-docker.io}
    tag=$(grep '^HME_STREAM_TAG=' .env 2>/dev/null | cut -d= -f2- | tr -d '[:space:]'); tag=${tag:-latest}
    echo "${reg}/dividia/hme-stream:${tag}"
}

# Fully REMOVE the legacy RPM hme-stream launcher once the addon takes over, so
# no leftover init/systemd unit or watchprog entry tries to (re)start a foreign
# container that the addon has replaced — including "start an image that has been
# pruned". Masking is not enough; the package files must go (the unit, the
# watchprog conf, and /usr/local/bin/start-hme-stream all belong to the RPM).
#
# CRITICAL: use `rpm -e --noscripts`. The RPM's %preun does `docker rm -f
# hme-stream` + `docker rmi dividia/hme-stream:*`, which would kill the MANAGED
# compose container and delete the image the addon just pulled. It does NOT touch
# DeviceType 64, so removing the package with --noscripts preserves the row the
# addon relies on. Best-effort + root-only; a non-RPM host (Ubuntu) has no
# `rpm`/hme-stream and this no-ops.
hme_remove_legacy_rpm() {
    # Stop + de-register the running unit first, while its script still exists.
    if command -v systemctl >/dev/null 2>&1 && systemctl cat hme-stream.service >/dev/null 2>&1; then
        systemctl stop hme-stream 2>/dev/null || true
        systemctl disable hme-stream 2>/dev/null || true
    fi
    if [[ -x /etc/init.d/hme-stream ]]; then
        service hme-stream stop 2>/dev/null || true
        chkconfig hme-stream off 2>/dev/null || true
    fi
    # Remove the package WITHOUT its destructive %preun.
    if command -v rpm >/dev/null 2>&1 && rpm -q hme-stream >/dev/null 2>&1; then
        rpm -e --noscripts hme-stream 2>/dev/null || true
    fi
    # Belt-and-suspenders for a box whose RPM db no longer owns these (botched
    # prior removal): drop the launcher + watchprog entry directly.
    rm -f /etc/watchprog.conf.d/hme-stream.conf /etc/watchprog.d/hme-stream.conf \
          /usr/local/bin/start-hme-stream 2>/dev/null || true
}

# --- reconcile --------------------------------------------------------------

# Converge HME HOST state (COMPOSE_FILE + conf + DeviceType seed) WITHOUT
# pulling or starting containers. Shared by cmd_update (which runs its own
# compose up afterward) and hme_reconcile (which adds the compose up). Records
# HME_DESIRED and HME_CONF_CHANGED for the caller, and any error for `status`.
# Always returns 0 — a reconcile must never abort an update.
hme_reconcile_config() {
    # "--refresh-image" is passed ONLY by the nightly `nvr update` path so the
    # addon auto-updates like the core services. The 2-minute reconcile cron
    # (`nvr addon hme reconcile`) passes nothing and keeps the cheap local check,
    # so it never does a per-cron network pull (~720/day/box, Docker Hub rate
    # limits) — that regression is exactly what the local-first check below avoids.
    local refresh_image="${1:-}"
    HME_CONF_CHANGED=0
    HME_DESIRED=""
    if update_source_guard_is_active; then
        HME_DESIRED="keep"
        hme_record_error "pending nvr update keeps the guarded source HME configuration"
        return 0
    fi
    local marker streak rc=0 row present=0 decision new_streak
    marker=$(hme_get_marker)
    streak=$(hme_get_streak)
    row=$(hme_query_device) || rc=$?
    [[ $rc -eq 0 && -n "$row" ]] && present=1
    read -r decision new_streak <<< "$(hme_decide "$marker" "$rc" "$present" "$streak")"
    hme_set_streak "$new_streak"
    HME_DESIRED="$decision"

    case "$decision" in
        keep)
            [[ $rc -ne 0 ]] && hme_record_error "db query failed (rc=$rc); kept prior HME state"
            return 0 ;;
        off)
            hme_set_overlay off
            hme_clear_error
            return 0 ;;
    esac

    # decision == on
    if [[ "$marker" == "enabled" ]]; then
        hme_seed_devicetype >/dev/null 2>&1 || true
    fi
    if [[ $present -eq 1 ]]; then
        if ! hme_write_conf "$row"; then
            # Fail-SAFE: never enable the overlay against a missing/broken conf.
            # HME_DESIRED=keep so hme_reconcile leaves any running container on
            # its last-good conf rather than tearing it down over a bad read.
            hme_record_error "conf generation failed (bad Device row); keeping prior state"
            HME_DESIRED="keep"
            return 0
        fi
    elif [[ ! -f "$HME_CONF_PATH" ]]; then
        # Pinned on (marker=enabled) but no Device 64 and no prior conf: nothing
        # to stream. Hold OFF rather than crash-loop the container on empty conf,
        # and reflect that in HME_DESIRED so hme_reconcile does not try to start it.
        hme_record_error "pinned on but no Device 64 configured yet; waiting"
        hme_set_overlay off
        HME_DESIRED="off"
        return 0
    fi
    # Prove the managed image is AVAILABLE before enabling the overlay. This
    # single gate protects two things:
    #   1. It never evicts a working foreign container (below) for an image that
    #      is not here — otherwise a missing/bad ${CHANNEL} tag or a registry blip
    #      takes HME dark with no fallback (a Docker-only box has no RPM to
    #      restore), and because this fires unattended on every HME box's nightly
    #      update it would be a synchronized fleet-wide outage.
    #   2. It never lets the hme overlay onto COMPOSE_FILE for an unpullable tag,
    #      which would make the core `nvr update` compose-up fail on the hme pull
    #      and skip its post-success steps (watchtower handoff, dview refresh).
    #      This is the real path when the independent hme-stream tag is not yet
    #      published (a pinned ${HME_STREAM_TAG} that hasn't shipped, or the
    #      out-of-band hme publish lagging a core release).
    local img; img=$(hme_image_ref)
    if [[ "$refresh_image" == "--refresh-image" ]]; then
        # Nightly `nvr update`: PULL FIRST so a moved hme tag is fetched
        # (the by-name core `up -d hme` below then recreates onto the new image),
        # auto-updating like the core services. Fail-soft: if the pull fails
        # (registry blip / lagging tag) fall back to any LOCAL copy so HME never
        # goes dark (protection #1). Only truly unavailable (pull failed AND no
        # local image) keeps prior state (protection #2: no overlay for an
        # unpullable+absent tag).
        if ! docker pull "$img" >/dev/null 2>&1 && ! docker image inspect "$img" >/dev/null 2>&1; then
            hme_record_error "managed hme image $img unavailable (pull failed, no local copy); not enabling (kept prior state)"
            HME_DESIRED="keep"
            return 0
        fi
    elif ! { docker image inspect "$img" >/dev/null 2>&1 || docker pull "$img" >/dev/null 2>&1; }; then
        # 2-minute reconcile cron / enable / boot: cheap local check first, pull
        # only when the image is absent (first install / channel switch). Same
        # two protections; no per-cron network pull.
        hme_record_error "managed hme image $img unavailable; not enabling (kept prior state)"
        HME_DESIRED="keep"
        return 0
    fi

    # Takeover: now that the image is local, evict a leftover FOREIGN or STOPPED
    # hme-stream container (RPM init / pre-addon `docker run`) that holds the name
    # + :8554. Gate on EXISTENCE, not running state: a stopped container still
    # owns the name.
    if docker container inspect hme-stream >/dev/null 2>&1 && ! hme_container_is_managed; then
        docker rm -f hme-stream >/dev/null 2>&1 || true
    fi
    # Remove the legacy RPM launcher entirely so nothing tries to restart it.
    # Run it whenever HME is being enabled, NOT only on the foreign-eviction path,
    # because the migration cutover already `docker rm -f`'s the foreign container
    # before it calls `nvr addon hme enable` — so gating on the container would
    # skip RPM removal during a fresh migration. Self-gates on `rpm -q`; a no-op
    # once the package is gone or on a non-RPM host.
    hme_remove_legacy_rpm
    hme_set_overlay on
    hme_clear_error
    return 0
}

# Full reconcile: converge host state, then converge the container — but only
# act on the container when reality differs from the desired state, so the
# 2-minute cron is a cheap DB-read poll in steady state (no compose churn).
# Serialized on its own lock (FD 8) so the cron and an operator command cannot
# overlap; also DEFERS to an in-progress `nvr update` (which converges HME
# itself) so the cron's compose call can't race the update's and disturb a core
# service. Its on-path `up` is scoped to the hme service alone, never the whole
# project, so even a residual race touches only hme.
hme_reconcile() {
    # $1 == --from-cron marks the unattended 2-minute run: it must not resurrect
    # hme against an operator `nvr stop` / at boot before `nvr start`. An explicit
    # enable/auto is a deliberate "on now" and skips that guard.
    local from_cron="${1:-}"
    if [[ $EUID -eq 0 ]]; then
        # Probe the nvr-update lock WITHOUT holding it (subshell releases it on
        # exit), so we never make a real update skip. If an update holds it,
        # skip — it reconciles HME via hme_reconcile_config.
        if ! ( exec 9>"$NVR_LIFECYCLE_LOCK" 2>/dev/null && flock -n 9 ) 2>/dev/null; then
            echo "nvr update in progress; skipping HME reconcile"
            return 0
        fi
        exec 8>"$HME_LOCK" 2>/dev/null || true
        flock -n 8 || { echo "another HME reconcile in progress, skipping"; return 0; }
    fi
    hme_reconcile_config
    local running="no"; hme_container_running && running="yes"
    case "$HME_DESIRED" in
        on)
            if [[ "$running" == "no" ]]; then
                # Overlay now in COMPOSE_FILE. Scope to the hme service so this
                # never recreates core containers (the image is already local from
                # the availability gate, so this create is fast). Skip on the cron
                # path when the stack is intentionally down, so the poll does not
                # undo `nvr stop`.
                if [[ "$from_cron" != "--from-cron" ]] || hme_stack_is_up; then
                    $COMPOSE up -d hme || true
                fi
            elif [[ "${HME_CONF_CHANGED:-0}" -eq 1 ]]; then
                # A bind-mount content change does not recreate on its own.
                $COMPOSE up -d --force-recreate hme || true
            fi ;;
        off)
            # reconcile_config dropped the overlay from COMPOSE_FILE. Remove the
            # hme container directly BY NAME rather than a project-wide
            # `compose up --remove-orphans`: the latter reconciles the WHOLE
            # project, so it could recreate/disturb core containers and race a
            # concurrent `nvr update` (the exact race this cron's lock probe is
            # only best-effort against). `docker rm -f hme-stream` removes a
            # managed OR a pre-addon foreign `docker run --name hme-stream` alike.
            # Remove whenever the container EXISTS, running OR exited -- do NOT
            # gate on "$running". A crashed/stopped hme-stream left behind by a
            # disable would otherwise linger, and the Services dashboard (which
            # lists a container by presence, `all=True`) would render it as a
            # red, restartable "stopped" add-on and let "Restart All Unhealthy"
            # resurrect a service the operator deliberately disabled. The
            # `container inspect` guard makes this a no-op when nothing exists.
            docker container inspect hme-stream >/dev/null 2>&1 && docker rm -f hme-stream >/dev/null 2>&1 || true
            ;;
        keep)
            : ;;  # fail-open / debounce hold: touch nothing
    esac
    ensure_hme_reconcile_cron
}

# --- 2-minute reconcile cron ------------------------------------------------

hme_devicetype_exists() {
    # 0 = DeviceType 64 present, OR the DB is unreadable (fail-SAFE: a DB blip
    # must never deprovision the reconcile cron). 1 = DB readable and 64 absent.
    local db_pass out rc=0
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)
    out=$($COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" -N -B dtech \
        -e "SELECT 1 FROM DeviceType WHERE bID=64 LIMIT 1" 2>/dev/null) || rc=$?
    [[ $rc -ne 0 ]] && return 0
    [[ -n "$out" ]]
}

hme_is_provisioned() {
    # Reactive (cron-worthy) when pinned on, or in auto while the box is
    # HME-CAPABLE. "Capable" = the overlay is on OR DeviceType 64 exists (the
    # durable "installed with HME support" marker), so an auto box that auto-offed
    # KEEPS the 2-minute cron and re-enables within 2 min when a Device is
    # re-added — instead of going dark until the next nightly `nvr update`. A
    # pinned-OFF box is never reactive.
    local marker; marker=$(hme_get_marker)
    [[ "$marker" == "disabled" ]] && return 1
    [[ "$marker" == "enabled" ]] && return 0
    hme_overlay_is_on && return 0
    hme_devicetype_exists
}

_hme_remove_cron_file() {
    [[ -f "$HME_RECONCILE_CRON" ]] || return 0
    if [[ $EUID -ne 0 ]]; then
        sudo -n rm -f "$HME_RECONCILE_CRON" 2>/dev/null || true
    else
        rm -f "$HME_RECONCILE_CRON" 2>/dev/null || true
    fi
}

ensure_hme_reconcile_cron() {
    # Install the 2-minute reconcile cron only on HME-provisioned boxes so a
    # non-HME NVR never carries it; remove it once the box is pinned off /
    # deprovisioned. Matches the retired watchprog cadence (a cheap DB read +
    # idempotent converge). Owned here; do not edit by hand.
    if ! hme_is_provisioned; then
        _hme_remove_cron_file
        return 0
    fi
    local cron_body
    IFS= read -r -d '' cron_body <<'EOF' || true
# HME drive-thru reconcile for Dividia NVR.
#
# Every 2 minutes, converge the optional hme-stream container to the host
# marker + the Device (bType 64) signal, matching the retired watchprog
# cadence. Installed by `nvr addon hme enable`, removed by `disable`.
# Owned by /opt/dividia/nvr ensure_hme_reconcile_cron; do not edit by hand.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/2 * * * * root /opt/dividia/nvr addon hme reconcile >/var/log/dividia-nvr-hme.log 2>&1
EOF
    if [[ $EUID -ne 0 ]]; then
        if ! sudo -n true 2>/dev/null; then return 0; fi
        if [[ -f "$HME_RECONCILE_CRON" ]] && sudo cmp -s <(echo "$cron_body") "$HME_RECONCILE_CRON"; then return 0; fi
        echo "$cron_body" | sudo tee "$HME_RECONCILE_CRON" >/dev/null && sudo chmod 0644 "$HME_RECONCILE_CRON"
    else
        if [[ -f "$HME_RECONCILE_CRON" ]] && cmp -s <(echo "$cron_body") "$HME_RECONCILE_CRON"; then return 0; fi
        echo "$cron_body" > "$HME_RECONCILE_CRON" && chmod 0644 "$HME_RECONCILE_CRON"
    fi
}

# --- status -----------------------------------------------------------------

hme_status() {
    local marker db_pass
    marker=$(hme_get_marker); [[ -z "$marker" ]] && marker="auto"
    db_pass=$(grep '^MYSQL_ROOT_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)

    echo -e "${BLUE}=== HME addon ===${NC}"
    echo "Host intent (marker):   $marker"

    local dt
    dt=$($COMPOSE exec -T db mariadb -u root -p"${db_pass:-lynn1094}" -N -B dtech \
        -e "SELECT sName FROM DeviceType WHERE bID=64" 2>/dev/null | head -1)
    [[ -n "$dt" ]] && echo "DeviceType 64:          present ('$dt')" || echo "DeviceType 64:          absent"

    local rc=0 row
    row=$(hme_query_device) || rc=$?
    if [[ $rc -ne 0 ]]; then
        echo "Device (bType 64):      DB unreadable (fail-open: prior state kept)"
    elif [[ -n "$row" ]]; then
        echo "Device (bType 64):      configured (ip=$(printf '%s' "$row" | cut -f1) port=$(printf '%s' "$row" | cut -f2) storeid=$(printf '%s' "$row" | cut -f3))"
    else
        echo "Device (bType 64):      none configured"
    fi

    hme_overlay_is_on && echo "COMPOSE_FILE overlay:   on" || echo "COMPOSE_FILE overlay:   off"
    hme_container_running && echo "Container hme-stream:   running" || echo "Container hme-stream:   not running"

    if [[ -f "$HME_CONF_PATH" ]]; then
        local mt hash
        mt=$(stat -c '%y' "$HME_CONF_PATH" 2>/dev/null || stat -f '%Sm' "$HME_CONF_PATH" 2>/dev/null || echo '?')
        hash=$( { md5sum "$HME_CONF_PATH" 2>/dev/null || md5 -q "$HME_CONF_PATH" 2>/dev/null; } | awk '{print $1}')
        echo "Conf:                   $HME_CONF_PATH (mtime=$mt md5=$hash)"
    else
        echo "Conf:                   absent"
    fi

    local img
    img=$(docker inspect -f '{{.Config.Image}} {{.Image}}' hme-stream 2>/dev/null || true)
    [[ -n "$img" ]] && echo "Image:                  $img"

    local owner
    owner=$( { ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null; } | grep -E '[:.]8554[[:space:]]' | head -1 | sed 's/^[[:space:]]*//')
    [[ -n "$owner" ]] && echo "Port 8554:              $owner" || echo "Port 8554:              (unbound)"

    echo "Auto-disable streak:    $(hme_get_streak)/2"
    [[ -f "$HME_LAST_ERROR_FILE" ]] && echo "Last reconcile error:   $(head -1 "$HME_LAST_ERROR_FILE")" || echo "Last reconcile error:   (none)"
}

# --- command surface --------------------------------------------------------

cmd_addon_hme() {
    local action="${1:-status}"; shift 2>/dev/null || true
    # Mutating actions rewrite .env / markers, write the DB, manage the host cron,
    # and take the reconcile flock — all of which only work reliably as root, and
    # the flock is skipped for non-root (matching cmd_update). Enforce root so a
    # non-root `nvr addon hme enable` can't run lock-free and race the root cron.
    # `status` stays open (read-only). Matches the `sudo nvr addon hme ...` help.
    case "$action" in
        enable|disable|auto|reconcile)
            if [[ $EUID -ne 0 ]]; then
                echo "nvr addon hme $action must run as root (use: sudo nvr addon hme $action)" >&2
                exit 1
            fi ;;
    esac
    case "$action" in
        enable)
            # `enable` OWNS the DeviceType 64 seed (the only creator now the RPM
            # is gone) and reconciles immediately so a tech doesn't wait for the
            # 02:xx update cron before dview offers the camera type.
            hme_set_marker enabled
            hme_reconcile
            echo "HME enabled. dview now offers the 'HME Stream' camera type; add the Device in Setup."
            hme_status ;;
        disable)
            local force=""
            [[ "${1:-}" == "--force" ]] && force="--force"
            hme_set_marker disabled
            hme_reconcile   # drops overlay, removes container, frees :8554
            if hme_remove_devicetype "$force"; then
                echo "HME disabled; DeviceType 64 removed."
            else
                echo "HME disabled (container stopped); DeviceType 64 left in place — see the warning above."
            fi
            hme_status ;;
        auto)
            hme_set_marker auto
            hme_reconcile
            echo "HME set to auto (follows the Device bType 64 signal)."
            hme_status ;;
        reconcile)
            # Internal: driven by the 2-minute cron. --from-cron applies the
            # stack-up guard so the poll never resurrects hme against `nvr stop`.
            hme_reconcile --from-cron ;;
        status)
            hme_status ;;
        *)
            echo "Usage: nvr addon hme {enable|disable [--force]|auto|status}"; exit 1 ;;
    esac
}

################################################################################
# Privilege self-elevation
################################################################################
# Commands that write root-owned host state historically forced the operator to
# remember `sudo` (and `sudo nvr` failed command-not-found because sudo's
# secure_path omits /opt/dividia). These now re-exec themselves under sudo when
# not root; dividia has NOPASSWD sudo, so it is silent. Two entry points:
#
#   - Whole-command mutators with NO read-only subcommand (update/start/stop/
#     restart/ensure-*/boot-up) elevate up front, via command_needs_root below.
#   - Commands that have BOTH read and mutating subcommands (addon/display/
#     cloudapi-profile/boot-storage: their status/show/default paths only read)
#     elevate at the precise mutating call site, inside their existing
#     *_require_root helpers.
#     A read path never calls those, so `nvr addon aiengine status` and the
#     nvr-monitor read path never sudo.
#
# Docker-group commands (backup, prune, migrate-scalewatcher, db, shell) and the
# .env-only `channel` run as the caller by design: they write no root-owned host
# path, and elevating `channel` would rewrite .env as root and strip the dividia
# owner. NVR_NO_SUDO_REEXEC=1 opts out entirely.

should_reexec_root() {
    # Pure gate predicate (no exec) so it is unit-testable. 0 = should and can
    # elevate; 1 = must not / cannot (caller falls back to its own error).
    [[ $EUID -eq 0 ]] && return 1                       # already root
    [[ "${NVR_NO_SUDO_REEXEC:-}" == "1" ]] && return 1  # explicit opt-out
    [[ "$INSTALL_DIR" == "/opt/dividia" ]] || return 1  # dev/test install: never elevate
    command -v sudo >/dev/null 2>&1 || return 1         # no sudo -> caller errors plainly
    return 0
}

nvr_do_exec() {
    # The one process-replacing exec, isolated behind a function so a test
    # harness (which SOURCES this script) can redefine it to a recorder. There is
    # deliberately NO environment hook: production has no way to turn elevation
    # into a no-op.
    exec "$@"
}

nvr_try_elevate() {
    # Re-exec THIS invocation under sudo. Caller must have checked
    # should_reexec_root. Uses the ABSOLUTE canonical path, never $0: $0 is
    # argv[0] ("nvr"), which sudo would resolve via secure_path -- and CentOS 6/7
    # secure_path omits /usr/local/bin, so `sudo nvr` fails there; an absolute
    # path also removes any argv[0] symlink-swap vector. NVR_ARGV is the original
    # argv captured before the dispatch shifted it.
    echo "nvr: this command needs root; re-running with sudo..." >&2
    nvr_do_exec sudo "$INSTALL_DIR/nvr" "${NVR_ARGV[@]}"
}

command_needs_root() {
    # Whole-command mutators only (see block comment). Anything with a read-only
    # subcommand is handled at its *_require_root call site instead.
    case "$1" in
	 update|start|stop|restart|camera|backup|heal|ensure-host-config|ensure-boot-service|ensure-heal-cron|boot-up|ensure-videostore-mounts|recover-aiengine-support|prepare-customer-assets|block-customer-assets)
            return 0 ;;
        *) return 1 ;;
    esac
}

maybe_reexec_root() {
    command_needs_root "${1:-}" || return 0
    should_reexec_root || return 0
    nvr_try_elevate
}

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

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
NVR_ARGV=("$@")   # capture original argv BEFORE the case below shifts it
maybe_reexec_root "$@"
case "${1:-help}" in
    status)   shift; cmd_status "$@" ;;
    logs)     shift; cmd_logs "$@" ;;
    update)   shift; cmd_update_monitored "$@" ;;
    backup)   shift; cmd_backup "$@" ;;
    channel)  shift; cmd_channel "$@" ;;
    db)       shift; cmd_db "$@" ;;
    name)     shift; cmd_name "$@" ;;
    camera)   shift; cmd_camera "$@" ;;
    videostore) shift; cmd_videostore "$@" ;;
    start)    shift; cmd_start "$@" ;;
    stop)     shift; cmd_stop "$@" ;;
    restart)  shift; cmd_restart "$@" ;;
    heal)     shift; cmd_heal "$@" ;;
    version)  shift; cmd_version "$@" ;;
    display)  shift; cmd_display "$@" ;;
    remoted) shift; cmd_remoted "$@" ;;
    cloudapi-profile) shift; cmd_cloudapi_profile "$@" ;;
    shell)    shift; cmd_shell "$@" ;;
    vm-shell) shift; cmd_vm_shell "$@" ;;
    find)     shift; cmd_find "$@" ;;
    prune)    shift; cmd_prune "$@" ;;
    migrate-scalewatcher) shift; cmd_migrate_scalewatcher "$@" ;;
    addon)    shift; cmd_addon "$@" ;;
    activation) shift; cmd_activation "$@" ;;
    update-continuation) shift; cmd_update_continuation "$@" ;;
    boot-storage) shift; cmd_boot_storage "$@" ;;
    # Re-assert host-level config (sudoers, docker log cap, rc.local dvs block).
    # cmd_update calls this on every run; exposed as a subcommand so
    # install-nvr.sh's migration can trigger the SAME implementation instead of
    # carrying its own copy.
    ensure-host-config) shift; ensure_host_config "$@" ;;
    # Same shape as ensure-host-config: lets a tech converge one box by hand
    # (and lets the runbook name a command) without waiting for the update cron.
    ensure-boot-service) shift; ensure_boot_service "$@" ;;
    # Install/refresh the heal cron + logrotate by hand (ADR-100). cmd_update
    # also calls ensure_heal_cron on every run.
    ensure-heal-cron) shift; ensure_heal_cron "$@" ;;
    # Hidden: recompute COMPOSE_FILE from aiengine intent and strip AIENGINE_*
    # when the add-on is not fully present. The backup restore (dvs30.py) and
    # install-nvr.sh .env.save copy-back call this so a restored .env can never
    # implicitly re-enable a broken aiengine container. Not in help on purpose.
    normalize-addon-env) shift; aiengine_env_normalize "$@" ;;
    # Hidden: roll back a power-interrupted AI support-file restore before the
    # add-on can start with a partial model set.
    recover-aiengine-support) shift; aiengine_support_restore_recover "$@" ;;
    # Hidden: first-update compatibility handoff. The newly extracted installer
    # invokes this even while the prior nvr updater remains in memory.
    prepare-customer-assets) shift; prepare_customer_assets_for_compose "$@" ;;
    # Hidden: finish an inode-safe LPR file swap after Compose starts its
    # consumers. The durable marker makes a power-interrupted swap retryable.
    refresh-customer-assets) shift; lpr_templates_refresh_consumers_if_needed "$@" ;;
    # Hidden: make old in-memory updater Compose calls fail closed after a new
    # customer-asset handoff reports an error.
    block-customer-assets) shift; customer_assets_block_enable "$@" ;;
    # Hidden: reconciled boot bring-up for the CentOS 6 SysV init script and
    # migration finalize (core up by name + add-on isolated). Not in help.
	boot-up) shift; cmd_boot_up "$@" ;;
	ensure-videostore-mounts) shift; cmd_ensure_videostore_mounts "$@" ;;
    help|--help|-h) cmd_help ;;
    *)        echo "Unknown command: $1"; echo ""; cmd_help; exit 1 ;;
esac
fi
