#!/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
set -euo pipefail
set -o errtrace
trap 'echo "WARNING: error on line $LINENO: \"$BASH_COMMAND\""; exit 1' ERR
shopt -s nullglob
IFS=$'\n\t'
echo "Restic Backup V 4.4.8"

# Check if Already Running
exec 9>/var/run/smartstore.lock
flock -n 9 || {
  echo "WARNING: [$(date)] : process is already running"
  exit 1
}

if ! command -v restic &> /dev/null; then
    echo "restic not found. Starting installation..."
    apt-get update && apt-get install -y restic
fi

# Check dependencies early (before mounting and starting the backup)
for cmd in qm pct restic pvesm pvesh jq; do
    command -v "$cmd" &>/dev/null || { echo "WARNING: required command missing: ${cmd}"; exit 1; }
done


# Set Default Values
RESTIC_BASEFOLDER="/media/SmartStore"
logPath="/var/log/smartstore.log"
mountPersistent="no"
setStandby="yes"
fsTrim="yes"
UUIDS=()
MOUNTOPT=()
COUNTIGNORE="no"

# ---- ResticBackup defaults (can be overridden in config.cfg) ----
KEEP_DAILY="${KEEP_DAILY:-5}"
KEEP_WEEKLY="${KEEP_WEEKLY:-4}"
KEEP_MONTHLY="${KEEP_MONTHLY:-2}"
PRUNE_AFTER_BACKUP="${PRUNE_AFTER_BACKUP:-true}"
EXCLUDE_VMIDS="${EXCLUDE_VMIDS:-}"
SKIP_TEMPLATES="${SKIP_TEMPLATES:-true}"
NOTIFY_EMAIL="${NOTIFY_EMAIL:-root}"
NOTIFY_ON_SUCCESS="${NOTIFY_ON_SUCCESS:-true}"
LXC_EXCLUDE_DIRS="${LXC_EXCLUDE_DIRS:-/var/cache,/var/log}"
# Print the overview of all backups at the end of the run (one
# 'restic stats' call per snapshot).
PRINT_BACKUP_OVERVIEW="${PRINT_BACKUP_OVERVIEW:-true}"
RESTIC_COMPRESSION_SEND="${RESTIC_COMPRESSION_SEND:-off}"
# ---- RAW image mode ----
# auto = ZFS where possible, otherwise RAW | zfs = ZFS only | raw = always RAW
BACKUP_MODE="${BACKUP_MODE:-auto}"
RESTIC_COMPRESSION_RAW="${RESTIC_COMPRESSION_RAW:-auto}"
# Compression for all file based backups (LXC, LOCAL_DIRS, config).
RESTIC_COMPRESSION_FILES="${RESTIC_COMPRESSION_FILES:-auto}"
# If the target drive is smaller than AUTO_RAW_FACTOR x the source data in use
# it can never hold two full generations -> switch to RAW instead of zfs send.
# 0 disables the automatic switch.
AUTO_RAW_FACTOR="${AUTO_RAW_FACTOR:-2.3}"
RAW_BLOCK_SIZE="${RAW_BLOCK_SIZE:-4M}"
RAW_USE_LVM_SNAPSHOT="${RAW_USE_LVM_SNAPSHOT:-true}"
# Without a consistent snapshot source do NOT write a RAW backup (no live read).
RAW_REQUIRE_SNAPSHOT="${RAW_REQUIRE_SNAPSHOT:-true}"
RAW_LVM_SNAPSHOT_SIZE="${RAW_LVM_SNAPSHOT_SIZE:-5G}"
FULL_MAX_AGE_DAYS="${FULL_MAX_AGE_DAYS:-21}"
# Force a full when the differential stream would reach this share of a full
# stream (percent). 0 = check disabled.
FULL_MAX_DIFF_PERCENT="${FULL_MAX_DIFF_PERCENT:-25}"
# Optional weekday on which a full backup is forced (mon,tue,wed,thu,fri,sat,sun).
# Empty = no weekday based full backup.
FULL_WEEKDAY="${FULL_WEEKDAY:-}"
LOCAL_DIRS="${LOCAL_DIRS:-}"
LOCAL_EXCLUDE_DIRS="${LOCAL_EXCLUDE_DIRS:-.zfs}"
RESTIC_CHECK_AFTER_BACKUP="${RESTIC_CHECK_AFTER_BACKUP:-true}"
RESTIC_CHECK_MAX_GB="${RESTIC_CHECK_MAX_GB:-10}"
DISK_SPACE_WARN_PERCENT="${DISK_SPACE_WARN_PERCENT:-10}"
FULL_FILESYSTEM_PERCENTAGE="${FULL_FILESYSTEM_PERCENTAGE:-90}"
MAX_UPTIME_DAYS="${MAX_UPTIME_DAYS:-39}"
# Expected growth of an already existing file based / RAW backup,
# in percent of the space it currently occupies.
ESTIMATED_BACKUP_INCREASE_PERCENTAGE="${ESTIMATED_BACKUP_INCREASE_PERCENTAGE:-10}"
# On a total lack of space (not even freeing all old backups is enough) a
# prominent warning is printed when this is TRUE.
LOW_MEMORY_WARNING="${LOW_MEMORY_WARNING:-TRUE}"
RESTIC_DRY_RUN=false
RESTIC_VERBOSE=false

# Import config file (optional - the script also runs on defaults without it)
config=/usr/local/bin/config.cfg
if [[ -r "$config" ]]; then
    # shellcheck source=/dev/null
    source "$config"
else
    echo "INFO: no config found (${config}), using defaults"
fi

# Normalize the backup mode / check ZFS availability
if [[ -n "${DIFF_BUFFER_PERCENTAGE:-}" ]]; then
    echo "INFO: DIFF_BUFFER_PERCENTAGE has been removed and is ignored - the"
    echo "      required space is now calculated exactly during the planning phase."
fi
BACKUP_MODE="$(echo "${BACKUP_MODE}" | tr '[:upper:]' '[:lower:]')"
case "$BACKUP_MODE" in
    auto|zfs|raw) : ;;
    *) echo "WARNING: invalid BACKUP_MODE '${BACKUP_MODE}' - using 'auto'"; BACKUP_MODE="auto" ;;
esac
# AUTO_RAW_FACTOR may be fractional (e.g. 2.2). A comma is accepted as the
# decimal separator but stored as a dot, so awk parses it the same way in every
# locale. An invalid value would silently switch the whole mode automatic off,
# so it is caught here instead of failing quietly later on.
AUTO_RAW_FACTOR="${AUTO_RAW_FACTOR/,/.}"
if ! [[ "$AUTO_RAW_FACTOR" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
    echo "WARNING: invalid AUTO_RAW_FACTOR '${AUTO_RAW_FACTOR}' - using 2"
    AUTO_RAW_FACTOR=2
fi
if ! [[ "$FULL_MAX_DIFF_PERCENT" =~ ^[0-9]+$ ]]; then
    echo "WARNING: invalid FULL_MAX_DIFF_PERCENT '${FULL_MAX_DIFF_PERCENT}' - check disabled"
    FULL_MAX_DIFF_PERCENT=0
fi

if ! command -v zfs &>/dev/null; then
    [[ "$BACKUP_MODE" == "zfs" ]] && echo "WARNING: BACKUP_MODE=zfs but zfs is missing - falling back to RAW"
    echo "INFO: no ZFS on this host - all volumes will be backed up as RAW images"
    BACKUP_MODE="raw"
fi

# Derive repo and password path from RESTIC_BASEFOLDER
# (both can be overridden explicitly in the config)
RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-${RESTIC_BASEFOLDER}/ResticBackup}"
RESTIC_PASSWORDFILE="${RESTIC_PASSWORDFILE:-${RESTIC_BASEFOLDER}/ResticBackup.pw}"

# ================== ResticBackup Runtime State ==================
ERRORS=0
WARNINGS=0
TOTAL_VMS=0
TOTAL_CTS=0
TOTAL_DISKS=0
TOTAL_SKIPPED=0
LOG_OUTPUT=""
FULL_RESTIC_ID=""
BASE_SNAP_NAME=""
# Name tag of the VM/CT currently being backed up (for restic --tag "name=...")
NAME_TAG_FLAGS=()
# Backup run identifier: short_id of this run's config snapshot.
# Appended to every disk backup as tag "config-id=<id>" so that a restore
# can find all disks that belong together.
CURRENT_CONFIG_ID=""
CONFIG_ID_TAG_FLAGS=()

# Harden tag values: commas would split the tag on the restic CLI,
# whitespace makes the evaluation fragile
sanitize_tag_value() {
    local s="$1"
    s="${s//,/}"
    s="${s//[[:space:]]/_}"
    echo "$s"
}
FULL_TIME=""
# Set once per run, as soon as the space check has been done.
FULL_SPACE_CHECK_DONE=false
# "All-or-nothing" full rule: if the planning phase finds that any disk needs
# a full backup this is set to true and ALL disks are backed up full.
# Filled in by plan_all_backups().
FORCE_ALL_FULL=false
TOTAL_FULLS=0
TOTAL_DIFFS=0
TOTAL_RAWS=0
# Result of the planning phase: exact space required by this run, in bytes.
SUM_DIFF_BYTES=0     # sum of the zfs-send stream sizes of all differentials
SUM_FULL_BYTES=0     # sum of the ZFS 'used' of all VM disks (on a full run)
SUM_RAW_BYTES=0      # requirement of all RAW images
SUM_OTHER_BYTES=0    # requirement of all LXC and LOCAL_DIRS backups
REQUIRED_BYTES=0     # the space actually needed
# true as soon as at least one volume is stored as a RAW image.
# RAW images have no differentials -> plan space as for a full run.
HAS_RAW_DISKS=false
VOL_PATH=""
VOL_KIND=""
RAW_READ_PATH=""
RAW_TMP_SNAP=""
RAW_TMP_CLONE=""
RAW_SOURCE_CONSISTENT=false
RAW_SNAPSHOT_ATTEMPTED=false
TOTAL_DATA_ADDED=0
REPO_SIZE_BEFORE=0

# ================== Logging ==================
# Every message goes to stdout AND into LOG_OUTPUT, which is mailed at the end
# of the run. log_warn/log_error additionally maintain the counters that decide
# the exit code and the subject line of the notification mail.

rb_log() {
    local level="$1"; shift
    local msg="[$(date '+%Y-%m-%dT%H:%M:%S%z')] [$level] $*"
    echo "$msg"
    LOG_OUTPUT+="${msg}"$'\n'
}
log_info()  { rb_log "INFO" "$@"; }
log_warn()  { rb_log "WARNING" "$@"; WARNINGS=$((WARNINGS+1)); }
log_error() { rb_log "ERROR" "$@"; ERRORS=$((ERRORS+1)); }

is_excluded() {
    local vmid="$1"
    [[ ",${EXCLUDE_VMIDS}," == *",${vmid},"* ]]
}

# ================== RAW image mode ==================

# Resolve a Proxmox volume and classify it.
# Sets VOL_PATH and VOL_KIND (zvol|blockdev|dir|file).
resolve_volume() {
    local volume="$1"
    VOL_PATH=""; VOL_KIND=""
    [[ -z "$volume" || "$volume" == "none" ]] && return 1
    if [[ "$volume" == /* ]]; then
        VOL_PATH="$volume"
    else
        VOL_PATH=$(pvesm path "$volume" 2>/dev/null) || return 1
    fi
    [[ -z "$VOL_PATH" ]] && return 1
    if [[ "$VOL_PATH" == /dev/zvol/* ]]; then
        VOL_KIND="zvol"
    elif [[ -b "$VOL_PATH" ]]; then
        VOL_KIND="blockdev"
    elif [[ -d "$VOL_PATH" ]]; then
        VOL_KIND="dir"
    elif [[ -f "$VOL_PATH" ]]; then
        VOL_KIND="file"
    else
        return 1
    fi
    return 0
}

# restic knows exactly three values. Anything else is caught at startup instead
# of failing on every backup call. $1 is the NAME of the setting to normalize.
normalize_compression() {
    local -n _c="$1"
    case "${_c,,}" in
        off|auto|max) _c="${_c,,}" ;;
        *)
            log_warn "${1}: \"${_c}\" is not a valid value (off|auto|max) - using auto"
            _c="auto"
            ;;
    esac
}

# Size of a RAW source in bytes (block device or file).
raw_source_size() {
    local p="$1" v=""
    if [[ -b "$p" ]]; then
        v=$(blockdev --getsize64 "$p" 2>/dev/null) || v=""
    fi
    [[ -z "$v" ]] && { v=$(stat -Lc %s "$p" 2>/dev/null) || v=""; }
    [[ "$v" =~ ^[0-9]+$ ]] || return 1
    echo "$v"
}

# Space actually occupied by a RAW source in bytes, holes excluded.
# Far more realistic for SPACE PLANNING than the logical size: unallocated
# ranges read back as zeros and end up in the repo deduplicated to nearly
# nothing. (The amount of data READ is still the full size - that is I/O
# effort, not a space requirement.)
#   ZFS zvol       : logicalreferenced of the newest snapshot, else the dataset
#   LVM-thin       : lv_size * data_percent
#   raw file       : allocated blocks (st_blocks), not the apparent size
#   otherwise      : logical size (thick provisioning has no sparseness)
raw_allocated_bytes() {
    local src="$1" v=""

    if [[ "$src" == /dev/zvol/* ]] && command -v zfs &>/dev/null; then
        local ds="${src#/dev/zvol/}" target=""
        if find_latest_snap "$ds"; then
            target="$LATEST_SNAP"
        else
            target="$ds"
        fi
        v=$(zfs get -Hp -o value logicalreferenced "$target" 2>/dev/null) || v=""
        if [[ ! "$v" =~ ^[0-9]+$ ]]; then
            v=$(zfs get -Hp -o value referenced "$target" 2>/dev/null) || v=""
        fi
        [[ "$v" =~ ^[0-9]+$ ]] && { echo "$v"; return 0; }
    fi

    if [[ -b "$src" ]] && command -v lvs &>/dev/null; then
        local lsize dpct
        lsize=$(lvs --noheadings --units b --nosuffix -o lv_size "$src" 2>/dev/null | tr -d '[:space:]') || lsize=""
        dpct=$(lvs --noheadings -o data_percent "$src" 2>/dev/null | tr -d '[:space:]') || dpct=""
        if [[ "$lsize" =~ ^[0-9]+$ && -n "$dpct" ]]; then
            v=$(awk -v s="$lsize" -v pc="$dpct" 'BEGIN{printf "%d", s*pc/100}') || v=""
            [[ "$v" =~ ^[0-9]+$ ]] && { echo "$v"; return 0; }
        fi
    fi

    if [[ -f "$src" ]]; then
        local blocks
        blocks=$(stat -Lc %b "$src" 2>/dev/null) || blocks=""
        [[ "$blocks" =~ ^[0-9]+$ ]] && { echo $(( blocks * 512 )); return 0; }
    fi

    raw_source_size "$src"
}

# Prepare a consistent read source:
#   LVM / LVM-thin -> temporary snapshot LV
#   ZFS zvol       -> snapshot device, if visible
#   otherwise      -> read directly (with a warning)
# Sets RAW_READ_PATH and, where applicable, RAW_TMP_SNAP.
raw_prepare_source() {
    local src="$1"
    RAW_READ_PATH="$src"
    RAW_TMP_SNAP=""
    RAW_TMP_CLONE=""
    RAW_SOURCE_CONSISTENT=false
    RAW_SNAPSHOT_ATTEMPTED=false

    # ZFS zvol: the snapshot EXISTS (find_latest_snap finds it), but it only
    # appears under /dev/zvol/ when snapdev=visible is set - the default is
    # hidden. So: take the device if it is there, otherwise make the snapshot
    # visible through a temporary clone (costs no space, COW).
    if [[ "$src" == /dev/zvol/* ]] && command -v zfs &>/dev/null; then
        local ds="${src#/dev/zvol/}"
        RAW_SNAPSHOT_ATTEMPTED=true
        if ! find_latest_snap "$ds"; then
            log_error "  RAW source: no ZFS snapshot exists on ${ds}"
            return 0
        fi

        local snapdev="/dev/zvol/${LATEST_SNAP}"
        if [[ -b "$snapdev" ]]; then
            RAW_READ_PATH="$snapdev"
            RAW_SOURCE_CONSISTENT=true
            log_info "  RAW source: ZFS snapshot ${LATEST_SNAP} (snapdev visible)"
            return 0
        fi

        # snapdev=hidden -> temporaeren Klon anlegen
        local clone="${ds}_rbclone" _orig=""
        _orig=$(zfs get -H -o value origin "$clone" 2>/dev/null) || _orig=""
        if [[ -n "$_orig" && "$_orig" != "-" ]]; then
            # Leftover from an earlier run - only real clones are removed
            zfs destroy -f "$clone" &>/dev/null || true
        fi
        if zfs clone -o volmode=dev "$LATEST_SNAP" "$clone" &>/dev/null \
           || zfs clone "$LATEST_SNAP" "$clone" &>/dev/null; then
            RAW_TMP_CLONE="$clone"
            local clonedev="/dev/zvol/${clone}" _i=0
            command -v udevadm &>/dev/null && udevadm settle --timeout=10 &>/dev/null || true
            while [[ ! -b "$clonedev" && $_i -lt 50 ]]; do sleep 0.1; _i=$((_i+1)); done
            if [[ -b "$clonedev" ]]; then
                RAW_READ_PATH="$clonedev"
                RAW_SOURCE_CONSISTENT=true
                log_info "  RAW source: ZFS snapshot ${LATEST_SNAP} via temporary clone ${clone}"
                return 0
            fi
            log_warn "  RAW source: clone device ${clonedev} did not appear"
            raw_cleanup_source
        else
            log_warn "  RAW source: 'zfs clone ${LATEST_SNAP} ${clone}' failed"
        fi
        log_warn "  RAW source: no consistent source for ${ds} (hint: zfs set snapdev=visible ${ds})"
        return 0
    fi

    [[ "$RAW_USE_LVM_SNAPSHOT" == true ]] || return 0
    [[ -b "$src" ]] || return 0
    command -v lvs &>/dev/null || return 0
    command -v lvcreate &>/dev/null || return 0

    RAW_SNAPSHOT_ATTEMPTED=true
    local vg lv pool
    vg=$(lvs --noheadings -o vg_name "$src" 2>/dev/null | tr -d '[:space:]') || vg=""
    lv=$(lvs --noheadings -o lv_name "$src" 2>/dev/null | tr -d '[:space:]') || lv=""
    if [[ -z "$vg" || -z "$lv" ]]; then
        RAW_SNAPSHOT_ATTEMPTED=false
        log_warn "  RAW source: not an LVM volume (${src}) - reading live"
        return 0
    fi
    pool=$(lvs --noheadings -o pool_lv "$src" 2>/dev/null | tr -d '[:space:]') || pool=""

    local snap="${lv}_rbsnap"
    lvremove -f "${vg}/${snap}" &>/dev/null || true
    if [[ -n "$pool" ]]; then
        lvcreate -s -n "$snap" "${vg}/${lv}" &>/dev/null || {
            log_warn "  RAW source: thin snapshot ${vg}/${snap} failed - reading live"; return 0; }
        lvchange -ay -Ky "${vg}/${snap}" &>/dev/null || true
    else
        lvcreate -s -L "$RAW_LVM_SNAPSHOT_SIZE" -n "$snap" "${vg}/${lv}" &>/dev/null || {
            log_warn "  RAW source: LVM snapshot ${vg}/${snap} failed - reading live"; return 0; }
    fi
    RAW_TMP_SNAP="${vg}/${snap}"
    RAW_READ_PATH="/dev/${vg}/${snap}"
    RAW_SOURCE_CONSISTENT=true
    log_info "  RAW source: LVM snapshot ${RAW_TMP_SNAP}"
}

raw_cleanup_source() {
    if [[ -n "$RAW_TMP_CLONE" ]]; then
        zfs destroy -f "$RAW_TMP_CLONE" &>/dev/null \
            || log_warn "  Could not remove temporary ZFS clone ${RAW_TMP_CLONE} (manually: zfs destroy ${RAW_TMP_CLONE})"
        RAW_TMP_CLONE=""
    fi
    [[ -z "$RAW_TMP_SNAP" ]] && return 0
    lvremove -f "$RAW_TMP_SNAP" &>/dev/null \
        || log_warn "  Could not remove LVM snapshot ${RAW_TMP_SNAP} (manually: lvremove -f ${RAW_TMP_SNAP})"
    RAW_TMP_SNAP=""
}

# If the run is aborted (Ctrl+C, SIGTERM, or the ERR trap above) the temporary
# artefacts of the current volume would stay behind: an LVM snapshot LV, a ZFS
# clone and the read-only snapshot mount used for file based LXC backups. They
# cost space and block the next run, so they are cleaned up unconditionally on
# exit. Everything is best effort - a failing cleanup must not mask the
# original exit code.
cleanup_on_exit() {
    local _rc=$?
    trap - EXIT INT TERM ERR
    raw_cleanup_source 2>/dev/null || true
    local _stale
    for _stale in /tmp/restic-snap-*; do
        [[ -d "$_stale" ]] || continue
        if mountpoint -q "$_stale" 2>/dev/null; then
            umount "$_stale" 2>/dev/null || true
        fi
        rmdir "$_stale" 2>/dev/null || true
    done
    exit "$_rc"
}
trap cleanup_on_exit EXIT INT TERM

# Write a RAW image of a volume to restic.
# Always a full backup - there are no differentials in this mode.
# $1 prefix (vm|ct)  $2 id  $3 disk_key  $4 Quellpfad
backup_raw_volume() {
    local prefix="$1" id="$2" disk_key="$3" src_path="$4"
    local disk_tag="${prefix}-${id}-${disk_key}"
    local filename="${prefix}-${id}-${disk_key}.raw"
    local size comp
    size=$(raw_source_size "$src_path") || size=0
    [[ "$size" =~ ^[0-9]+$ ]] || size=0
    comp="$RESTIC_COMPRESSION_RAW"

    if $RESTIC_DRY_RUN; then
        log_info "  [DRY-RUN] RAW image ${disk_key}: dd if=${src_path} | restic --stdin ${filename} (compression=${comp}, $(human_bytes "$size"))"
        return 0
    fi

    raw_prepare_source "$src_path"
    if [[ "$RAW_REQUIRE_SNAPSHOT" == true ]] && $RAW_SNAPSHOT_ATTEMPTED && ! $RAW_SOURCE_CONSISTENT; then
        raw_cleanup_source
        log_error "  ${disk_key}: no consistent snapshot source - skipped (RAW_REQUIRE_SNAPSHOT=true)"
        return 1
    fi
    log_info "  RAW image ${disk_key}: ${RAW_READ_PATH} ($(human_bytes "$size"), compression=${comp})"

    set +e +o pipefail
    dd if="$RAW_READ_PATH" bs="$RAW_BLOCK_SIZE" status=none 2>/dev/null | restic backup \
        --stdin --stdin-filename "$filename" --compression "$comp" \
        --tag "${prefix}-${id}" --tag "$disk_tag" --tag "raw" \
        --tag "raw-size=${size}" --tag "raw-source=${VOL_KIND:-unknown}" \
        "${NAME_TAG_FLAGS[@]}" "${CONFIG_ID_TAG_FLAGS[@]}"
    local _ps=("${PIPESTATUS[@]}")
    set -e -o pipefail
    raw_cleanup_source

    local _dd_rc=${_ps[0]:-1}
    local _restic_rc=${_ps[1]:-1}
    if [[ "$_restic_rc" -eq 0 && "$_dd_rc" -eq 0 ]]; then
        log_info "  OK: ${disk_key} raw"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
        TOTAL_RAWS=$((TOTAL_RAWS+1))
    elif [[ "$_restic_rc" -eq 0 ]]; then
        log_warn "  Partial: ${disk_key} raw (dd exit=${_dd_rc}) - removing incomplete backup"
        local _bad_snap
        _bad_snap=$(restic snapshots --tag "${disk_tag},raw" --json 2>/dev/null \
            | jq -r 'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || true
        [[ -n "$_bad_snap" ]] && { restic forget "$_bad_snap" &>/dev/null || log_warn "  Could not remove ${_bad_snap}"; }
    else
        log_error "  Failed: ${filename} (restic=${_restic_rc}, dd=${_dd_rc})"
    fi
}

find_latest_snap() {
    local dataset="$1"
    LATEST_SNAP=""
    local snap
    snap=$(zfs list -t snapshot -H -o name -S creation "${dataset}" 2>/dev/null | head -1) || true
    if [[ -n "$snap" ]]; then
        LATEST_SNAP="$snap"
        return 0
    fi
    return 1
}

is_snap_stale() {
    local snap_full="$1"
    local creation_epoch now_epoch age
    # -p returns the creation time directly as an epoch (locale independent)
    creation_epoch=$(zfs get -Hp -o value creation "${snap_full}" 2>/dev/null) || return 1
    [[ "$creation_epoch" =~ ^[0-9]+$ ]] || return 1
    now_epoch=$(date +%s)
    age=$(( now_epoch - creation_epoch ))
    [[ $age -gt 86400 ]]
}

prepare_snapshot() {
    local dataset="$1"
    SNAP_FULL=""
    if ! find_latest_snap "$dataset"; then
        log_error "  No snapshot found on ${dataset}"
        return 1
    fi
    SNAP_FULL="$LATEST_SNAP"
    local snap_name="${SNAP_FULL#*@}"
    # An old snapshot is worth pointing out (the backup then contains an old
    # state), but the backup itself still succeeds - so this must not turn the
    # whole run into a FAILED one.
    if is_snap_stale "$SNAP_FULL"; then
        log_warn "  Snapshot older than 24h (${snap_name} on ${dataset}) - backing up that state"
    fi
    return 0
}

# Returns 0 if today is the weekday configured in FULL_WEEKDAY.
# FULL_WEEKDAY empty -> always 1 (no weekday based full backup).
# The comparison is locale independent via date +%u (1=Mon ... 7=Sun).
is_weekday_full() {
    [[ -z "$FULL_WEEKDAY" ]] && return 1
    local want want_num today_num
    want=$(echo "$FULL_WEEKDAY" | tr '[:upper:]' '[:lower:]')
    want="${want:0:3}"
    case "$want" in
        mon) want_num=1 ;;
        tue) want_num=2 ;;
        wed) want_num=3 ;;
        thu) want_num=4 ;;
        fri) want_num=5 ;;
        sat) want_num=6 ;;
        sun) want_num=7 ;;
        *)   log_warn "Invalid FULL_WEEKDAY value: '${FULL_WEEKDAY}' (expected mon/tue/wed/thu/fri/sat/sun)"; return 1 ;;
    esac
    today_num=$(date +%u)
    [[ "$today_num" == "$want_num" ]]
}

get_restic_json() {
    local output
    output=$(restic "$@" --json 2>/dev/null) || {
        log_error "restic $* failed"
        return 1
    }

    if ! echo "$output" | jq . >/dev/null 2>&1; then
        log_error "Invalid JSON from restic ($*)"
        return 1
    fi

    echo "$output"
}


find_last_full() {
    local disk_tag="$1"
    FULL_RESTIC_ID=""
    BASE_SNAP_NAME=""
    FULL_TIME=""
    local snapshots_json
    snapshots_json=$(get_restic_json snapshots --tag "${disk_tag},full") || return 1
    [[ -z "$snapshots_json" || "$snapshots_json" == "[]" ]] && return 1
    local _parsed
    _parsed=$(echo "$snapshots_json" | jq -r '
        sort_by(.time) | last as $s |
        [$s.short_id // "", $s.time // "",
         ([$s.tags // [] | .[] | select(startswith("base-snap=")) | sub("^base-snap="; "")][0] // "")] |
        @tsv
    ' 2>/dev/null) || return 1
    IFS=$'\t' read -r FULL_RESTIC_ID FULL_TIME BASE_SNAP_NAME <<< "$_parsed"
    [[ -n "$FULL_RESTIC_ID" && -n "$BASE_SNAP_NAME" ]]
}

is_full_too_old() {
    [[ -z "$FULL_TIME" ]] && return 0
    local full_epoch now_epoch age
    full_epoch=$(date -d "$FULL_TIME" +%s 2>/dev/null) || return 0
    now_epoch=$(date +%s)
    age=$(( (now_epoch - full_epoch) / 86400 ))
    [[ $age -ge $FULL_MAX_AGE_DAYS ]]
}

base_snap_exists() {
    local dataset="$1"
    local base_full_snap="${dataset}@${BASE_SNAP_NAME}"
    zfs list -t snapshot -H -o name "$base_full_snap" &>/dev/null
}

full_restic_snap_exists() {
    local snap_id="$1"
    [[ -z "$snap_id" ]] && return 1
    restic snapshots --json "$snap_id" 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1
}

backup_zfs_disk_full() {
    local vmid="$1" disk_key="$2" dataset="$3" current_snap="$4"
    local disk_tag="vm-${vmid}-${disk_key}"
    local filename="vm-${vmid}-${disk_key}.zfs"
    log_info "  Full backup: ${disk_key}"
    set +e +o pipefail
    zfs send -w "${SNAP_FULL}" 2>/dev/null | restic backup \
        --stdin --stdin-filename "$filename" --compression "$RESTIC_COMPRESSION_SEND" \
        --tag "vm-${vmid}" --tag "$disk_tag" --tag "full" --tag "base-snap=${current_snap}" "${NAME_TAG_FLAGS[@]}" "${CONFIG_ID_TAG_FLAGS[@]}"
    local _ps=("${PIPESTATUS[@]}")
    set -e -o pipefail
    local _send_rc=${_ps[0]:-1}
    local _restic_rc=${_ps[1]:-1}
    if [[ "$_restic_rc" -eq 0 && "$_send_rc" -eq 0 ]]; then
        log_info "  OK: ${disk_key} full (${dataset})"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
        TOTAL_FULLS=$((TOTAL_FULLS+1))
    elif [[ "$_restic_rc" -eq 0 ]]; then
        log_warn "  Partial: ${disk_key} full (zfs send exit=${_send_rc}) - removing incomplete backup"
        local _bad_snap
        _bad_snap=$(restic snapshots --tag "${disk_tag},full" --json 2>/dev/null \
            | jq -r 'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || true
        if [[ -n "$_bad_snap" ]]; then
            restic forget "$_bad_snap" &>/dev/null || log_warn "  Could not auto-remove partial ${_bad_snap}"
        fi
    else
        log_error "  Failed: ${filename} (restic=${_restic_rc}, send=${_send_rc})"
    fi
}

backup_zfs_disk_differential() {
    local vmid="$1" disk_key="$2" dataset="$3" current_snap="$4"
    local disk_tag="vm-${vmid}-${disk_key}"
    local filename="vm-${vmid}-${disk_key}.zfs"
    local base_full_snap="${dataset}@${BASE_SNAP_NAME}"

    if ! zfs list -t snapshot -H -o name "$base_full_snap" &>/dev/null; then
        log_error "  ${disk_key}: base snapshot ${base_full_snap} no longer exists in ZFS/Proxmox - refusing to build differential"
        return 1
    fi

    if [[ "$BASE_SNAP_NAME" == "$current_snap" ]]; then
        log_info "  Skip: snapshot unchanged since last full (${current_snap})"
        return 0
    fi
    log_info "  Differential: ${BASE_SNAP_NAME} -> ${current_snap}"
    set +e +o pipefail
    zfs send -w -i "$base_full_snap" "${SNAP_FULL}" 2>/dev/null | restic backup \
        --stdin --stdin-filename "$filename" --compression "$RESTIC_COMPRESSION_SEND" \
        --tag "vm-${vmid}" --tag "$disk_tag" --tag "differential" "${NAME_TAG_FLAGS[@]}" "${CONFIG_ID_TAG_FLAGS[@]}" \
        --tag "parent-snap=${BASE_SNAP_NAME}" --tag "parent-id=${FULL_RESTIC_ID}"
    local _ps=("${PIPESTATUS[@]}")
    set -e -o pipefail
    local _send_rc=${_ps[0]:-1}
    local _restic_rc=${_ps[1]:-1}
    if [[ "$_restic_rc" -eq 0 && "$_send_rc" -eq 0 ]]; then
        log_info "  OK: ${disk_key} diff (${dataset})"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
        TOTAL_DIFFS=$((TOTAL_DIFFS+1))
        local _new_snap_id
        _new_snap_id=$(restic snapshots --tag "vm-${vmid},${disk_tag},differential" --json 2>/dev/null \
            | jq -r "sort_by(.time) | last | .short_id // empty" 2>/dev/null) || true
        [[ -n "$_new_snap_id" ]] && show_full_diff_age "$disk_tag" "$_new_snap_id" 2>/dev/null || true
    elif [[ "$_send_rc" -ne 0 ]]; then
        log_error "  Failed: ${disk_key} diff - zfs send exit=${_send_rc} (base snapshot vanished during send?)"
        if [[ "$_restic_rc" -eq 0 ]]; then
            local _bad_snap
            _bad_snap=$(restic snapshots --tag "vm-${vmid},${disk_tag},differential" --json 2>/dev/null \
                | jq -r 'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || true
            if [[ -n "$_bad_snap" ]]; then
                log_warn "  Removing orphaned differential snapshot ${_bad_snap} (no valid data behind it)"
                restic forget "$_bad_snap" &>/dev/null || log_warn "  Could not auto-remove ${_bad_snap}, remove manually: restic forget ${_bad_snap}"
            fi
        fi
    else
        log_error "  Failed: ${filename} (restic=${_restic_rc}, send=${_send_rc})"
    fi
}

# Decides for a disk already prepared by prepare_snapshot() whether a full
# backup would be needed. Side effect: sets FULL_RESTIC_ID / BASE_SNAP_NAME /
# FULL_TIME (via find_last_full) and reports the reason in DISK_FULL_REASON.
# Return: 0 = full needed, 1 = differential possible.
# EFI and TPM state volumes are ALWAYS backed up full. They are tiny, they
# change abruptly and a differential on them gains nothing. They deliberately
# do NOT trigger the all-or-nothing rule - the rest of the run stays
# differential.
is_always_full_disk() {
    case "$1" in
        efidisk*|tpmstate*) return 0 ;;
        *)                  return 1 ;;
    esac
}

DISK_FULL_REASON=""
disk_needs_full() {
    local disk_tag="$1" dataset="$2"
    DISK_FULL_REASON=""
    FULL_RESTIC_ID=""
    BASE_SNAP_NAME=""
    FULL_TIME=""
    if is_weekday_full; then
        DISK_FULL_REASON="configured full weekday (${FULL_WEEKDAY})"; return 0
    elif ! find_last_full "$disk_tag"; then
        DISK_FULL_REASON="no previous full in repo"; return 0
    elif is_full_too_old; then
        DISK_FULL_REASON="last full older than ${FULL_MAX_AGE_DAYS} days"; return 0
    elif ! base_snap_exists "$dataset"; then
        DISK_FULL_REASON="base snapshot ${BASE_SNAP_NAME} missing"; return 0
    elif ! full_restic_snap_exists "$FULL_RESTIC_ID"; then
        DISK_FULL_REASON="referenced full restic snapshot ${FULL_RESTIC_ID} no longer exists in repo"; return 0
    fi
    return 1
}

backup_zfs_disk() {
    local vmid="$1" disk_key="$2" dataset="$3"
    local disk_tag="vm-${vmid}-${disk_key}"
    if ! prepare_snapshot "$dataset"; then
        return 1
    fi
    local current_snap="${SNAP_FULL#*@}"
    log_info "  Backing up ${disk_key}: ZFS snapshot ${SNAP_FULL}"

    # Decision: own requirement OR the global all-or-nothing rule
    local do_full=false reason=""
    if $FORCE_ALL_FULL; then
        # Call find_last_full anyway so that base-snap/parent tags can still
        # be set correctly (return value does not matter)
        find_last_full "$disk_tag" >/dev/null 2>&1 || true
        do_full=true; reason="all-or-nothing: at least one disk requires a full"
    elif is_always_full_disk "$disk_key"; then
        find_last_full "$disk_tag" >/dev/null 2>&1 || true
        do_full=true; reason="EFI/TPM state disks are always backed up full"
    elif disk_needs_full "$disk_tag" "$dataset"; then
        do_full=true; reason="$DISK_FULL_REASON"
    fi

    if $RESTIC_DRY_RUN; then
        if $do_full; then
            log_info "  [DRY-RUN] FULL (${reason}): zfs send -w ${SNAP_FULL} | restic --stdin"
        else
            log_info "  [DRY-RUN] DIFFERENTIAL: zfs send -w -i @${BASE_SNAP_NAME} ${SNAP_FULL} | restic --stdin"
        fi
        return 0
    fi

    if $do_full; then
        log_info "  Full backup (${reason})"
        backup_zfs_disk_full "$vmid" "$disk_key" "$dataset" "$current_snap"
    else
        if ! backup_zfs_disk_differential "$vmid" "$disk_key" "$dataset" "$current_snap"; then
            log_warn "  Differential refused/failed -> falling back to Full backup"
            backup_zfs_disk_full "$vmid" "$disk_key" "$dataset" "$current_snap"
        fi
    fi
}

backup_lxc_fs() {
    local ctid="$1" key="$2" dataset="$3"
    local mnt="/tmp/restic-snap-ct${ctid}-${key}"
    if ! prepare_snapshot "$dataset"; then
        return 1
    fi
    log_info "  Backing up CT ${ctid} ${key}: ZFS snapshot ${SNAP_FULL}"

    if $RESTIC_DRY_RUN; then
        log_info "  [DRY-RUN] mount ${SNAP_FULL} ro -> restic backup ${mnt}"
        return 0
    fi
    for stale in /tmp/restic-snap-*; do
        [[ -d "$stale" ]] || continue
        if mountpoint -q "$stale" 2>/dev/null; then
            umount "$stale" 2>/dev/null || true
        fi
        rmdir "$stale" 2>/dev/null || true
    done
    mkdir -p "$mnt"
    if ! mount -t zfs -o ro "${SNAP_FULL}" "$mnt" 2>/dev/null; then
        log_error "  Failed to mount ${SNAP_FULL}"
        rmdir "$mnt" 2>/dev/null || true
        return 1
    fi
    local rc=1
    ( cd "$mnt" && restic backup . "${LXC_EXCLUDE_FLAGS[@]}" \
        --compression "$RESTIC_COMPRESSION_FILES" \
        --tag "ct-${ctid}" --tag "lxc" "${NAME_TAG_FLAGS[@]}" "${CONFIG_ID_TAG_FLAGS[@]}" ) && rc=0 || rc=$?
    if [[ $rc -eq 0 ]]; then
        log_info "  OK: ${key} (${dataset})"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
    else
        log_error "  Failed: ct-${ctid}-${key} (restic exit=${rc})"
    fi
    umount "$mnt" 2>/dev/null || true
    rmdir "$mnt" 2>/dev/null || true
}

# LXC rootfs on a directory storage without ZFS: back up file based straight
# from the running directory (no snapshot -> no point-in-time consistency).
backup_lxc_dir() {
    local ctid="$1" key="$2" dir="$3"
    if [[ ! -d "$dir" ]]; then
        log_error "  ${key}: directory ${dir} not found"
        return 1
    fi
    if $RESTIC_DRY_RUN; then
        log_info "  [DRY-RUN] restic backup ${dir} (LXC file-based, no snapshot)"
        return 0
    fi
    log_warn "  CT ${ctid} ${key}: no snapshot possible (${dir}) - live backup"
    local rc=1
    ( cd "$dir" && restic backup . "${LXC_EXCLUDE_FLAGS[@]}" \
        --compression "$RESTIC_COMPRESSION_FILES" \
        --tag "ct-${ctid}" --tag "lxc" "${NAME_TAG_FLAGS[@]}" "${CONFIG_ID_TAG_FLAGS[@]}" ) && rc=0 || rc=$?
    if [[ $rc -eq 0 ]]; then
        log_info "  OK: ${key} (${dir})"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
    else
        log_error "  Failed: ct-${ctid}-${key} (restic exit=${rc})"
    fi
}

backup_config() {
    local id="$1" subdir="$2" prefix="$3"
    local node
    node=$(hostname -s)
    local api_path
    if [[ "$subdir" == "qemu-server" ]]; then
        api_path="/nodes/${node}/qemu/${id}/config"
    else
        api_path="/nodes/${node}/lxc/${id}/config"
    fi
    if $RESTIC_DRY_RUN; then
        log_info "  [DRY-RUN] pvesh get ${api_path} | restic --stdin"
        return 0
    fi
    set +e +o pipefail
    pvesh get "$api_path" --output-format json 2>/dev/null | restic backup \
        --stdin --stdin-filename "${prefix}-${id}.conf" \
        --compression "$RESTIC_COMPRESSION_FILES" \
        --tag "${prefix}-${id}" --tag "config" "${NAME_TAG_FLAGS[@]}"
    local _ps=("${PIPESTATUS[@]}")
    set -e -o pipefail
    local _pvesh_rc=${_ps[0]:-1}
    local _restic_rc=${_ps[1]:-1}
    if [[ "$_restic_rc" -eq 0 && "$_pvesh_rc" -eq 0 ]]; then
        log_info "  OK: config"
    elif [[ "$_restic_rc" -eq 0 ]]; then
        log_warn "  Partial: config (pvesh exit=${_pvesh_rc})"
    else
        log_error "  Failed: config for ${prefix}-${id}"
        CURRENT_CONFIG_ID=""
        return 0
    fi
    # Read the newest config snapshot ID - it is the group identifier for
    # all disk backups of this backup run
    CURRENT_CONFIG_ID=$(restic snapshots --tag "${prefix}-${id},config" --json 2>/dev/null \
        | jq -r 'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || CURRENT_CONFIG_ID=""
    [[ -n "$CURRENT_CONFIG_ID" ]] && log_info "  Config-Snapshot-ID: ${CURRENT_CONFIG_ID}"
}

backup_local_dir() {
    local dir="$1"
    local dirname="${dir%/}"
    dirname="${dirname##*/}"
    if [[ ! -d "$dir" ]]; then
        log_error "  Directory not found: ${dir}"
        return 1
    fi
    if $RESTIC_DRY_RUN; then
        log_info "  [DRY-RUN] restic backup ${dir} ${LOCAL_EXCLUDE_FLAGS[*]:-}"
        return 0
    fi
    log_info "  Backing up: ${dir}"
    local rc=0
    restic backup "$dir" "${LOCAL_EXCLUDE_FLAGS[@]}" \
        --compression "$RESTIC_COMPRESSION_FILES" \
        --tag "local" --tag "dir-${dirname}" || rc=$?
    if [[ $rc -eq 0 ]]; then
        log_info "  OK: ${dir}"
        TOTAL_DISKS=$((TOTAL_DISKS+1))
    else
        log_error "  Failed: ${dir} (restic exit=${rc})"
    fi
}

restic() {
    local restic_opts=("--repo" "$RESTIC_REPOSITORY" "--password-file" "$RESTIC_PASSWORDFILE")
    if $RESTIC_VERBOSE; then
        restic_opts+=("--verbose")
    else
        restic_opts+=("--quiet")
    fi
    command restic "${restic_opts[@]}" "$@"
}

# restic keeps a lock inside the repository while it works. A run that was
# killed (power loss, OOM, Ctrl+C, ERR trap) leaves it behind and every later
# run fails with "repository is already locked".
# The flock at the top of this script already guarantees that only one instance
# runs at a time, so a lock found here is stale by definition. Plain
# 'restic unlock' only removes stale locks; if an exclusive lock survives that,
# it is removed explicitly.
clear_stale_repo_lock() {
    $RESTIC_DRY_RUN && return 0
    # No repo yet (first run) or no locks at all: nothing to do.
    restic list locks 2>/dev/null | grep -q . || return 0

    log_warn "Lock found in the repository (previous run aborted?) - removing it"
    restic unlock &>/dev/null || true
    if ! restic list locks 2>/dev/null | grep -q .; then
        log_info "Stale lock removed"
        return 0
    fi
    if restic unlock --remove-all &>/dev/null; then
        log_info "Exclusive lock removed"
    else
        log_error "restic unlock failed - remove it manually: restic unlock --remove-all"
    fi
}

get_snap_info() {
    local snap_id="$1"
    local info_json
    info_json=$(restic snapshots --json "$snap_id" 2>/dev/null) || return 1
    [[ -z "$info_json" || "$info_json" == "[]" ]] && return 1
    echo "$info_json" | jq -r '
        .[0] as $s |
        [
            ($s.short_id // ""),
            ([$s.tags // [] | .[] | select(startswith("parent-id=")) | sub("^parent-id="; "")][0] // ""),
            ([$s.tags // [] | .[] | select(startswith("parent-snap=")) | sub("^parent-snap="; "")][0] // ""),
            ($s.time // "")
        ] | @tsv
    ' 2>/dev/null
}


show_full_diff_age() {
    local disk_tag="$1"
    local inc_snap_id="$2"
    local parent_snap=""

    local _info
    _info=$(get_snap_info "$inc_snap_id" 2>/dev/null) || true
    IFS=$'\t' read -r _ parent_id parent_snap _ <<< "$_info"

    if [[ -z "$parent_id" ]]; then
        return 0
    fi

    local full_time inc_time
    full_time=$(restic snapshots --json "$parent_id" 2>/dev/null         | jq -r '.[0].time // empty' 2>/dev/null) || true
    inc_time=$(restic snapshots --json "$inc_snap_id" 2>/dev/null         | jq -r '.[0].time // empty' 2>/dev/null) || true

    if [[ -z "$full_time" || -z "$inc_time" ]]; then
        return 0
    fi

    local full_epoch inc_epoch age_days
    full_epoch=$(date -d "$full_time" +%s 2>/dev/null) || return 0
    inc_epoch=$(date -d "$inc_time" +%s 2>/dev/null) || return 0
    age_days=$(( (inc_epoch - full_epoch) / 86400 ))

    log_info "  Age: differential is ${age_days} day(s) after full (${parent_id})"
}


# Warning after the run if less than DISK_SPACE_WARN_PERCENT is free.
check_disk_space() {
    local free_bytes total_bytes threshold_bytes
    free_bytes=$(get_free_bytes) || return 0
    total_bytes=$(get_total_bytes) || return 0
    threshold_bytes=$(( total_bytes * DISK_SPACE_WARN_PERCENT / 100 ))

    log_info "Disk space: $(human_bytes "$free_bytes") free of $(human_bytes "$total_bytes") (warning threshold ${DISK_SPACE_WARN_PERCENT}% = $(human_bytes "$threshold_bytes"))"

    if [[ "$free_bytes" -lt "$threshold_bytes" ]]; then
        log_warn "WARNING: free space on the target drive is low, only $(human_bytes "$free_bytes") left (below ${DISK_SPACE_WARN_PERCENT}%)"
    fi
}

# ================== Post-Backup Integrity Check ==================

bytes_to_restic_size() {
    local bytes="$1"
    if [[ "$bytes" -ge 1073741824 ]]; then
        echo "$(( (bytes + 1073741823) / 1073741824 ))G"
    elif [[ "$bytes" -ge 1048576 ]]; then
        echo "$(( (bytes + 1048575) / 1048576 ))M"
    elif [[ "$bytes" -ge 1024 ]]; then
        echo "$(( (bytes + 1023) / 1024 ))K"
    else
        echo "${bytes}"
    fi
}

human_bytes() {
    local bytes="$1"
    if [[ "$bytes" -ge 1073741824 ]]; then
        awk -v b="$bytes" 'BEGIN{printf "%.1f GiB", b/1073741824}'
    elif [[ "$bytes" -ge 1048576 ]]; then
        awk -v b="$bytes" 'BEGIN{printf "%.1f MiB", b/1048576}'
    elif [[ "$bytes" -ge 1024 ]]; then
        awk -v b="$bytes" 'BEGIN{printf "%.1f KiB", b/1024}'
    else
        echo "${bytes} B"
    fi
}

run_post_backup_check() {
    [[ "$RESTIC_CHECK_AFTER_BACKUP" != "true" ]] && return 0
    $RESTIC_DRY_RUN && { log_info "Skipping check (dry-run)"; return 0; }

    local data_added="$TOTAL_DATA_ADDED"

    if [[ "$data_added" -le 0 ]]; then
        log_info "=== Post-backup integrity check ==="
        log_info "No new data written this run, skipping check"
        return 0
    fi

    local max_check_bytes=$(( RESTIC_CHECK_MAX_GB * 1024 * 1024 * 1024 ))
    local check_bytes="$data_added"
    [[ "$check_bytes" -gt "$max_check_bytes" ]] && check_bytes="$max_check_bytes"

    local check_size_str
    check_size_str=$(bytes_to_restic_size "$check_bytes")

    log_info "=== Post-backup integrity check ==="
    log_info "Data written this run: $(human_bytes "$data_added")"
    log_info "Checking: ${check_size_str} (max: ${RESTIC_CHECK_MAX_GB} GiB)"

    # Collect the output completely first, then evaluate it in the current
    # shell - otherwise log_error/ERRORS increments are lost in a pipe subshell
    local _check_out _check_rc=0
    _check_out=$(restic check --read-data-subset="$check_size_str" 2>&1) || _check_rc=$?
    local _line
    while IFS= read -r _line; do
        [[ -z "$_line" ]] && continue
        case "$_line" in
            *"no errors were found"*) log_info "check OK: $_line" ;;
            *"Fatal"*|*"error"*)     log_error "$_line" ;;
            *) echo "$_line" ;;
        esac
    done <<< "$_check_out"
    if [[ "$_check_rc" -eq 0 ]]; then
        log_info "Integrity check passed"
    else
        log_error "Integrity check FAILED (exit=${_check_rc}) - repository may be corrupted"
    fi
}

# ================== Space management for full backups ==================

# Determine the free space on the target drive, in bytes.
get_free_bytes() {
    local avail block_size
    avail=$(stat -f -c '%a' "$RESTIC_BASEFOLDER" 2>/dev/null) || return 1
    block_size=$(stat -f -c '%S' "$RESTIC_BASEFOLDER" 2>/dev/null) || block_size=512
    echo $(( avail * block_size ))
}

# Total size of the target drive, in bytes.
get_total_bytes() {
    local total block_size
    total=$(stat -f -c '%b' "$RESTIC_BASEFOLDER" 2>/dev/null) || return 1
    block_size=$(stat -f -c '%S' "$RESTIC_BASEFOLDER" 2>/dev/null) || block_size=512
    echo $(( total * block_size ))
}

# ================== Space calculation helpers ==================

# Determine the ZFS space actually in use (used, bytes) of a dataset/zvol.
zfs_used_bytes() {
    local dataset="$1" v
    v=$(zfs get -Hp -o value used "$dataset" 2>/dev/null) || return 1
    [[ "$v" =~ ^[0-9]+$ ]] || return 1
    echo "$v"
}

# Estimated stream size of a 'zfs send' in bytes, WITHOUT reading data.
# $1 dataset, $2 target snapshot name, $3 base snapshot name (empty = full).
# The flags must match those of the real send (-w), otherwise the estimate
# drifts. ZFS deliberately returns an estimate, not an exact value.
zfs_send_size() {
    local dataset="$1" target_snap="$2" base_snap="${3:-}"
    local args=(-w -n -P) out=""
    [[ -n "$base_snap" ]] && args+=(-i "${dataset}@${base_snap}")
    set +e +o pipefail
    out=$(zfs send "${args[@]}" "${dataset}@${target_snap}" 2>/dev/null \
          | awk '$1=="size"{print $2; exit}')
    set -e -o pipefail
    [[ "$out" =~ ^[0-9]+$ ]] || return 1
    echo "$out"
}

# Space occupied by a single restic snapshot (bytes).
# With compression enabled raw-data tends to overestimate - which is the safe
# direction for space planning.
snapshot_repo_bytes() {
    local snap_id="$1" v=""
    set +e +o pipefail
    v=$(restic stats --mode raw-data --json "$snap_id" 2>/dev/null \
        | grep -x '^{.*}' | jq -r '.total_size // 0' 2>/dev/null)
    set -e -o pipefail
    [[ "$v" =~ ^[0-9]+$ ]] || return 1
    echo "$v"
}

# Size of a directory tree in bytes (only needed when there is no backup of
# this tag yet - which is why it is called that late on purpose).
dir_size_bytes() {
    local v
    v=$(du -sb "$1" 2>/dev/null | awk '{print $1; exit}') || return 1
    [[ "$v" =~ ^[0-9]+$ ]] || return 1
    echo "$v"
}

# Estimate the space required by a file based or RAW backup.
#   $1        = tag filter (comma separated, restic semantics = AND)
#   $2..$n    = command returning the FULL source size (lazy - only executed
#               when there is no backup for this tag yet)
# Present     -> ESTIMATED_BACKUP_INCREASE_PERCENTAGE % of the space in use
# Not present -> full source size, without assuming compression
estimate_backup_need() {
    local tag_csv="$1"; shift
    local last_id repo_bytes full
    last_id=$(restic snapshots --tag "$tag_csv" --json 2>/dev/null \
        | jq -r 'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || last_id=""
    if [[ -n "$last_id" ]]; then
        repo_bytes=$(snapshot_repo_bytes "$last_id") || repo_bytes=0
        echo $(( repo_bytes * ESTIMATED_BACKUP_INCREASE_PERCENTAGE / 100 ))
        return 0
    fi
    full=$("$@" 2>/dev/null) || full=0
    [[ "$full" =~ ^[0-9]+$ ]] || full=0
    echo "$full"
}

# =================== Snapshot index ===================
# One pass over 'restic snapshots', chronologically sorted (oldest first).
# SNAP_IDS keeps that order, so "the newest snapshot of a group" is simply the
# last one seen while iterating, and a generation is a contiguous range.
declare -a SNAP_IDS=()
declare -A SNAP_TIME=()
declare -A SNAP_DAY=()
declare -A SNAP_TAGS=()

load_snapshot_index() {
    SNAP_IDS=(); SNAP_TIME=(); SNAP_DAY=(); SNAP_TAGS=()
    local rows _id _time _tags
    rows=$(restic snapshots --json 2>/dev/null | jq -r '
        sort_by(.time) | .[]
        | [ (.short_id // ""), ((.time // "")[0:16] | sub("T"; " ")),
            ((.tags // []) | join(",")) ]
        | @tsv
    ' 2>/dev/null) || return 1
    [[ -z "$rows" ]] && return 0
    while IFS=$'\t' read -r _id _time _tags; do
        [[ -z "$_id" ]] && continue
        SNAP_IDS+=("$_id")
        SNAP_TIME["$_id"]="$_time"
        SNAP_DAY["$_id"]="${_time:0:10}"
        SNAP_TAGS["$_id"]=",${_tags},"
    done <<< "$rows"
    return 0
}

snap_has_tag() { [[ "${SNAP_TAGS[$1]:-}" == *",$2,"* ]]; }

# Value of the tag beginning with $2 (prefix including '='), "" if absent.
snap_tag_value() {
    local _all="${SNAP_TAGS[$1]:-}" _v
    _v="${_all#*,$2}"
    [[ "$_v" == "$_all" ]] && { echo ""; return 1; }
    echo "${_v%%,*}"
}

# Volume tag (vm-101-scsi0, ct-100-rootfs) of a snapshot, "" if it has none.
snap_disk_tag() {
    [[ "${SNAP_TAGS[$1]:-}" =~ ,((vm|ct)-[0-9]+-[^,]+), ]] && { echo "${BASH_REMATCH[1]}"; return 0; }
    echo ""; return 1
}

# efidisk*/tpmstate* are backed up full on EVERY run. They must not be
# mistaken for a full generation of their own.
snap_is_always_full_volume() {
    local _d
    _d=$(snap_disk_tag "$1") || return 1
    is_always_full_disk "${_d##*-}"
}

# Group of a self-contained backup (LXC, LOCAL_DIRS, RAW) - one line of
# history per group. Empty for fulls, differentials and config snapshots.
snap_group_key() {
    local _all="${SNAP_TAGS[$1]:-}" _k=""
    if snap_has_tag "$1" lxc || snap_has_tag "$1" raw; then
        _k=$(snap_disk_tag "$1") || _k=""
        [[ -z "$_k" && "$_all" =~ ,((vm|ct)-[0-9]+), ]] && _k="${BASH_REMATCH[1]}"
    elif snap_has_tag "$1" local; then
        [[ "$_all" =~ ,(dir-[^,]+), ]] && _k="${BASH_REMATCH[1]}"
    fi
    echo "$_k"
    [[ -n "$_k" ]]
}

# Backup type and owner of a snapshot, for log lines.
snap_kind() {
    snap_has_tag "$1" raw          && { echo "RAW";    return 0; }
    snap_has_tag "$1" full         && { echo "FULL";   return 0; }
    snap_has_tag "$1" differential && { echo "DIFF";   return 0; }
    snap_has_tag "$1" config       && { echo "CONFIG"; return 0; }
    snap_has_tag "$1" lxc          && { echo "LXC-FS"; return 0; }
    snap_has_tag "$1" local        && { echo "LOCAL";  return 0; }
    echo "?"
}
snap_owner() {
    [[ "${SNAP_TAGS[$1]:-}" =~ ,((vm|ct)-[0-9]+), ]] && { echo "${BASH_REMATCH[1]}"; return 0; }
    echo "-"
}

# Volume resp. directory a snapshot belongs to, as shown in the DISK column.
snap_disk_column() {
    local _d
    _d=$(snap_disk_tag "$1") || _d=""
    [[ -n "$_d" ]] && { echo "${_d#*-*-}"; return 0; }
    [[ "${SNAP_TAGS[$1]:-}" =~ ,dir-([^,]+), ]] && { echo "${BASH_REMATCH[1]}"; return 0; }
    echo "-"
}

# Restore size of a snapshot in bytes.
snap_restore_size() {
    local v
    v=$(restic stats --mode restore-size --json "$1" 2>/dev/null \
        | jq -r '.total_size // 0' 2>/dev/null) || v=0
    [[ "$v" =~ ^[0-9]+$ ]] || v=0
    echo "$v"
}

# Newest backup per LXC/LOCAL/RAW group. Those are never dropped while
# reducing - without the guard a run could throw away the only container
# backup while VM history survives. Only the final wipe touches them.
declare -A NEWEST_OF_GROUP=()
build_newest_of_group() {
    NEWEST_OF_GROUP=()
    [[ ${#SNAP_IDS[@]} -eq 0 ]] && return 0
    local _id _k
    for _id in "${SNAP_IDS[@]}"; do
        _k=$(snap_group_key "$_id") || continue
        NEWEST_OF_GROUP["$_k"]="$_id"
    done
    return 0
}

# Drop config snapshots from a candidate list that a SURVIVING backup still
# points at. The config snapshot of a run is written seconds BEFORE the disks
# of that run, so it always lands in the range of the previous generation -
# without this filter a kept full would lose its configuration and could no
# longer be restored completely.
filter_protected_configs() {
    [[ $# -eq 0 ]] && return 0
    local -A doomed=() protected=()
    local _id _ref
    for _id in "$@"; do doomed["$_id"]=1; done
    for _id in "${SNAP_IDS[@]}"; do
        [[ -n "${doomed[$_id]:-}" ]] && continue
        _ref=$(snap_tag_value "$_id" "config-id=") || _ref=""
        [[ -n "$_ref" ]] && protected["$_ref"]=1
    done
    for _id in "$@"; do
        snap_has_tag "$_id" config && [[ -n "${protected[$_id]:-}" ]] && continue
        printf '%s\n' "$_id"
    done
    return 0
}

# Forget the given ids (protected configs excluded), clean up what that leaves
# behind and prune. $1 is the log label. Return 1 when nothing was removed.
forget_and_prune() {
    local label="$1"; shift
    [[ $# -eq 0 ]] && return 1
    local -a ids=()
    mapfile -t ids < <(filter_protected_configs "$@")
    [[ ${#ids[@]} -eq 0 ]] && return 1

    log_warn "  ${label}: removing ${#ids[@]} snapshot(s)"
    restic forget "${ids[@]}" &>/dev/null || { log_error "  forget failed"; return 1; }
    forget_orphans || true
    restic prune &>/dev/null || log_warn "  restic prune failed"
    return 0
}

# Leftovers of a reduction step: differentials whose full it removed (they can
# never be restored again) and config snapshots no backup refers to any more.
# Return: 0 if something was removed.
forget_orphans() {
    load_snapshot_index || return 1
    [[ ${#SNAP_IDS[@]} -eq 0 ]] && return 1

    local _id _p _ref
    local -A present=() dead=() used=()
    for _id in "${SNAP_IDS[@]}"; do present["$_id"]=1; done

    local -a doomed=()
    for _id in "${SNAP_IDS[@]}"; do
        snap_has_tag "$_id" differential || continue
        _p=$(snap_tag_value "$_id" "parent-id=") || _p=""
        [[ -n "$_p" && -n "${present[$_p]:-}" ]] && continue
        dead["$_id"]=1; doomed+=("$_id")
    done

    # configs are judged against what survives the removal above
    for _id in "${SNAP_IDS[@]}"; do
        [[ -n "${dead[$_id]:-}" ]] && continue
        _ref=$(snap_tag_value "$_id" "config-id=") || _ref=""
        [[ -n "$_ref" ]] && used["$_ref"]=1
    done
    for _id in "${SNAP_IDS[@]}"; do
        snap_has_tag "$_id" config || continue
        [[ -z "${used[$_id]:-}" ]] && doomed+=("$_id")
    done

    [[ ${#doomed[@]} -eq 0 ]] && return 1
    log_info "  Removing ${#doomed[@]} orphaned snapshot(s) - nothing refers to them any more"
    restic forget "${doomed[@]}" &>/dev/null || return 1
    return 0
}
# ---------------------------------------------------------------------------
# Pre-backup pruning. REQUIRED_BYTES comes from the planning phase; if it does
# not fit, the repository is reduced from old to new in one loop:
#
#   1. thin out the oldest full generation (its fulls and, per volume, its
#      newest differential stay)
#   2. otherwise drop that generation entirely - dropping the last remaining
#      full triggers the low space warning
#   3. the next generation is now the oldest one - back to 1
#
# A generation is the run that wrote a full for at least one regular volume
# plus everything up to the next such run, i.e. a contiguous range of the
# chronological snapshot index. Without any full (RAW/LXC only) the reduction
# runs day by day; when nothing is left to release, the rest is wiped.
# The reasoning behind all of it is documented in config.cfg.
# ---------------------------------------------------------------------------

# Index of the first snapshot of every full generation, oldest first.
# All snapshots BEFORE the second entry belong to the oldest generation.
generation_start_indices() {
    [[ ${#SNAP_IDS[@]} -eq 0 ]] && return 0
    local _i _id _lastday=""
    for _i in "${!SNAP_IDS[@]}"; do
        _id="${SNAP_IDS[$_i]}"
        snap_has_tag "$_id" full || continue
        snap_is_always_full_volume "$_id" && continue
        [[ "${SNAP_DAY[$_id]}" == "$_lastday" ]] && continue
        _lastday="${SNAP_DAY[$_id]}"
        printf '%s\n' "$_i"
    done
    return 0
}

# Upper bound (exclusive index) of the oldest generation.
# Sets OLDEST_GEN_IS_LAST=true when no further generation follows.
OLDEST_GEN_END=0
OLDEST_GEN_IS_LAST=false
oldest_generation_range() {
    local -a starts=()
    mapfile -t starts < <(generation_start_indices)
    [[ ${#starts[@]} -eq 0 ]] && return 1
    if [[ ${#starts[@]} -ge 2 ]]; then
        OLDEST_GEN_END="${starts[1]}"
        OLDEST_GEN_IS_LAST=false
    else
        OLDEST_GEN_END="${#SNAP_IDS[@]}"
        OLDEST_GEN_IS_LAST=true
    fi
    return 0
}

# Step 1: thin out the oldest generation. Its fulls and, per volume, its
# newest differential survive; so does the newest backup of every
# LXC/LOCAL/RAW group. Return 1 when there was nothing to thin out.
reduce_thin_oldest_generation() {
    oldest_generation_range || return 1
    local _i _id _vol
    local -A keep=()

    # newest differential per volume inside the range
    for ((_i = 0; _i < OLDEST_GEN_END; _i++)); do
        _id="${SNAP_IDS[$_i]}"
        snap_has_tag "$_id" differential || continue
        _vol=$(snap_disk_tag "$_id") || continue
        keep["$_vol"]="$_id"
    done
    local -A keep_ids=()
    for _vol in "${!keep[@]}"; do keep_ids["${keep[$_vol]}"]=1; done

    local -a doomed=()
    for ((_i = 0; _i < OLDEST_GEN_END; _i++)); do
        _id="${SNAP_IDS[$_i]}"
        snap_has_tag "$_id" full && continue
        [[ -n "${keep_ids[$_id]:-}" ]] && continue
        _is_newest_of_group "$_id" && continue
        doomed+=("$_id")
    done
    [[ ${#doomed[@]} -eq 0 ]] && return 1

    forget_and_prune "Thinning out the oldest full generation (its fulls and the newest differential per volume stay)" \
        "${doomed[@]}" || return 1
    return 0
}

# Step 2: drop the oldest generation completely.
# Return 1 when there is no generation left.
reduce_drop_oldest_generation() {
    oldest_generation_range || return 1
    local _i _id
    local -a doomed=()
    for ((_i = 0; _i < OLDEST_GEN_END; _i++)); do
        _id="${SNAP_IDS[$_i]}"
        _is_newest_of_group "$_id" && continue
        doomed+=("$_id")
    done
    [[ ${#doomed[@]} -eq 0 ]] && return 1

    if $OLDEST_GEN_IS_LAST; then
        forget_and_prune "Removing the LAST remaining full generation" "${doomed[@]}" || return 1
        _low_memory_warning
    else
        forget_and_prune "Removing the oldest full generation" "${doomed[@]}" || return 1
    fi
    return 0
}

# Fallback for repositories without any full backup (RAW/LXC only): remove the
# oldest calendar day. Return 1 when there is nothing left to remove.
reduce_drop_oldest_day() {
    [[ ${#SNAP_IDS[@]} -eq 0 ]] && return 1
    local _id _day="" 
    local -a doomed=()
    for _id in "${SNAP_IDS[@]}"; do
        _is_newest_of_group "$_id" && continue
        [[ -z "$_day" ]] && _day="${SNAP_DAY[$_id]}"
        [[ "${SNAP_DAY[$_id]}" == "$_day" ]] && doomed+=("$_id")
    done
    [[ ${#doomed[@]} -eq 0 ]] && return 1

    forget_and_prune "Removing the oldest backup day ${_day}" "${doomed[@]}" || return 1
    return 0
}

_is_newest_of_group() {
    local _k
    _k=$(snap_group_key "$1") || return 1
    [[ "${NEWEST_OF_GROUP[$_k]:-}" == "$1" ]]
}

LOW_MEMORY_WARNED=false
_low_memory_warning() {
    $LOW_MEMORY_WARNED && return 0
    LOW_MEMORY_WARNED=true
    [[ "${LOW_MEMORY_WARNING^^}" == "TRUE" ]] && \
        log_warn "WARNING: target drive too small! A larger drive is required!"
    return 0
}

# Last resort: nothing can be released in generations or days any more.
_wipe_repository() {
    load_snapshot_index || return 1
    [[ ${#SNAP_IDS[@]} -eq 0 ]] && return 1
    log_warn "  Wiping the remaining ${#SNAP_IDS[@]} snapshot(s) - nothing else can be released"
    restic forget "${SNAP_IDS[@]}" &>/dev/null || return 1
    restic prune &>/dev/null || log_warn "  restic prune (final) failed"
    return 0
}

# Measure the space once and compare it against the requirement.
# Return: 0 = enough free, 1 = too little. Sets SPACE_FREE_BYTES.
SPACE_FREE_BYTES=0
space_is_sufficient() {
    SPACE_FREE_BYTES=$(get_free_bytes) || return 0
    [[ "$SPACE_FREE_BYTES" -ge "$REQUIRED_BYTES" ]]
}

# Central space check / release BEFORE the backup.
ensure_space_available() {
    $FULL_SPACE_CHECK_DONE && return 0
    FULL_SPACE_CHECK_DONE=true
    $RESTIC_DRY_RUN && return 0

    local total_bytes guard=0
    log_info "=== Space check before backup ==="

    total_bytes=$(get_total_bytes) || total_bytes=0
    if ! SPACE_FREE_BYTES=$(get_free_bytes); then
        log_warn "  Cannot determine free space - check skipped"
        return 0
    fi
    log_info "  Required: $(human_bytes "$REQUIRED_BYTES")  |  Free: $(human_bytes "$SPACE_FREE_BYTES") / $(human_bytes "$total_bytes")"

    if [[ "$SPACE_FREE_BYTES" -ge "$REQUIRED_BYTES" ]]; then
        log_info "  Enough free space -> no pre-backup pruning needed"
        return 0
    fi

    log_warn "  Not enough free space -> pre-backup pruning"

    while ! space_is_sufficient; do
        guard=$((guard+1))
        [[ $guard -gt 1000 ]] && { log_error "  Pruning aborted (guard limit)"; break; }

        load_snapshot_index || { log_warn "  Cannot read the snapshot list - pruning aborted"; break; }
        build_newest_of_group

        log_info "  Free: $(human_bytes "$SPACE_FREE_BYTES") - still $(human_bytes "$REQUIRED_BYTES") required"

        reduce_thin_oldest_generation && continue
        reduce_drop_oldest_generation && continue
        reduce_drop_oldest_day        && continue

        # Nothing left that a reduction step could release.
        _low_memory_warning
        _wipe_repository || true
        break
    done

    SPACE_FREE_BYTES=$(get_free_bytes) || SPACE_FREE_BYTES=0
    if [[ "$SPACE_FREE_BYTES" -ge "$REQUIRED_BYTES" ]]; then
        log_info "  Enough space after pruning: $(human_bytes "$SPACE_FREE_BYTES") free"
    else
        log_warn "  Still not enough space: $(human_bytes "$SPACE_FREE_BYTES") free, $(human_bytes "$REQUIRED_BYTES") required"
    fi
    return 0
}

# ================== Retention: forget + prune ==================
# The keep-daily/weekly/monthly policy does not know about our snapshot
# relations, so its removal list is corrected afterwards:
#   - a full that a KEPT differential still points at stays,
#   - and so does the config snapshot of everything that stays. A full without
#     its VM configuration would no longer be fully restorable, and the config
#     costs a few hundred bytes.
run_forget_prune() {
    log_info "=== Retention: forget + prune ==="
    log_info "Policy: keep-daily=${KEEP_DAILY} keep-weekly=${KEEP_WEEKLY} keep-monthly=${KEEP_MONTHLY}"

    local remove_ids
    remove_ids=$(restic forget \
        --keep-daily  "$KEEP_DAILY" \
        --keep-weekly "$KEEP_WEEKLY" \
        --keep-monthly "$KEEP_MONTHLY" \
        --group-by host,paths \
        --dry-run --json 2>/dev/null \
        | jq -r '.[].remove[]?.short_id // empty' 2>/dev/null) || {
        log_error "restic forget --dry-run failed"
        return 1
    }
    [[ -z "$remove_ids" ]] && { log_info "Nothing to forget"; return 0; }

    load_snapshot_index || { log_error "Cannot read the snapshot list"; return 1; }

    local -A doomed=() reason=()
    local _id _p _ref
    while IFS= read -r _id; do
        [[ -n "$_id" ]] && doomed["$_id"]=1
    done <<< "$remove_ids"

    # 1) fulls that a surviving differential still needs
    for _id in "${SNAP_IDS[@]}"; do
        [[ -n "${doomed[$_id]:-}" ]] && continue
        snap_has_tag "$_id" differential || continue
        _p=$(snap_tag_value "$_id" "parent-id=") || _p=""
        [[ -n "$_p" && -n "${doomed[$_p]:-}" ]] || continue
        unset "doomed[$_p]"
        reason["$_p"]="still referenced by a kept differential"
    done

    # 2) configs of everything that survives (including the fulls kept above)
    for _id in "${SNAP_IDS[@]}"; do
        [[ -n "${doomed[$_id]:-}" ]] && continue
        _ref=$(snap_tag_value "$_id" "config-id=") || _ref=""
        [[ -n "$_ref" && -n "${doomed[$_ref]:-}" ]] || continue
        unset "doomed[$_ref]"
        reason["$_ref"]="config of a kept backup"
    done

    local -a to_forget=()
    local _disk
    for _id in "${SNAP_IDS[@]}"; do
        [[ -n "${reason[$_id]:-}" ]] && {
            log_info "  Protected: ${_id}  ${SNAP_TIME[$_id]}  $(snap_kind "$_id")  $(snap_owner "$_id")  (${reason[$_id]})"
            continue
        }
        [[ -n "${doomed[$_id]:-}" ]] || continue
        _disk=$(snap_disk_tag "$_id") || _disk="-"
        log_info "  Prune: ${_id}  ${SNAP_TIME[$_id]}  $(snap_kind "$_id")  ${_disk:--}"
        to_forget+=("$_id")
    done

    if [[ ${#to_forget[@]} -gt 0 ]]; then
        if restic forget "${to_forget[@]}"; then
            log_info "forget OK (${#to_forget[@]} removed, ${#reason[@]} protected)"
        else
            log_error "restic forget failed"
        fi
    else
        log_info "Nothing to forget (all ${#reason[@]} protected)"
    fi

    if [[ "$PRUNE_AFTER_BACKUP" == "true" ]]; then
        if restic prune; then
            log_info "prune OK"
        else
            log_error "restic prune failed"
        fi
    fi
}

# ================== Backup overview ==================
# Identical to 'ResticRestore.sh --list --size': one line per snapshot with the
# cumulative restore size. Sizes cost one 'restic stats' call per snapshot, so
# a large repository takes a moment - set PRINT_BACKUP_OVERVIEW=false to skip.
#
# The SIZE column answers "what does restoring THIS line cost", not "what does
# it occupy in the repository":
#   FULL / RAW / LXC-FS / LOCAL : its own size
#   DIFF                        : itself plus its parent full
#   CONFIG                      : the whole run it belongs to
print_backup_overview() {
    [[ "$PRINT_BACKUP_OVERVIEW" == "true" ]] || return 0
    log_info "=== Backups in the repository ==="

    load_snapshot_index || { log_warn "Cannot read the snapshot list"; return 1; }
    if [[ ${#SNAP_IDS[@]} -eq 0 ]]; then
        log_info "Repository is empty"
        return 0
    fi

    local _id _kind _cfg _parent
    local -A KIND=() CFG=() PARENT=() SIZE=()
    for _id in "${SNAP_IDS[@]}"; do
        _kind=$(snap_kind "$_id")
        KIND["$_id"]="$_kind"
        if [[ "$_kind" == "CONFIG" ]]; then
            CFG["$_id"]="$_id"
        else
            _cfg=$(snap_tag_value "$_id" "config-id=") || _cfg=""
            CFG["$_id"]="$_cfg"
        fi
        _parent=$(snap_tag_value "$_id" "parent-id=") || _parent=""
        PARENT["$_id"]="$_parent"
        SIZE["$_id"]=$(snap_restore_size "$_id")
    done

    local rows="" _name _cum _k _pp
    for _id in "${SNAP_IDS[@]}"; do
        _cum=0
        case "${KIND[$_id]}" in
            DIFF)
                _cum=${SIZE[$_id]:-0}
                _pp="${PARENT[$_id]}"
                [[ -n "$_pp" ]] && _cum=$(( _cum + ${SIZE[$_pp]:-0} ))
                ;;
            CONFIG)
                # everything that carries the config-id of this run
                for _k in "${SNAP_IDS[@]}"; do
                    [[ "${CFG[$_k]}" == "$_id" && "$_k" != "$_id" ]] || continue
                    _cum=$(( _cum + ${SIZE[$_k]:-0} ))
                    if [[ "${KIND[$_k]}" == "DIFF" ]]; then
                        _pp="${PARENT[$_k]}"
                        [[ -n "$_pp" ]] && _cum=$(( _cum + ${SIZE[$_pp]:-0} ))
                    fi
                done
                ;;
            *)
                _cum=${SIZE[$_id]:-0}
                ;;
        esac
        _name=$(snap_tag_value "$_id" "name=") || _name=""
        rows+="${_id}"$'\t'"${SNAP_TIME[$_id]}"$'\t'"${KIND[$_id]}"$'\t'"$(snap_owner "$_id")"$'\t'"${_name:--}"$'\t'"$(snap_disk_column "$_id")"$'\t'"${PARENT[$_id]:--}"$'\t'"${CFG[$_id]:--}"$'\t'"$(human_bytes "$_cum")"$'\n'
    done

    local table
    table=$( { printf 'SNAP ID\tBackup Time\tTYPE\tMACHINE\tNAME\tDISK\tPARENT\tCONFIG\tSIZE\n'
               printf '%s' "$rows"
             } | awk -F'\t' '{printf "%-10s  %-16s  %-7s  %-9s  %-24s  %-10s  %10s  %-10s  %s\n", $1, $2, $3, $4, $5, $6, $9, $7, $8}' )
    local _line
    while IFS= read -r _line; do
        log_info "$_line"
    done <<< "$table"
    return 0
}

# ================== Inventory ==================
# Resolves and measures all volumes to be backed up ONCE per run. Without it
# the mode automatic and the planning phase would repeat the very same
# 'pvesm path', 'zfs get' and 'du' calls twice.
#
# Line format:
#   INV_VM_DISKS / INV_CT_VOLS : id|disk_key|voltype|path|dataset|bytes
#   INV_LOCAL_DIRS             : path|tagname|bytes
# voltype : zvol | zfsdir | blockdev | file | dir
# bytes   : space in use, -1 = deliberately not determined (see LOCAL_DIRS)
INV_VM_DISKS=()
INV_CT_VOLS=()
INV_LOCAL_DIRS=()
INV_TOTAL_USED=0
INV_BUILT=false

# du can be expensive on large trees. It is only measured when the mode
# automatic needs the value - otherwise it stays -1 and the planning phase
# determines it only if there is no backup of that tag yet.
_inv_want_dir_size() {
    # AUTO_RAW_FACTOR is validated at startup and may be fractional, so the
    # comparison goes through awk instead of bash arithmetic.
    LC_ALL=C awk -v f="$AUTO_RAW_FACTOR" 'BEGIN{ exit !(f > 0) }' 
}

# Arguments: <vm ids...> -- <ct ids...>
build_inventory() {
    $INV_BUILT && return 0
    INV_BUILT=true
    INV_VM_DISKS=(); INV_CT_VOLS=(); INV_LOCAL_DIRS=(); INV_TOTAL_USED=0

    local -a vm_ids=() ct_ids=()
    local _cur="vm" _a
    for _a in "$@"; do
        if [[ "$_a" == "--" ]]; then _cur="ct"; continue; fi
        if [[ "$_cur" == "vm" ]]; then vm_ids+=("$_a"); else ct_ids+=("$_a"); fi
    done

    log_info "=== Inventory (volumes are resolved and measured once) ==="

    local vmid ctid CFG line key value volume _type _path _ds _sz
    for vmid in ${vm_ids[@]+"${vm_ids[@]}"}; do
        is_excluded "$vmid" && continue
        CFG=$(qm config "$vmid" 2>/dev/null) || continue
        if [[ "$SKIP_TEMPLATES" == true ]] && echo "$CFG" | grep -q '^template:.*1'; then
            continue
        fi
        while IFS= read -r line; do
            [[ -z "$line" ]] && continue
            key="${line%%:*}"
            value="${line#*:}"
            value="${value#"${value%%[![:space:]]*}"}"
            volume="${value%%,*}"
            volume="${volume#file=}"
            [[ -z "$volume" || "$volume" == "none" ]] && continue
            if [[ "$key" != efidisk* ]] && [[ ",${value}," == *",backup=0"* ]]; then
                continue
            fi
            # A CD/DVD drive is not a disk. Without this check an attached ISO
            # (ide2: local:iso/foo.iso,media=cdrom) resolves to a readable file
            # and would end up in the repository as a RAW image.
            if [[ ",${value}," == *",media=cdrom,"* ]]; then
                continue
            fi
            if ! resolve_volume "$volume"; then
                log_warn "  VM ${vmid} ${key}: cannot resolve volume ${volume} - skipping"
                continue
            fi
            _type="$VOL_KIND"; _path="$VOL_PATH"; _ds=""
            [[ "$_type" == "zvol" ]] && _ds="${_path#/dev/zvol/}"
            _sz=$(raw_allocated_bytes "$_path") || _sz=0
            [[ "$_sz" =~ ^[0-9]+$ ]] || _sz=0
            INV_VM_DISKS+=("${vmid}|${key}|${_type}|${_path}|${_ds}|${_sz}")
            INV_TOTAL_USED=$(( INV_TOTAL_USED + _sz ))
        done < <(echo "$CFG" | grep -E "$VM_DISK_REGEX" || true)
    done

    for ctid in ${ct_ids[@]+"${ct_ids[@]}"}; do
        is_excluded "$ctid" && continue
        CFG=$(pct config "$ctid" 2>/dev/null) || continue
        if [[ "$SKIP_TEMPLATES" == true ]] && echo "$CFG" | grep -q '^template:.*1'; then
            continue
        fi
        while IFS= read -r line; do
            [[ -z "$line" ]] && continue
            key="${line%%:*}"
            value="${line#*:}"
            value="${value#"${value%%[![:space:]]*}"}"
            volume="${value%%,*}"
            volume="${volume#file=}"
            [[ -z "$volume" || "$volume" == "none" ]] && continue
            if [[ ",${value}," == *",backup=0"* ]]; then
                continue
            fi
            if ! resolve_volume "$volume"; then
                log_warn "  CT ${ctid} ${key}: cannot resolve volume ${volume} - skipping"
                continue
            fi
            _type="$VOL_KIND"; _path="$VOL_PATH"; _ds=""
            if [[ "$_type" == "zvol" ]]; then
                _ds="${_path#/dev/zvol/}"
            elif [[ "$_type" == "dir" ]] && command -v zfs &>/dev/null; then
                _ds=$(zfs list -H -o name "$_path" 2>/dev/null) || _ds=""
                [[ -n "$_ds" ]] && _type="zfsdir"
            fi
            case "$_type" in
                zfsdir) _sz=$(zfs_used_bytes "$_ds") || _sz=0 ;;
                dir)    if _inv_want_dir_size; then _sz=$(dir_size_bytes "$_path") || _sz=-1; else _sz=-1; fi ;;
                *)      _sz=$(raw_allocated_bytes "$_path") || _sz=0 ;;
            esac
            [[ "$_sz" =~ ^-?[0-9]+$ ]] || _sz=0
            INV_CT_VOLS+=("${ctid}|${key}|${_type}|${_path}|${_ds}|${_sz}")
            [[ "$_sz" -gt 0 ]] && INV_TOTAL_USED=$(( INV_TOTAL_USED + _sz ))
        done < <(echo "$CFG" | grep -E "$LXC_FS_REGEX" || true)
    done

    if [[ -n "$LOCAL_DIRS" ]]; then
        local _oldifs="$IFS" _dir _dirname
        IFS=$' \t\n'
        for _dir in $LOCAL_DIRS; do
            _dir="${_dir%\"}"
            _dir="${_dir#\"}"
            [[ -d "$_dir" ]] || continue
            _dirname="${_dir%/}"
            _dirname="${_dirname##*/}"
            if _inv_want_dir_size; then _sz=$(dir_size_bytes "$_dir") || _sz=-1; else _sz=-1; fi
            [[ "$_sz" =~ ^-?[0-9]+$ ]] || _sz=-1
            INV_LOCAL_DIRS+=("${_dir}|${_dirname}|${_sz}")
            [[ "$_sz" -gt 0 ]] && INV_TOTAL_USED=$(( INV_TOTAL_USED + _sz ))
        done
        IFS="$_oldifs"
    fi

    log_info "  ${#INV_VM_DISKS[@]} VM disk(s), ${#INV_CT_VOLS[@]} CT volume(s), ${#INV_LOCAL_DIRS[@]} local dir(s) | in use: $(human_bytes "$INV_TOTAL_USED")"
    return 0
}

# Print all inventory rows of one machine.
inv_entries() {
    local kind="$1" id="$2" e
    if [[ "$kind" == "vm" ]]; then
        for e in ${INV_VM_DISKS[@]+"${INV_VM_DISKS[@]}"}; do
            [[ "${e%%|*}" == "$id" ]] && echo "$e"
        done
    else
        for e in ${INV_CT_VOLS[@]+"${INV_CT_VOLS[@]}"}; do
            [[ "${e%%|*}" == "$id" ]] && echo "$e"
        done
    fi
    return 0
}

# ================== Automatic mode selection ==================
# A zfs-send backup writes a complete generation on every full. If at least
# AUTO_RAW_FACTOR generations do not fit on the target drive, every second
# full is bound to become a space problem - then RAW is the better choice,
# because restic deduplicates identical blocks there and no second full copy
# is created at all. Uses INV_TOTAL_USED from the inventory.
auto_select_backup_mode() {
    [[ "$BACKUP_MODE" == "raw" ]] && return 0
    _inv_want_dir_size || return 0

    local total_target
    total_target=$(get_total_bytes) || return 0
    [[ "$total_target" -gt 0 ]] || return 0
    [[ "$INV_TOTAL_USED" -gt 0 ]] || return 0

    # bash arithmetic is integer only - AUTO_RAW_FACTOR may be fractional, so
    # the product is computed with awk. %.0f (not %d) avoids any integer
    # conversion limits on multi-terabyte values.
    local needed
    needed=$(LC_ALL=C awk -v u="$INV_TOTAL_USED" -v f="$AUTO_RAW_FACTOR" \
        'BEGIN{ printf "%.0f", u * f }') || needed=""
    [[ "$needed" =~ ^[0-9]+$ ]] || {
        log_warn "  Could not compute the space requirement (AUTO_RAW_FACTOR=${AUTO_RAW_FACTOR}) - keeping BACKUP_MODE=${BACKUP_MODE}"
        return 0
    }
    log_info "=== Backup mode check ==="
    log_info "  Source data in use: $(human_bytes "$INV_TOTAL_USED")  |  target drive: $(human_bytes "$total_target")"
    log_info "  Needed at AUTO_RAW_FACTOR=${AUTO_RAW_FACTOR}: $(human_bytes "$needed")"

    if [[ "$total_target" -ge "$needed" ]]; then
        log_info "  Target drive is large enough -> keeping BACKUP_MODE=${BACKUP_MODE}"
        return 0
    fi

    if [[ "$BACKUP_MODE" == "zfs" ]]; then
        log_warn "  Target drive holds less than ${AUTO_RAW_FACTOR}x the source data, but BACKUP_MODE=zfs is set explicitly"
        log_warn "  Expect space problems as soon as a second full backup is written - consider BACKUP_MODE=auto or raw"
        return 0
    fi

    BACKUP_MODE="raw"
    log_warn "  Target drive holds less than ${AUTO_RAW_FACTOR}x the source data -> switching to BACKUP_MODE=raw"
    log_warn "  Reason: a second zfs-send full would not fit; RAW images deduplicate instead of writing a second full copy"
    return 0
}

# ================== Planning phase ==================
# Two jobs, both WITHOUT writing any data:
#   1) All-or-nothing full rule: if ANY ZFS disk needs a full, the whole run
#      is backed up full (FORCE_ALL_FULL=true).
#   2) Exact space requirement REQUIRED_BYTES for the pre-backup check.
#
# The only source is the inventory - nothing is resolved here any more.

# "vmid|disk_key|dataset|current_snap|base_snap"  (base empty -> full needed)
PLAN_ZFS_DISKS=()
# "prefix|id|disk_key|source_path|bytes"
PLAN_RAW_VOLUMES=()

# Additional full condition (ZFS send mode only): a differential that has grown
# to FULL_MAX_DIFF_PERCENT percent of a full is not worth chaining any more -
# every further differential is measured against that same old full and only
# gets bigger. Both figures come from 'zfs send -w -n -P', once incremental and
# once complete, so they are directly comparable and read no data.
# Runs only when nothing else has forced a full already, and stops at the first
# hit: from then on the all-or-nothing rule applies anyway.
# The estimate is stored in the plan entry, finalize_space_plan reuses it.
plan_check_diff_ratio() {
    [[ "$FULL_MAX_DIFF_PERCENT" -gt 0 ]] || return 0
    local i _vmid _key _ds _cur _base _diff _full _pct
    for i in "${!PLAN_ZFS_DISKS[@]}"; do
        IFS='|' read -r _vmid _key _ds _cur _base <<< "${PLAN_ZFS_DISKS[$i]}"
        [[ -n "$_base" && "$_base" != "$_cur" ]] || continue

        _diff=$(zfs_send_size "$_ds" "$_cur" "$_base") || continue
        PLAN_ZFS_DISKS[$i]="${_vmid}|${_key}|${_ds}|${_cur}|${_base}|${_diff}"

        _full=$(zfs_send_size "$_ds" "$_cur" "") || _full=$(zfs_used_bytes "$_ds") || continue
        [[ "$_full" -gt 0 ]] || continue

        _pct=$(( _diff * 100 / _full ))
        log_info "  VM ${_vmid} ${_key}: differential is ${_pct}% of a full ($(human_bytes "$_diff") of $(human_bytes "$_full"))"
        if [[ "$_pct" -ge "$FULL_MAX_DIFF_PERCENT" ]]; then
            log_info "  VM ${_vmid} ${_key}: limit of ${FULL_MAX_DIFF_PERCENT}% reached -> full required"
            FORCE_ALL_FULL=true
            return 0
        fi
    done
    return 0
}

plan_all_backups() {
    FORCE_ALL_FULL=false
    HAS_RAW_DISKS=false
    PLAN_ZFS_DISKS=()
    PLAN_RAW_VOLUMES=()
    SUM_DIFF_BYTES=0
    SUM_FULL_BYTES=0
    SUM_RAW_BYTES=0
    SUM_OTHER_BYTES=0
    REQUIRED_BYTES=0

    log_info "=== Planning phase (full/diff decision) ==="
    log_info "  BACKUP_MODE=${BACKUP_MODE}"

    local weekday_full=false
    if [[ "$BACKUP_MODE" != "raw" ]] && is_weekday_full; then
        log_info "  Full weekday (${FULL_WEEKDAY}) active -> entire run will be full"
        weekday_full=true
        FORCE_ALL_FULL=true
    fi

    local vmid key vtype vpath vds vsz entry
    for entry in ${INV_VM_DISKS[@]+"${INV_VM_DISKS[@]}"}; do
        IFS='|' read -r vmid key vtype vpath vds vsz <<< "$entry"

        if [[ "$BACKUP_MODE" != "raw" && "$vtype" == "zvol" ]]; then
            local disk_tag="vm-${vmid}-${key}"
            if ! find_latest_snap "$vds"; then
                log_info "  VM ${vmid} ${key}: no snapshot -> full required"
                FORCE_ALL_FULL=true
                PLAN_ZFS_DISKS+=("${vmid}|${key}|${vds}||")
                continue
            fi
            SNAP_FULL="$LATEST_SNAP"
            local cur_snap="${SNAP_FULL#*@}" base_snap=""
            if is_always_full_disk "$key"; then
                # always full, but does NOT force a full for the whole run
                log_info "  VM ${vmid} ${key}: always backed up full (EFI/TPM state)"
                PLAN_ZFS_DISKS+=("${vmid}|${key}|${vds}|${cur_snap}|")
                continue
            fi
            if $weekday_full; then
                :
            elif disk_needs_full "$disk_tag" "$vds"; then
                log_info "  VM ${vmid} ${key}: full required (${DISK_FULL_REASON})"
                FORCE_ALL_FULL=true
            else
                base_snap="$BASE_SNAP_NAME"
            fi
            PLAN_ZFS_DISKS+=("${vmid}|${key}|${vds}|${cur_snap}|${base_snap}")
        elif [[ "$BACKUP_MODE" == "zfs" ]]; then
            continue
        else
            HAS_RAW_DISKS=true
            PLAN_RAW_VOLUMES+=("vm|${vmid}|${key}|${vpath}|${vsz}")
        fi
    done

    if [[ "$BACKUP_MODE" != "raw" ]] && ! $FORCE_ALL_FULL; then
        plan_check_diff_ratio
    fi

    if [[ "$BACKUP_MODE" == "raw" ]]; then
        log_info "  Result: RAW mode - every volume is stored as a full RAW image, no differentials"
    elif [[ ${#PLAN_ZFS_DISKS[@]} -eq 0 ]]; then
        log_info "  Result: no ZFS disks found - nothing to decide (RAW/file-based volumes only)"
    elif $FORCE_ALL_FULL; then
        log_info "  Result: FULL run (all-or-nothing rule)"
    else
        log_info "  Result: all ZFS disks can be backed up differentially"
    fi
    return 0
}

# Plan LXC containers: the requirement depends on the storage type.
plan_ct_backups() {
    local ctid key vtype vpath vds vsz entry need
    for entry in ${INV_CT_VOLS[@]+"${INV_CT_VOLS[@]}"}; do
        IFS='|' read -r ctid key vtype vpath vds vsz <<< "$entry"
        case "$vtype" in
            zfsdir)
                # Directory dataset: always file based, RAW makes no sense here
                need=$(estimate_backup_need "ct-${ctid},lxc" echo "$vsz")
                SUM_OTHER_BYTES=$(( SUM_OTHER_BYTES + need ))
                log_info "  CT ${ctid} ${key}: requires ~$(human_bytes "$need") (ZFS, file-based)"
                ;;
            dir)
                [[ "$BACKUP_MODE" == "zfs" ]] && continue
                if [[ "$vsz" -ge 0 ]]; then
                    need=$(estimate_backup_need "ct-${ctid},lxc" echo "$vsz")
                else
                    need=$(estimate_backup_need "ct-${ctid},lxc" dir_size_bytes "$vpath")
                fi
                SUM_OTHER_BYTES=$(( SUM_OTHER_BYTES + need ))
                log_info "  CT ${ctid} ${key}: requires ~$(human_bytes "$need") (dir, file-based)"
                ;;
            *)
                [[ "$BACKUP_MODE" == "zfs" ]] && continue
                HAS_RAW_DISKS=true
                PLAN_RAW_VOLUMES+=("ct|${ctid}|${key}|${vpath}|${vsz}")
                ;;
        esac
    done
    return 0
}

# Plan the LOCAL_DIRS backups.
plan_local_dirs() {
    local entry _dir _dirname _sz need
    for entry in ${INV_LOCAL_DIRS[@]+"${INV_LOCAL_DIRS[@]}"}; do
        IFS='|' read -r _dir _dirname _sz <<< "$entry"
        if [[ "$_sz" -ge 0 ]]; then
            need=$(estimate_backup_need "local,dir-${_dirname}" echo "$_sz")
        else
            need=$(estimate_backup_need "local,dir-${_dirname}" dir_size_bytes "$_dir")
        fi
        SUM_OTHER_BYTES=$(( SUM_OTHER_BYTES + need ))
        log_info "  LOCAL ${_dir}: requires ~$(human_bytes "$need")"
    done
    return 0
}

# Determine sizes and build REQUIRED_BYTES. Only here do the expensive
# estimates run - and only those needed for the decided run type.
finalize_space_plan() {
    local entry _vmid _key _ds _cur _base _pre _sz
    local rv _pfx _id _rkey _path _psz _need

    if $FORCE_ALL_FULL; then
        for entry in ${PLAN_ZFS_DISKS[@]+"${PLAN_ZFS_DISKS[@]}"}; do
            IFS='|' read -r _vmid _key _ds _cur _base _pre <<< "$entry"
            _sz=$(zfs_used_bytes "$_ds") || _sz=0
            SUM_FULL_BYTES=$(( SUM_FULL_BYTES + _sz ))
        done
    else
        for entry in ${PLAN_ZFS_DISKS[@]+"${PLAN_ZFS_DISKS[@]}"}; do
            IFS='|' read -r _vmid _key _ds _cur _base _pre <<< "$entry"
            if [[ -z "$_base" ]]; then
                # no diff possible (e.g. efidisk/tpmstate) -> full size
                _sz=$(zfs_used_bytes "$_ds") || _sz=0
                SUM_DIFF_BYTES=$(( SUM_DIFF_BYTES + _sz ))
                log_info "  VM ${_vmid} ${_key}: full ~$(human_bytes "$_sz") (always full)"
                continue
            fi
            if [[ "$_base" == "$_cur" ]]; then
                # snapshot unchanged since the last full -> will be skipped
                _sz=0
            elif [[ -n "$_pre" ]]; then
                # already estimated by the diff ratio check
                _sz="$_pre"
            else
                _sz=$(zfs_send_size "$_ds" "$_cur" "$_base") || {
                    log_warn "  VM ${_vmid} ${_key}: cannot determine diff size - falling back to ZFS used"
                    _sz=$(zfs_used_bytes "$_ds") || _sz=0
                }
            fi
            SUM_DIFF_BYTES=$(( SUM_DIFF_BYTES + _sz ))
            log_info "  VM ${_vmid} ${_key}: Diff ~$(human_bytes "$_sz") (${_base} -> ${_cur})"
        done
    fi

    for rv in ${PLAN_RAW_VOLUMES[@]+"${PLAN_RAW_VOLUMES[@]}"}; do
        IFS='|' read -r _pfx _id _rkey _path _psz <<< "$rv"
        if [[ "$_psz" =~ ^[0-9]+$ ]]; then
            _need=$(estimate_backup_need "${_pfx}-${_id}-${_rkey},raw" echo "$_psz")
        else
            _need=$(estimate_backup_need "${_pfx}-${_id}-${_rkey},raw" raw_allocated_bytes "$_path")
        fi
        SUM_RAW_BYTES=$(( SUM_RAW_BYTES + _need ))
        log_info "  ${_pfx}-${_id} ${_rkey}: RAW requires ~$(human_bytes "$_need")"
    done

    if $FORCE_ALL_FULL; then
        REQUIRED_BYTES=$(( SUM_FULL_BYTES + SUM_RAW_BYTES + SUM_OTHER_BYTES ))
    else
        REQUIRED_BYTES=$(( SUM_DIFF_BYTES + SUM_RAW_BYTES + SUM_OTHER_BYTES ))
    fi

    log_info "=== Space required for this run ==="
    if [[ ${#PLAN_ZFS_DISKS[@]} -gt 0 ]]; then
        if $FORCE_ALL_FULL; then
            log_info "  VM disks (full):  $(human_bytes "$SUM_FULL_BYTES")"
        else
            log_info "  VM disks (diff):  $(human_bytes "$SUM_DIFF_BYTES")"
        fi
    fi
    if [[ ${#PLAN_RAW_VOLUMES[@]} -gt 0 ]]; then
        log_info "  RAW images:       $(human_bytes "$SUM_RAW_BYTES")  (always full)"
    fi
    if [[ "$SUM_OTHER_BYTES" -gt 0 ]]; then
        log_info "  LXC/LOCAL_DIRS:   $(human_bytes "$SUM_OTHER_BYTES")  (new = full size, existing = ${ESTIMATED_BACKUP_INCREASE_PERCENTAGE}%)"
    fi
    log_info "  Total required:   $(human_bytes "$REQUIRED_BYTES")"
}

# ================== Main ResticBackup Routine (called after mount) ==================
run_restic_backup() {
    local RB_VMIDS_TO_BACKUP=()
    local arg
    for arg in "$@"; do
        case "$arg" in
            --dry-run)    RESTIC_DRY_RUN=true ;;
            --verbose|-v) RESTIC_VERBOSE=true ;;
            *)
                if [[ "$arg" =~ ^[0-9]+$ ]]; then
                    RB_VMIDS_TO_BACKUP+=("$arg")
                fi
                ;;
        esac
    done

    normalize_compression RESTIC_COMPRESSION_SEND
    normalize_compression RESTIC_COMPRESSION_RAW
    normalize_compression RESTIC_COMPRESSION_FILES

    LXC_EXCLUDE_FLAGS=()
    local _dirs
    IFS=',' read -ra _dirs <<< "$LXC_EXCLUDE_DIRS"
    local _d
    for _d in "${_dirs[@]}"; do
        _d="${_d#"${_d%%[![:space:]]*}"}"
        _d="${_d%"${_d##*[![:space:]]}"}"
        _d="${_d#/}"
        [[ -n "$_d" ]] && LXC_EXCLUDE_FLAGS+=(--exclude "**/${_d}")
    done

    # Excludes for LOCAL_DIRS backups (default: the .zfs snapdir)
    LOCAL_EXCLUDE_FLAGS=()
    local _ldirs _ld
    IFS=',' read -ra _ldirs <<< "$LOCAL_EXCLUDE_DIRS"
    for _ld in "${_ldirs[@]}"; do
        _ld="${_ld#"${_ld%%[![:space:]]*}"}"
        _ld="${_ld%"${_ld##*[![:space:]]}"}"
        _ld="${_ld#/}"
        [[ -n "$_ld" ]] && LOCAL_EXCLUDE_FLAGS+=(--exclude "**/${_ld}")
    done

    if [[ ! -f "$RESTIC_PASSWORDFILE" ]]; then
        log_info "Password file not found, auto-creating: ${RESTIC_PASSWORDFILE}"
        local _pw
        _pw=$(openssl rand -base64 32)
        echo -n "$_pw" > "$RESTIC_PASSWORDFILE"
        chmod 600 "$RESTIC_PASSWORDFILE"
        log_info "Password generated (32 chars, saved with chmod 600)"
        log_warn "IMPORTANT: Save this password elsewhere, it is needed for restore!"
        log_warn "  cat '${RESTIC_PASSWORDFILE}'"
    fi

    log_info "Base: ${RESTIC_BASEFOLDER}"
    log_info "Repo: ${RESTIC_REPOSITORY}"

    # Must run BEFORE the repo check below: a leftover lock makes
    # 'restic snapshots' fail, which would look like an uninitialized repo and
    # send us into 'restic init' on an existing repository.
    clear_stale_repo_lock

    local _snap_opts=()
    $RESTIC_DRY_RUN && _snap_opts+=(--no-lock)
    if ! restic snapshots "${_snap_opts[@]}" &>/dev/null; then
        if $RESTIC_DRY_RUN; then
            log_warn "Restic repo not initialized (dry-run, would auto-create)"
        else
            log_info "Restic repo not found, auto-initializing: ${RESTIC_REPOSITORY}"
            restic init
            log_info "Restic repo initialized and ready"
        fi
    fi

    log_info "=== Starting Proxmox backup ==="
    $RESTIC_DRY_RUN && log_info "DRY-RUN MODE"

    VM_DISK_REGEX='^(ide[0-3]|sata[0-5]|scsi[0-9]+|virtio[0-9]+|efidisk[0-9]+|tpmstate[0-9]+):'
    LXC_FS_REGEX='^rootfs:'

    local VM_IDS=() CT_IDS=()
    if [[ ${#RB_VMIDS_TO_BACKUP[@]} -gt 0 ]]; then
        local _id
        for _id in "${RB_VMIDS_TO_BACKUP[@]}"; do
            if qm config "$_id" &>/dev/null; then
                VM_IDS+=("$_id")
            fi
        done
    else
        mapfile -t VM_IDS < <(qm list 2>/dev/null | awk 'NR > 1 && NF { print $1 }')
        [[ ${#VM_IDS[@]} -eq 0 ]] && log_info "No VMs found"
    fi

    if [[ ${#RB_VMIDS_TO_BACKUP[@]} -eq 0 ]]; then
        mapfile -t CT_IDS < <(pct list 2>/dev/null | awk 'NR>1 && NF {print $1}')
        [[ ${#CT_IDS[@]} -eq 0 ]] && log_info "No LXC containers found"
    else
        local id
        for id in "${RB_VMIDS_TO_BACKUP[@]}"; do
            if pct config "$id" &>/dev/null; then
                CT_IDS+=("$id")
            fi
        done
    fi

    if [[ ${#RB_VMIDS_TO_BACKUP[@]} -gt 0 ]]; then
        local _id2 _is_vm _is_ct _v _c
        for _id2 in "${RB_VMIDS_TO_BACKUP[@]}"; do
            _is_vm=false; _is_ct=false
            for _v in "${VM_IDS[@]}"; do [[ "$_v" == "$_id2" ]] && _is_vm=true && break; done
            for _c in "${CT_IDS[@]}"; do [[ "$_c" == "$_id2" ]] && _is_ct=true && break; done
            if ! $_is_vm && ! $_is_ct; then
                log_warn "VMID ${_id2} is neither a VM nor an LXC container - ignoring"
            fi
        done
    fi

    # --- Inventory: resolve and measure all volumes once ---
    build_inventory ${VM_IDS[@]+"${VM_IDS[@]}"} -- ${CT_IDS[@]+"${CT_IDS[@]}"}

    # --- Mode automatic: does the target drive fit two full generations? ---
    auto_select_backup_mode

    # --- Planning phase: decide full/diff + determine the exact space needed ---
    plan_all_backups
    plan_ct_backups
    plan_local_dirs
    finalize_space_plan

    # --- Space check / release before the backup (3 stages) ---
    ensure_space_available

    # Measured AFTER the pre-backup pruning on purpose. Taken before, a pruning
    # run would make the repository smaller than the reference value, the delta
    # below would go negative, get clamped to 0 - and run_post_backup_check
    # would silently skip the integrity check with "no new data written".
    if ! $RESTIC_DRY_RUN; then
        REPO_SIZE_BEFORE=$(restic stats --mode raw-data --json 2>/dev/null \
            | grep -x '^{.*}' | jq -r '.total_size // 0' 2>/dev/null) || REPO_SIZE_BEFORE=0
        log_info "Repo data size before backup: $(human_bytes "$REPO_SIZE_BEFORE")"
    fi

    local vmid
    for vmid in "${VM_IDS[@]}"; do
        if is_excluded "$vmid"; then
            log_info "Skip VM ${vmid} (excluded)"
            TOTAL_SKIPPED=$((TOTAL_SKIPPED+1))
            continue
        fi
        local VM_CONFIG VM_NAME
        VM_CONFIG=$(qm config "$vmid" 2>/dev/null) || { log_error "qm config ${vmid} failed"; continue; }
        VM_NAME=$(echo "$VM_CONFIG" | awk -F': ' '/^name:/{print $2}')
        NAME_TAG_FLAGS=()
        [[ -n "$VM_NAME" ]] && NAME_TAG_FLAGS=(--tag "name=$(sanitize_tag_value "$VM_NAME")")
        if [[ "$SKIP_TEMPLATES" == true ]] && echo "$VM_CONFIG" | grep -q '^template:.*1'; then
            log_info "Skip VM ${vmid} (${VM_NAME}) - template"
            TOTAL_SKIPPED=$((TOTAL_SKIPPED+1))
            continue
        fi
        log_info "--- VM ${vmid} (${VM_NAME}) ---"
        TOTAL_VMS=$((TOTAL_VMS+1))

        # Back up the config first -> its short ID groups this backup run
        backup_config "$vmid" "qemu-server" "vm" || true
        CONFIG_ID_TAG_FLAGS=()
        [[ -n "$CURRENT_CONFIG_ID" ]] && CONFIG_ID_TAG_FLAGS=(--tag "config-id=${CURRENT_CONFIG_ID}")

        local _iid key vtype vpath vds vsz
        while IFS='|' read -r _iid key vtype vpath vds vsz; do
            [[ -z "$key" ]] && continue
            if [[ "$BACKUP_MODE" != "raw" && "$vtype" == "zvol" ]]; then
                backup_zfs_disk "$vmid" "$key" "$vds" || true
            elif [[ "$BACKUP_MODE" == "zfs" ]]; then
                log_warn "  Disk ${key}: not a ZFS zvol (${vpath}), skipping (BACKUP_MODE=zfs)"
            else
                backup_raw_volume "vm" "$vmid" "$key" "$vpath" || true
            fi
        done < <(inv_entries vm "$vmid")
    done

    local ctid
    for ctid in "${CT_IDS[@]}"; do
        if is_excluded "$ctid"; then
            log_info "Skip CT ${ctid} (excluded)"
            TOTAL_SKIPPED=$((TOTAL_SKIPPED+1))
            continue
        fi
        local CT_CONFIG CT_NAME
        CT_CONFIG=$(pct config "$ctid" 2>/dev/null) || { log_error "pct config ${ctid} failed"; continue; }
        CT_NAME=$(echo "$CT_CONFIG" | awk '/^hostname:/{print $2}')
        [[ -z "$CT_NAME" ]] && CT_NAME="(unnamed)"
        NAME_TAG_FLAGS=(--tag "name=$(sanitize_tag_value "$CT_NAME")")
        if [[ "$SKIP_TEMPLATES" == true ]] && echo "$CT_CONFIG" | grep -q '^template:.*1'; then
            log_info "Skip CT ${ctid} (${CT_NAME}) - template"
            TOTAL_SKIPPED=$((TOTAL_SKIPPED+1))
            continue
        fi
        log_info "--- CT ${ctid} (${CT_NAME}) ---"
        TOTAL_CTS=$((TOTAL_CTS+1))

        # Back up the config first -> its short ID groups this backup run
        backup_config "$ctid" "lxc" "ct" || true
        CONFIG_ID_TAG_FLAGS=()
        [[ -n "$CURRENT_CONFIG_ID" ]] && CONFIG_ID_TAG_FLAGS=(--tag "config-id=${CURRENT_CONFIG_ID}")

        local _iid key vtype vpath vds vsz
        while IFS='|' read -r _iid key vtype vpath vds vsz; do
            [[ -z "$key" ]] && continue
            case "$vtype" in
                zfsdir)
                    # Directory dataset: always file based via a ro snapshot
                    backup_lxc_fs "$ctid" "$key" "$vds" || true ;;
                dir)
                    if [[ "$BACKUP_MODE" == "zfs" ]]; then
                        log_warn "  ${key}: not ZFS-backed (${vpath}), skipping (BACKUP_MODE=zfs)"
                    else
                        backup_lxc_dir "$ctid" "$key" "$vpath" || true
                    fi ;;
                zvol|blockdev|file)
                    if [[ "$BACKUP_MODE" == "zfs" ]]; then
                        log_warn "  ${key}: not ZFS-backed (${vpath}), skipping (BACKUP_MODE=zfs)"
                    else
                        backup_raw_volume "ct" "$ctid" "$key" "$vpath" || true
                    fi ;;
                *)
                    log_warn "  ${key}: volume type '${vtype}' not supported (${vpath})" ;;
            esac
        done < <(inv_entries ct "$ctid")
    done

    if [[ -n "$LOCAL_DIRS" ]]; then
        NAME_TAG_FLAGS=()
        CONFIG_ID_TAG_FLAGS=()
        log_info "=== Local directories ==="
        local _entry _ldir _lname _lsz
        for _entry in ${INV_LOCAL_DIRS[@]+"${INV_LOCAL_DIRS[@]}"}; do
            IFS='|' read -r _ldir _lname _lsz <<< "$_entry"
            backup_local_dir "$_ldir" || true
        done
    fi

    if ! $RESTIC_DRY_RUN; then
        local _repo_after
        _repo_after=$(restic stats --mode raw-data --json 2>/dev/null \
            | grep -x '^{.*}' | jq -r '.total_size // 0' 2>/dev/null) || _repo_after=0
        TOTAL_DATA_ADDED=$(( _repo_after - REPO_SIZE_BEFORE ))
        [[ "$TOTAL_DATA_ADDED" -lt 0 ]] && TOTAL_DATA_ADDED=0
        log_info "Data added this run: $(human_bytes "$TOTAL_DATA_ADDED")"
    fi

    if ! $RESTIC_DRY_RUN; then
        check_disk_space
    fi

    log_info "=== Proxmox backup complete ==="
    log_info "VMs=${TOTAL_VMS} CTs=${TOTAL_CTS} Disks=${TOTAL_DISKS} (Full=${TOTAL_FULLS} Diff=${TOTAL_DIFFS} Raw=${TOTAL_RAWS}) Skipped=${TOTAL_SKIPPED} | Written=$(human_bytes "$TOTAL_DATA_ADDED") | Errors=${ERRORS} Warnings=${WARNINGS}"

    if ! $RESTIC_DRY_RUN; then
        run_forget_prune
        run_post_backup_check
        print_backup_overview
    fi

        return $((ERRORS > 0 ? 1 : 0))
}

fs_trim_now () {
    if [[ "${fsTrim}" == "yes" ]]; then
        echo "fstrim ${RESTIC_BASEFOLDER}"
        fstrim -v "$RESTIC_BASEFOLDER" || echo "fstrim not supported"
    else
        echo "fstrim not requested"
    fi
}

check_medium_change () {
    if [[ "${COUNTIGNORE}" == "no" ]]; then
        echo "========================"
        echo "Check Backup Media Count:"
        local BD BC
        BD=$(tail -n 30 "${logPath}" 2>/dev/null \
            | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) print $i}' \
            | sort -u || true)
        BC=$(echo "${BD}" | grep -c . || true)
        echo "${BD}"
        if [[ "${BC}" -le 1 ]] ; then
            echo "WARNING: backup medium has not been rotated for too long! Only ${BC} medium/media found!"
        else
            echo "${BC} different backup media found."
        fi
    fi
}

check_filesystem_usage() {
    local usage_all usage_full
    echo "========================"
    echo "Check File System Usage:"
    usage_all=$(df -h | awk '!/efivars/')
    usage_full=$(echo "${usage_all}" \
        | awk -v limit="${FULL_FILESYSTEM_PERCENTAGE}" '($5+0) >= limit')
    if [[ -n "${usage_full}" ]]; then
        echo "${usage_full}" | sed 's/^/WARNING: /'
    fi
    echo "${usage_all}"
}

# Warn if the uptime exceeds MAX_UPTIME_DAYS (updates probably not applied).
check_uptime() {
    local uptime_days

    echo "========================"
    echo "Check Uptime / Update Status:..."

    uptime_days=$(( $(awk '{print int($1)}' /proc/uptime) / 86400 ))

    if [[ "${uptime_days}" -gt "${MAX_UPTIME_DAYS}" ]]; then
        echo "WARNING: uptime is ${uptime_days} days: system has been running too long, updates probably not applied!"
    else
        echo "Uptime is ${uptime_days} days"
    fi
}

check_pool_status() {
    local degraded

    echo "========================"
    echo "Check Pool Status:"

    if ! command -v zpool &>/dev/null; then
        echo "No ZFS on this host - pool status skipped"
        return 0
    fi

    zpool status
    degraded=$(zpool status 2>/dev/null | grep degraded || true)

    if [[ -n "${degraded}" ]]; then
        echo "WARNING: ${degraded}"
    fi
}

check_medium_change
check_filesystem_usage
check_uptime
check_pool_status


mkdir -p "${RESTIC_BASEFOLDER}"

echo "========================"
if ! mountpoint -q "${RESTIC_BASEFOLDER}"; then
   for uuid in "${UUIDS[@]}"
   do
      mount "UUID=$uuid" "${RESTIC_BASEFOLDER}" "${MOUNTOPT[@]}" || true
      mountpoint -q "${RESTIC_BASEFOLDER}" || continue
      echo "Backing up to UUID $uuid"
      echo "Backup started at: $(date +"%Y-%m-%d-%H:%M") on UUID $uuid" >> "${logPath}"
      dev=$(blkid -U "$uuid")
      break
   done
   if ! mountpoint -q "${RESTIC_BASEFOLDER}"; then
      echo "WARNING: no backup medium found"
      exit 1
   fi
else
   echo "Backing up to existing mountpoint"
   dev=$(findmnt -n -o SOURCE --target "$RESTIC_BASEFOLDER")
   echo "Backup started at: $(date +"%Y-%m-%d-%H:%M")" >> "${logPath}"
fi

# Do Backup
run_restic_backup "$@" || echo "WARNING: ResticBackup reported errors (see log above)"

if command -v mail &>/dev/null; then
    _send_notify=false
    SUBJECT="[Proxmox] Backup - "
    if [[ "$ERRORS" -gt 0 ]]; then
        SUBJECT+="FAILED (${ERRORS} errors)"
        _send_notify=true
    elif [[ "$NOTIFY_ON_SUCCESS" == "true" && -n "${LOG_OUTPUT}" ]]; then
        SUBJECT+="SUCCESS"
        _send_notify=true
    fi
    if $_send_notify; then
        printf '%b' "$LOG_OUTPUT" | mail -s "$SUBJECT" "$NOTIFY_EMAIL" 2>/dev/null || true
    fi
fi

fs_trim_now

if [[ "${mountPersistent}" == "no" ]]; then
   echo "Unmounting ${RESTIC_BASEFOLDER}"
   # Without the guard a busy mountpoint would trip the ERR trap and abort the
   # script right here - the disk would never be sent to standby.
   umount "${RESTIC_BASEFOLDER}" || echo "WARNING: unmounting ${RESTIC_BASEFOLDER} failed (still in use?)"
else
   echo "Unmount not requested: ${RESTIC_BASEFOLDER}"
fi

if [[ "${setStandby}" == "yes" ]]; then
   disk=""
   if [[ -n "${dev:-}" ]]; then
      # $dev can be a partition (/dev/sda1) or already the whole disk
      # (/dev/sda). PKNAME only returns the parent for partitions; for a whole
      # disk the result is empty and $dev is already the target for hdparm.
      pkname=$(lsblk -no PKNAME "${dev}" 2>/dev/null | awk 'NF{print;exit}')
      if [[ -n "$pkname" ]]; then
         disk="/dev/${pkname}"
      elif [[ -b "${dev}" ]]; then
         disk="${dev}"
      fi
   fi
   if [[ -n "$disk" && -b "$disk" ]]; then
      echo "Setting standby: $disk"
      hdparm -y "$disk" || echo "WARNING: standby failed"
   else
      echo "WARNING: cannot determine physical device for standby (dev='${dev:-}') - standby skipped"
   fi
else
   echo "Disk standby not requested"
fi

