#!/bin/bash
#
# ResticRestore.sh - Restore Proxmox VMs + LXC from restic backup
# ---------------------------------------------------------------------------
echo "Restic Restore V 4.5.5"


set -euo pipefail

# Set Default Values
RESTIC_BASEFOLDER="/media/SmartStore"

# Same config as ResticBackup.sh (optional - the script also runs without it)
config=/usr/local/bin/config.cfg
if [[ -r "$config" ]]; then
    # shellcheck source=/dev/null
    source "$config"
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}"

FOUND_SNAP_ID=""
# On a restore via --snap <config-id>: requires every disk to carry the same
# config-id tag, so that exactly the disks belonging to that config snapshot
# are restored (not merely the snapshot closest in time).
CONFIG_ID_FILTER=""

log() { echo "[$(date '+%H:%M:%S')] [$1] $2"; }
info() { log "INFO" "$*"; }
warn() { log "WARN" "$*"; }
error() { log "ERROR" "$*" >&2; }

restic() {
    command restic --repo "$RESTIC_REPOSITORY" --password-file "$RESTIC_PASSWORDFILE" "$@"
}

# Bytes -> menschenlesbar (z.B. 1536000000 -> "1.43GiB").
# Leere/ungueltige Eingabe -> "-"
human_size() {
    local bytes="${1:-}"
    if [[ -z "$bytes" || ! "$bytes" =~ ^[0-9]+$ ]]; then
        echo "-"
        return
    fi
    awk -v b="$bytes" 'BEGIN{
        split("B KiB MiB GiB TiB PiB", u, " ");
        i=1; s=b;
        while (s>=1024 && i<6) { s/=1024; i++ }
        if (i==1) printf "%d%s\n", s, u[i];
        else printf "%.2f%s\n", s, u[i];
    }'
}

# Formatted snapshot overview: one line per snapshot with
# SNAP ID, Backup Time, TYPE, MACHINE, NAME, DISK, SIZE, PARENT, CONFIG.
# Filters case-insensitively by the global FILTER_* variables and SRC_ID.
# With --size (SHOW_SIZE=true) a cumulative restore size is shown:
#   FULL disk / LXC-FS / LOCAL : plain disk size
#   DIFF-Disk                  : Diff + zugehoerige Parent-Full-Disk
#   CONFIG (full run)          : sum of all full disks of that run
#   CONFIG (diff run)          : sum of all diff disks + their parents
print_snapshot_table() {
    local json
    json=$(restic snapshots --json 2>/dev/null) || json=""
    if [[ -z "$json" || "$json" == "null" || "$json" == "[]" ]]; then
        return 1
    fi

    # Typ-Filter normalisieren (Aliase zulassen)
    local ftype="${FILTER_TYPE,,}"
    case "$ftype" in
        differential) ftype="diff" ;;
        lxc)          ftype="lxc-fs" ;;
    esac

    local rows
    rows=$(echo "$json" | jq -r \
        --arg ftype "$ftype" \
        --arg fmachine "${SRC_ID,,}" \
        --arg fname "${FILTER_NAME,,}" \
        --arg fconfig "${FILTER_CONFIG,,}" '
        sort_by(.time)[] |
        (if ((.tags // []) | index("raw")) then "RAW"
         elif ((.tags // []) | index("full")) then "FULL"
         elif ((.tags // []) | index("differential")) then "DIFF"
         elif ((.tags // []) | index("config")) then "CONFIG"
         elif ((.tags // []) | index("lxc")) then "LXC-FS"
         elif ((.tags // []) | index("local")) then "LOCAL"
         else "?" end) as $typ |
        ((([.tags[]? | select(test("^(vm|ct)-[0-9]+$"))] | first) // "-")) as $machine |
        ((([.tags[]? | select(startswith("name="))] | first) // ""
          | sub("^name="; "")
          | if . == "" then "-" else . end)) as $name |
        ((([.tags[]? | select(test("^(vm|ct)-[0-9]+-.+$"))] | first) // ""
          | sub("^(vm|ct)-[0-9]+-"; "")) as $dk |
         (([.tags[]? | select(startswith("dir-"))] | first) // ""
          | sub("^dir-"; "")) as $dir |
         (if $dk != "" then $dk elif $dir != "" then $dir else "-" end)) as $disk |
        ((([.tags[]? | select(startswith("parent-id="))] | first) // ""
          | sub("^parent-id="; "")
          | if . == "" then "-" else . end)) as $parent |
        (if $typ == "CONFIG" then (.short_id // "-")
         else (([.tags[]? | select(startswith("config-id="))] | first) // ""
               | sub("^config-id="; "")
               | if . == "" then "-" else . end) end) as $cfg |
        select($ftype == ""    or ($typ | ascii_downcase) == $ftype) |
        select($fmachine == "" or ($machine | ascii_downcase) == $fmachine
                               or ($machine | sub("^(vm|ct)-"; "")) == $fmachine) |
        select($fname == ""    or ($name | ascii_downcase) == $fname) |
        select($fconfig == ""  or ($cfg | ascii_downcase) == $fconfig) |
        [ (.short_id // ""),
          ((.time // "")[0:16] | sub("T"; " ")),
          $typ, $machine, $name, $disk, $parent, $cfg
        ] | @tsv' 2>/dev/null) || rows=""
    [[ -z "$rows" ]] && return 1

    # The SIZE column is only computed when requested via --size. The size is
    # determined per snapshot via 'restic stats --mode restore-size' (one call
    # per snapshot) - with very many snapshots --list can get noticeably
    # dadurch spuerbar laenger dauern, daher standardmaessig deaktiviert.
    if [[ "${SHOW_SIZE:-false}" == "true" ]]; then
        info "Calculating snapshot sizes ..." >&2

        # ---- Pass 1: determine the raw size per snapshot and remember the
        # metadata (TYPE, config-id, parent-id) for the aggregation below.
        declare -A _RAWSIZE=()   # short_id -> bytes (plain restore size)
        declare -A _TYPE=()      # short_id -> TYPE
        declare -A _CFG=()       # short_id -> config-id (backup run)
        declare -A _PARENT=()    # short_id -> parent-id (DIFF disks only)
        local _line _sid _typ _parent _cfg _bytes
        while IFS=$'\t' read -r _sid _time _typ _machine _name _disk _parent _cfg; do
            [[ -z "$_sid" ]] && continue
            _bytes=$(get_snap_restore_size "$_sid") || _bytes=""
            [[ "$_bytes" =~ ^[0-9]+$ ]] || _bytes=0
            _RAWSIZE["$_sid"]="$_bytes"
            _TYPE["$_sid"]="$_typ"
            _CFG["$_sid"]="$_cfg"
            _PARENT["$_sid"]="$_parent"
        done <<< "$rows"

        # ---- Pass 2: compute the cumulative size per line ----
        # Regeln:
        #  FULL disk / LXC-FS / LOCAL : plain disk size
        #  DIFF-Disk                  : Diff + Parent-Full-Disk (parent-id)
        #  CONFIG (full run)          : sum of all FULL disks of that run
        #  CONFIG (diff run)          : sum of all DIFF disks of that run
        #                               + deren Parent-Full-Disks
        # Whether a CONFIG run is full or diff is derived from the TYPEs of the
        # disk snapshots carrying the same config-id.
        local rows_with_size=""
        local _cum _psize
        while IFS=$'\t' read -r _sid _time _typ _machine _name _disk _parent _cfg; do
            [[ -z "$_sid" ]] && continue
            _cum=0
            case "$_typ" in
                DIFF)
                    _cum=${_RAWSIZE["$_sid"]:-0}
                    if [[ -n "$_parent" && "$_parent" != "-" ]]; then
                        _psize=${_RAWSIZE["$_parent"]:-0}
                        _cum=$(( _cum + _psize ))
                    fi
                    ;;
                CONFIG)
                    # Add up all snapshots belonging to the run (same
                    # aufaddieren:
                    #  FULL   : plain disk size
                    #  DIFF   : disk size + parent full disk
                    #  LXC-FS : plain filesystem size (no parent concept)
                    local _k
                    for _k in "${!_CFG[@]}"; do
                        [[ "${_CFG[$_k]}" == "$_cfg" ]] || continue
                        case "${_TYPE[$_k]}" in
                            FULL|RAW)
                                _cum=$(( _cum + ${_RAWSIZE[$_k]:-0} ))
                                ;;
                            DIFF)
                                _cum=$(( _cum + ${_RAWSIZE[$_k]:-0} ))
                                local _pp="${_PARENT[$_k]}"
                                if [[ -n "$_pp" && "$_pp" != "-" ]]; then
                                    _cum=$(( _cum + ${_RAWSIZE[$_pp]:-0} ))
                                fi
                                ;;
                            LXC-FS)
                                _cum=$(( _cum + ${_RAWSIZE[$_k]:-0} ))
                                ;;
                        esac
                    done
                    ;;
                *)
                    _cum=${_RAWSIZE["$_sid"]:-0}
                    ;;
            esac
            rows_with_size+="${_sid}"$'\t'"${_time}"$'\t'"${_typ}"$'\t'"${_machine}"$'\t'"${_name}"$'\t'"${_disk}"$'\t'"${_parent}"$'\t'"${_cfg}"$'\t'"$(human_size "$_cum")"$'\n'
        done <<< "$rows"

        {
            printf 'SNAP ID\tBackup Time\tTYPE\tMACHINE\tNAME\tDISK\tPARENT\tCONFIG\tSIZE\n'
            printf '%s' "$rows_with_size"
        } | awk -F'\t' '{printf "%-10s  %-16s  %-7s  %-9s  %-24s  %-10s  %10s  %-10s  %s\n", $1, $2, $3, $4, $5, $6, $9, $7, $8}'
    else
        {
            printf 'SNAP ID\tBackup Time\tTYPE\tMACHINE\tNAME\tDISK\tPARENT\tCONFIG\n'
            echo "$rows"
        } | awk -F'\t' '{printf "%-10s  %-16s  %-7s  %-9s  %-24s  %-10s  %-10s  %s\n", $1, $2, $3, $4, $5, $6, $7, $8}'
    fi
    return 0
}

show_usage() {
    cat << 'EOF'
Usage: ResticRestore.sh <command> [options]

List:
  --list                        List all restic snapshots
  --list --machine <ID>         Only snapshots of this VM/CT
  --list --type <TYPE>          Only this type: raw, full, diff, config,
                                lxc-fs, local
  --list --name <NAME>          Only machines with this name
  --list --config <SNAP-ID>     Only snapshots belonging to this backup run
                                (all filters are case-insensitive, combinable)
  --list --size                 Additionally show each snapshot's restore
                                size (slower: one restic stats call per
                                snapshot)
  --list-disks <VMID>           Show disk backup status for a VM

Restore (requires --target AND one of --machine / --snap):
  --machine <ID> --target <DST>   Restore the LATEST backup run of machine <ID>
  --snap <SNAP-ID> --target <DST> Restore exactly this backup run (config
                                  snapshot ID from --list; the machine is
                                  derived from the snapshot, --machine is
                                  not required)

LOCAL / dir snapshots (browse or pull files, no VM/CT involved):
  --snap <SNAP-ID>                        If the snapshot is a LOCAL/dir backup,
                                          it is mounted via FUSE into a fresh
                                          temp dir in the foreground - the same
                                          as --mount <SNAP-ID>, which works for
                                          LXC and RAW as well.
  --snap <SNAP-ID> --target <DIR>         LOCAL/dir snapshot: extract it straight
                                          into <DIR> via 'restic restore' (no
                                          FUSE, no temp dir, no blocking).

Single file recovery (browse a backup without restoring it):
  --mount <SNAP-ID>             Make the snapshot readable below a temp dir on
                                THIS host, depending on its type (see --list):
                                  RAW    - the image becomes a READ-ONLY loop
                                           device, every readable partition is
                                           mounted; LVM inside the image is
                                           activated when its VG name does not
                                           clash with a host VG
                                  LXC-FS - the container root as backed up
                                  LOCAL  - the directory tree as backed up
                                  CONFIG - the stored config file
                                FULL/DIFF is a zfs send stream, not a file
                                system: restore it to a free ID instead.
                                Nothing is extracted - the repo is opened via
                                FUSE, so no extra disk space is needed. Runs in
                                the foreground; open a SECOND console to copy
                                files out. Ctrl+C releases everything again.
                                Needs FUSE; RAW also needs losetup and blkid.
  --mount-raw <SNAP-ID>         Same thing, kept for compatibility.
  --attach-raw <SNAP-ID> --vm <VMID>
                                Like --mount on a RAW snapshot, but instead of
                                mounting on this host the image is hot-plugged
                                into RUNNING VM <VMID> as an extra SCSI disk. The guest mounts its own
                                filesystem - the way to go when this host
                                cannot read it (Windows Storage Spaces, a
                                dynamic disk, LUKS with a key inside the
                                guest). Read-only unless --rw is given.
                                Needs a virtio-scsi controller and 'hotplug'
                                including 'disk' on the target VM. A read only
                                copy of a disk the VM already has online stays
                                offline/uninitialised there - Windows wants to
                                write a new signature first, so use --rw or a
                                different VM.
  --attach-raw <SNAP-ID> --vm <VMID> --rw
                                Writable, via a qcow2 overlay in
                                ATTACH_OVERLAY_DIR that is handed to the VM as
                                /dev/nbdN. Writes land in the overlay and are
                                thrown away on release; the backup is never
                                modified. Needs qemu-img, qemu-nbd and the
                                kernel module 'nbd'.
  --detach-raw [--vm <VMID>]    Clean up after an --attach-raw / --mount-raw run
                                that was killed hard (SIGKILL, host crash), so
                                the trap never ran: removes the disk from the VM
                                config, detaches the loop device, unmounts the
                                FUSE view and deletes overlay and temp
                                directory. With --vm only what that VM
                                refers to is released; without it, every leftover
                                is - so do not use the plain form while an
                                --attach-raw session is still running.
                                Needs no access to the backup drive. Combine
                                with --dry-run to see what it would do.

Options:
  --target <ID>                 Destination VMID/CTID - MANDATORY for restore,
                                must not exist yet
  --date <YYYY-MM-DD>           With --machine: use latest backup run on or
                                before this date instead of the newest
  --vm <VMID>                   With --attach-raw: the running VM the image is
                                attached to
  --rw                          With --attach-raw: writable via a throwaway
                                qcow2 overlay
  --dry-run                     Show what would be restored without doing it
  --help                        Show this help

Safety:
  - A restore never runs without an explicit --target; this prevents
    accidental restores when you actually meant to filter --list.
  - The target ID must NOT exist. Existing machines are never
    overwritten - delete the machine manually first or choose a
    different --target ID.

Backup run selection:
  - Disks/filesystems are matched to the chosen config snapshot via the
    config-id tag, so exactly the disks belonging to that run are
    restored. Differentials automatically pull in their parent full.

RAW image backups (TYPE=RAW in --list):
  - Volumes that are not ZFS-backed (LVM, LVM-thin, raw files) or hosts
    running with BACKUP_MODE=raw are stored as full raw block images.
  - There are no differentials for RAW backups; every RAW snapshot is
    self-contained and restores in a single step.
  - On restore the target volume is allocated automatically via
    'pvesm alloc' (size taken from the raw-size tag) and written with dd.
EOF
}

find_snap_by_tag() {
    FOUND_SNAP_ID=""
    local before_cutoff=""
    local tags=()

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --before) before_cutoff="$2"; shift 2 ;;
            *) tags+=("$1"); shift ;;
        esac
    done

    local tag_csv
    tag_csv=$(IFS=,; echo "${tags[*]}")

    local snapshots_json
    snapshots_json=$(restic snapshots --tag "$tag_csv" --json 2>/dev/null) || return 1
    [[ -z "$snapshots_json" || "$snapshots_json" == "[]" ]] && return 1

    if [[ -n "$before_cutoff" ]]; then
        local cutoff="$before_cutoff"
        [[ "$cutoff" != *T* ]] && cutoff="${cutoff}T23:59:59"
        FOUND_SNAP_ID=$(echo "$snapshots_json" | jq -r \
            --arg before "$cutoff" \
            'sort_by(.time) | map(select(.time <= $before)) | last | .short_id // empty' 2>/dev/null) || true
    else
        FOUND_SNAP_ID=$(echo "$snapshots_json" | jq -r \
            'sort_by(.time) | last | .short_id // empty' 2>/dev/null) || true
    fi
    [[ -n "$FOUND_SNAP_ID" && "$FOUND_SNAP_ID" != "null" ]]
}

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

snap_exists() {
    restic snapshots --json "$1" 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1
}

snap_has_file() {
    local snap_id="$1" filename="$2"
    restic ls "$snap_id" 2>/dev/null | awk -v f="$filename" '$0 ~ "(^|/)"f"$" {found=1} END{exit !found}'
}

get_zfs_pool_path() {
    local storage="$1"
    awk -v s="$storage" '
        /^zfspool:/ { name=$2 }
        $1=="pool" && name==s { print $2 }
    ' /etc/pve/storage.cfg
}

# Progress display: with a known size pv shows percent + ETA,
# without a size only throughput; without pv the data is passed through (cat)
pipe_helper() {
    local label="$1" size="${2:-}"
    if command -v pv &>/dev/null; then
        if [[ -n "$size" && "$size" =~ ^[0-9]+$ && "$size" -gt 0 ]]; then
            pv -N "$label" -s "$size"
        else
            pv -N "$label"
        fi
    else
        cat
    fi
}

# Size of a file inside a restic snapshot (bytes, empty if unknown)
get_snap_file_size() {
    local snap_id="$1" filename="$2"
    restic ls --json "$snap_id" 2>/dev/null \
        | jq -r --arg n "$filename" 'select(.type? == "file" and .name? == $n) | .size' 2>/dev/null \
        | head -1
}

# Total size of a snapshot on restore (bytes, empty if unknown)
get_snap_restore_size() {
    local snap_id="$1"
    restic stats --mode restore-size --json "$snap_id" 2>/dev/null \
        | jq -r '.total_size // empty' 2>/dev/null
}

# Recorded root path of a snapshot (paths[0])
get_snap_root_path() {
    local snap_id="$1"
    restic snapshots --json "$snap_id" 2>/dev/null \
        | jq -r '.[0].paths[0] // empty' 2>/dev/null
}

# Read the value of a tag with the given prefix from a snapshot
# (z.B. snap_tag_value <id> "raw-size=")
snap_tag_value() {
    local snap_id="$1" prefix="$2"
    restic snapshots --json "$snap_id" 2>/dev/null \
        | jq -r --arg p "$prefix" '.[0].tags[]? | select(startswith($p)) | sub("^" + $p; "")' 2>/dev/null \
        | head -1
}

# ================== RAW Image Restore ==================
#
# RAW backups are always complete (no diff). Restore:
#   1) Create the target volume if it does not exist (pvesm alloc, size taken
#      from the raw-size tag)
#   2) restic dump <snap> /<datei>.raw | dd of=<volpath>
#
# This works for ZFS zvols, LVM/LVM-thin LVs and raw files on directory
# storages alike.
restore_raw_volume() {
    local snap="$1" dst_volume="$2" dst_id="$3" label="$4" dump_fn="$5"
    local size volpath storage volname

    size=$(snap_tag_value "$snap" "raw-size=") || size=""
    if [[ ! "$size" =~ ^[0-9]+$ ]]; then
        size=$(get_snap_file_size "$snap" "$dump_fn") || size=""
    fi
    if [[ ! "$size" =~ ^[0-9]+$ ]]; then
        error "  ${label}: cannot determine size of RAW image (${dump_fn})"
        return 1
    fi

    if ! snap_has_file "$snap" "$dump_fn"; then
        error "  ${label}: RAW snapshot ${snap} does not contain ${dump_fn}"
        return 1
    fi

    storage="${dst_volume%%:*}"
    volname="${dst_volume##*:}"
    volname="${volname##*/}"

    if $DRY_RUN; then
        info "  ${label}: [DRY-RUN] RAW (${snap}, $(human_size "$size"))"
        info "    pvesm alloc ${storage} ${dst_id} ${volname} $(( (size + 1023) / 1024 ))"
        info "    restic dump ${snap} /${dump_fn} | dd of=<${dst_volume}>"
        return 0
    fi

    volpath=$(pvesm path "$dst_volume" 2>/dev/null || true)
    if [[ -z "$volpath" || ! -e "$volpath" ]]; then
        local kb=$(( (size + 1023) / 1024 ))
        if ! pvesm alloc "$storage" "$dst_id" "$volname" "$kb" &>/dev/null; then
            error "  ${label}: 'pvesm alloc ${storage} ${dst_id} ${volname} ${kb}' failed"
            return 1
        fi
        volpath=$(pvesm path "$dst_volume" 2>/dev/null || true)
    fi
    if [[ -z "$volpath" || ! -e "$volpath" ]]; then
        error "  ${label}: target volume ${dst_volume} not available"
        return 1
    fi

    info "  ${label}: RAW restore ${snap} ($(human_size "$size")) -> ${volpath}"
    if ! restic dump "$snap" "/${dump_fn}" \
            | pipe_helper "${label}-raw" "$size" \
            | dd of="$volpath" bs=512k conv=notrunc,sparse status=none; then
        error "  ${label}: RAW restore failed"
        return 1
    fi
    info "  OK: ${label} (raw)"
    return 0
}

# ================== VM Disk Restore ==================

restore_vm_disk() {
    local src_id="$1" disk_key="$2" dst_volume="$3"
    local before_date=""
    shift 3
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --before) before_date="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    local disk_tag="vm-${src_id}-${disk_key}"

    # --- RAW image? Then take the dedicated path, without ZFS ---
    local raw_find_extra=() raw_group_extra=()
    [[ -n "$before_date" ]] && raw_find_extra=(--before "$before_date")
    [[ -n "$CONFIG_ID_FILTER" ]] && raw_group_extra=("config-id=${CONFIG_ID_FILTER}")
    if find_snap_by_tag "$disk_tag" "raw" "${raw_group_extra[@]}" "${raw_find_extra[@]}"; then
        restore_raw_volume "$FOUND_SNAP_ID" "$dst_volume" "$DST_ID" "$disk_key" "vm-${src_id}-${disk_key}.raw"
        return $?
    fi

    local volpath dataset
    volpath=$(pvesm path "$dst_volume" 2>/dev/null || true)
    if [[ -n "$volpath" && "$volpath" == /dev/zvol/* ]]; then
        dataset="${volpath#/dev/zvol/}"
    else
        local volname="${dst_volume##*:}"
        local storage_name="${dst_volume%%:*}"
        local pool_path
        pool_path=$(get_zfs_pool_path "$storage_name")
        if [[ -z "$pool_path" ]]; then
            error "  ${disk_key}: cannot find ZFS pool for storage '${storage_name}'"
            return 1
        fi
        dataset="${pool_path}/${volname}"
    fi

    local find_extra=()
    [[ -n "$before_date" ]] && find_extra=(--before "$before_date")

    # When restoring via --snap <config-id>: use strictly the disks belonging
    # to that config snapshot. The differential tag gets config-id directly,
    # the full fallback (for the diff -> full chain) does NOT, because a diff
    # may point at an older full from a different run.
    local disk_group_extra=()
    [[ -n "$CONFIG_ID_FILTER" ]] && disk_group_extra=("config-id=${CONFIG_ID_FILTER}")

    local dump_fn="vm-${src_id}-${disk_key}.zfs"

    # ---- Phase 1: build and validate the restore plan (destroy nothing yet) ----
    local full_snap="" diff_snap=""

    if find_snap_by_tag "$disk_tag" "differential" "${disk_group_extra[@]}" "${find_extra[@]}"; then
        diff_snap="$FOUND_SNAP_ID"
        local _info diff_time parent_id parent_snap
        _info=$(get_snap_info "$diff_snap") || true
        IFS=$'\t' read -r diff_time parent_id parent_snap _ <<< "$_info"

        if ! snap_has_file "$diff_snap" "$dump_fn"; then
            warn "  ${disk_key}: differential ${diff_snap} does not contain ${dump_fn}, skipping"
            diff_snap=""
        fi

        if [[ -n "$diff_snap" ]]; then
            if [[ -n "$parent_id" ]] && snap_exists "$parent_id" && snap_has_file "$parent_id" "$dump_fn"; then
                full_snap="$parent_id"
            else
                [[ -n "$parent_id" ]] && warn "  ${disk_key}: parent-id ${parent_id} missing or without ${dump_fn}"
                if find_snap_by_tag "$disk_tag" "full" "${find_extra[@]}" \
                        && snap_has_file "$FOUND_SNAP_ID" "$dump_fn"; then
                    full_snap="$FOUND_SNAP_ID"
                    warn "  ${disk_key}: falling back to latest full ${full_snap} as base"
                fi
            fi
            if [[ -n "$full_snap" && "$full_snap" == "$diff_snap" ]]; then
                warn "  ${disk_key}: differential and full are same snapshot, treating as full-only"
                diff_snap=""
            fi
            if [[ -z "$full_snap" ]]; then
                warn "  ${disk_key}: no usable full base for differential ${diff_snap}, trying full-only"
                diff_snap=""
            fi
        fi
    fi

    if [[ -z "$full_snap" ]]; then
        # For "pure" full runs the config-id filter may apply - the full then
        # sits in the same backup run as the config snapshot.
        if find_snap_by_tag "$disk_tag" "full" "${disk_group_extra[@]}" "${find_extra[@]}"; then
            full_snap="$FOUND_SNAP_ID"
            if ! snap_has_file "$full_snap" "$dump_fn"; then
                warn "  ${disk_key}: full ${full_snap} does not contain ${dump_fn}"
                warn "  ${disk_key}: available files:"
                restic ls "$full_snap" 2>/dev/null | head -10
                error "  ${disk_key}: cannot find ${dump_fn} in any snapshot"
                return 1
            fi
        fi
    fi

    if [[ -z "$full_snap" ]]; then
        error "  ${disk_key}: no backup found for ${disk_tag}"
        return 1
    fi

    if $DRY_RUN; then
        local dr_full_size dr_diff_size
        dr_full_size=$(get_snap_file_size "$full_snap" "$dump_fn") || true
        if [[ -n "$diff_snap" ]]; then
            dr_diff_size=$(get_snap_file_size "$diff_snap" "$dump_fn") || true
            info "  ${disk_key}: [DRY-RUN] Full (${full_snap}, $(human_size "$dr_full_size")) + Differential (${diff_snap}, $(human_size "$dr_diff_size"))"
            info "    restic dump ${full_snap} /${dump_fn} | zfs recv -F ${dataset}"
            info "    restic dump ${diff_snap} /${dump_fn} | zfs recv -F ${dataset}"
        else
            info "  ${disk_key}: [DRY-RUN] Full only (${full_snap}, $(human_size "$dr_full_size"))"
            info "    restic dump ${full_snap} /${dump_fn} | zfs recv -F ${dataset}"
        fi
        return 0
    fi

    # ---- Phase 2: the plan holds - only now remove the target dataset ----
    zfs destroy -r "$dataset" 2>/dev/null || true

    local full_size
    full_size=$(get_snap_file_size "$full_snap" "$dump_fn") || true
    if [[ -n "$diff_snap" ]]; then
        local diff_size_pre
        diff_size_pre=$(get_snap_file_size "$diff_snap" "$dump_fn") || true
        info "  ${disk_key}: Full (${full_snap}, $(human_size "$full_size")) + Differential (${diff_snap}, $(human_size "$diff_size_pre"))"
    else
        info "  ${disk_key}: Full (${full_snap}, $(human_size "$full_size"))"
    fi
    restic dump "$full_snap" "/${dump_fn}" \
        | pipe_helper "${disk_key}-full" "$full_size" \
        | zfs recv -F "$dataset" || {
        error "  ${disk_key}: full restore failed"
        return 1
    }

    if [[ -n "$diff_snap" ]]; then
        local diff_size
        diff_size=$(get_snap_file_size "$diff_snap" "$dump_fn") || true
        restic dump "$diff_snap" "/${dump_fn}" \
            | pipe_helper "${disk_key}-diff" "$diff_size" \
            | zfs recv -F "$dataset" || {
            error "  ${disk_key}: differential apply failed"
            return 1
        }
        info "  OK: ${disk_key} (full + differential)"
    else
        info "  OK: ${disk_key} (full only)"
    fi
    return 0
}

# ================== LXC Filesystem Restore ==================

restore_lxc_fs() {
    local src_id="$1" key="$2" dst_volume="$3"
    local before_date=""
    shift 3
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --before) before_date="$2"; shift 2 ;;
            *) shift ;;
        esac
    done

    local find_extra=()
    [[ -n "$before_date" ]] && find_extra=(--before "$before_date")

    local fs_group_extra=()
    [[ -n "$CONFIG_ID_FILTER" ]] && fs_group_extra=("config-id=${CONFIG_ID_FILTER}")

    # --- RAW image (LVM/LVM-thin/raw file)? Then do a block restore ---
    if find_snap_by_tag "ct-${src_id}-${key}" "raw" "${fs_group_extra[@]}" "${find_extra[@]}"; then
        restore_raw_volume "$FOUND_SNAP_ID" "$dst_volume" "$DST_ID" "$key" "ct-${src_id}-${key}.raw"
        return $?
    fi

    if ! find_snap_by_tag "ct-${src_id}" "lxc" "${fs_group_extra[@]}" "${find_extra[@]}"; then
        error "  ${key}: no filesystem snapshot found for ct-${src_id}"
        return 1
    fi
    local fs_snap="$FOUND_SNAP_ID"

    local volpath
    volpath=$(pvesm path "$dst_volume" 2>/dev/null || true)
    if [[ -z "$volpath" || ! -d "$volpath" ]]; then
        local volname="${dst_volume##*:}"
        local storage_name="${dst_volume%%:*}"
        local pool_path
        pool_path=$(get_zfs_pool_path "$storage_name")
        if [[ -n "$pool_path" ]]; then
            zfs create "${pool_path}/${volname}" 2>/dev/null || true
            volpath=$(pvesm path "$dst_volume" 2>/dev/null || true)
        fi
        if [[ -z "$volpath" || ! -d "$volpath" ]]; then
            # Do not assume the pool is mounted at /<pool> - ask ZFS for the
            # real mountpoint. Only fall back to the naive path if ZFS has
            # nothing usable (unset, none, legacy).
            local mp=""
            mp=$(zfs get -H -o value mountpoint "${pool_path}/${volname}" 2>/dev/null) || mp=""
            case "$mp" in
                ""|"-"|none|legacy) volpath="/${pool_path}/${volname}" ;;
                *)                  volpath="$mp" ;;
            esac
            mkdir -p "$volpath" 2>/dev/null || true
        fi
    fi

    # Inside the snapshot the content sits at the snapshot ROOT, not under the
    # original host mountpoint from paths[0].
    # The backup ran as "cd <snap-mount> && restic backup ." -> content = "/".
    # restic dump <snap> <path> expects a path INSIDE the snapshot tree; the
    # absolute host path (e.g. /tmp/restic-snap-ct1007-rootfs) does NOT exist
    # there and leads to "path not found in snapshot".
    # So the correct internal dump path is determined robustly.
    local dump_path
    dump_path=$(determine_lxc_dump_path "$fs_snap") || {
        error "  ${key}: cannot determine dump path inside snapshot ${fs_snap}"
        return 1
    }

    if $DRY_RUN; then
        local dr_fs_size
        dr_fs_size=$(get_snap_restore_size "$fs_snap") || true
        info "  ${key}: [DRY-RUN] restic restore ${fs_snap} ($(human_size "$dr_fs_size")) --target /tmp/... -> tar-copy -> ${volpath}"
        return 0
    fi

    local fs_size
    fs_size=$(get_snap_restore_size "$fs_snap") || true
    info "  ${key}: restoring ${fs_snap} (${dump_path}, $(human_size "$fs_size")) -> ${volpath}"

    # --- Primary path: stage through /tmp ---
    # A direct stream (restic dump | tar -x -C <zvol-mount>) reliably fails
    # here, because the freshly created target dataset cannot be written to
    # directly. The detour via restic restore into a /tmp directory and a
    # subsequent tar copy does work.
    # --numeric-owner is mandatory: unprivileged CTs have shifted UIDs.
    local staged_ok=true
    local stage_dir="/tmp/restic-lxc-restore-${src_id}-$$"
    rm -rf "$stage_dir"
    mkdir -p "$stage_dir"

    if ! restic restore "$fs_snap" --target "$stage_dir"; then
        error "  ${key}: staging restore to ${stage_dir} failed"
        rm -rf "$stage_dir"
        staged_ok=false
    fi

    if $staged_ok; then
        # Determine the source root in the staging folder: the content is
        # either directly in stage_dir or under the recorded host path.
        local src_root="$stage_dir"
        local rec_root
        rec_root=$(get_snap_root_path "$fs_snap") || true
        if [[ -n "$rec_root" && -d "${stage_dir}${rec_root}" ]]; then
            src_root="${stage_dir}${rec_root}"
        elif [[ ! -e "${stage_dir}/etc" && ! -e "${stage_dir}/usr" && ! -e "${stage_dir}/bin" ]]; then
            # Content may be one level deeper (a single subdirectory)
            local only
            only=$(find "$stage_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1) || true
            [[ -n "$only" ]] && src_root="$only"
        fi

        # Copy staging -> target with tar, preserving numeric permissions/owner
        if ! tar -c --numeric-owner -p -C "$src_root" . \
                | pipe_helper "${key}" "$fs_size" \
                | tar -x -p --numeric-owner -f - -C "$volpath"; then
            error "  ${key}: copy from staging ${src_root} to ${volpath} failed"
            rm -rf "$stage_dir"
            return 1
        fi
        rm -rf "$stage_dir"
    else
        # --- Fallback: direkter Stream ---
        # If staging fails (e.g. /tmp is full), stream directly.
        warn "  ${key}: staging failed, falling back to direct stream"
        if ! restic dump "$fs_snap" "$dump_path" \
                | pipe_helper "${key}" "$fs_size" \
                | tar -x -p --numeric-owner -f - -C "$volpath"; then
            error "  ${key}: direct stream restore also failed"
            return 1
        fi
    fi

    # Fix ownership of the dataset root: 'zfs create' creates it as root:root,
    # but the container root (UID 100000 for unprivileged CTs) needs it for
    # itself - otherwise the start fails with
    # 'mount_autodev: Failed to create /dev: Permission denied'.
    local ref_entry=""
    local _cand
    for _cand in etc usr bin var; do
        [[ -e "${volpath}/${_cand}" ]] && { ref_entry="${volpath}/${_cand}"; break; }
    done
    [[ -z "$ref_entry" ]] && ref_entry=$(find "$volpath" -mindepth 1 -maxdepth 1 2>/dev/null | head -1) || true
    if [[ -n "$ref_entry" ]]; then
        local _owner
        _owner=$(stat -c '%u:%g' "$ref_entry" 2>/dev/null) || _owner=""
        if [[ -n "$_owner" ]]; then
            chown "$_owner" "$volpath" 2>/dev/null || warn "  ${key}: chown ${_owner} ${volpath} failed"
            chmod 755 "$volpath" 2>/dev/null || true
            info "  ${key}: rootfs owner set to ${_owner}"
        fi
    fi

    info "  OK: ${key} -> ${volpath}"
    return 0
}

# Determine the correct dump path INSIDE the snapshot.
# restic dump expects a path in the snapshot file tree. Depending on how the
# backup ran that is either "/" (content directly at the root) or the recorded
# paths[0]. Check via 'restic ls' which path actually holds entries.
# tatsaechlich Eintraege enthaelt.
determine_lxc_dump_path() {
    local snap_id="$1"
    local rec_root
    rec_root=$(get_snap_root_path "$snap_id") || rec_root=""

    # 1) Does the recorded path really exist as a directory in the snapshot?
    if [[ -n "$rec_root" && "$rec_root" != "/" ]]; then
        if restic ls "$snap_id" "$rec_root" 2>/dev/null | grep -q .; then
            echo "$rec_root"
            return 0
        fi
    fi

    # 2) Default case: the content sits at the root "/"
    #    (the backup ran as "cd <mount> && restic backup .")
    if restic ls "$snap_id" "/" 2>/dev/null | grep -qE '/(etc|usr|bin|var|dev|lib)(/|$)'; then
        echo "/"
        return 0
    fi

    # 3) Last fallback: accept the root if there are any entries at all
    if restic ls "$snap_id" 2>/dev/null | grep -q .; then
        echo "/"
        return 0
    fi

    return 1
}

# ================= Config Restore ==================

restore_config() {
    local src_id="$1" dst_id="$2" prefix="$3" snap_id="$4"
    local config_filename="${prefix}-${src_id}.conf"
    local restore_dir="/tmp/restic-restore-cfg-$$"
    mkdir -p "$restore_dir"

    if $DRY_RUN; then
        info "  [DRY-RUN] restic restore ${snap_id} --include ${config_filename}"
        rm -rf "$restore_dir"
        return 0
    fi

    restic restore "$snap_id" --include "$config_filename" --target "$restore_dir" || {
        error "  Config restore failed"
        rm -rf "$restore_dir"
        return 1
    }

    local src_file="${restore_dir}/${config_filename}"
    if [[ ! -f "$src_file" ]]; then
        src_file=$(find "$restore_dir" -name "$config_filename" -type f 2>/dev/null | head -1)
    fi
    if [[ ! -f "$src_file" ]]; then
        error "  Config file ${config_filename} not found in snapshot"
        rm -rf "$restore_dir"
        return 1
    fi

    local dst_dir dst_file
    if [[ "$prefix" == "vm" ]]; then
        dst_dir="/etc/pve/qemu-server"
    else
        dst_dir="/etc/pve/lxc"
    fi
    dst_file="${dst_dir}/${dst_id}.conf"

    python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
src, dst, out, pfx = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
lines = []

# Fields managed by Proxmox / dependent on the run that must NEVER end up in a
# geschriebene Config gehoeren:
#  - digest : checksum of the config state -> PVE reports 'unknown setting'
#             and recomputes it itself anyway
#  - parent / snaptime / <snapshotname> as a section : snapshot references
#    that would point at snapshots which do not exist after the restore
#  - lxc.* is kept (valid container options)
SKIP_KEYS = {'digest', 'parent', 'snaptime'}

def rewrite(v):
    if pfx == 'vm':
        return v.replace(f'vm-{src}-', f'vm-{dst}-')
    # LXC: ZFS/dir-Storages nutzen subvol-<id>-, LVM/LVM-thin nutzen vm-<id>-
    return v.replace(f'subvol-{src}-', f'subvol-{dst}-') \
            .replace(f'vm-{src}-', f'vm-{dst}-')

# PVE config convention for multi-line values (e.g. description):
# the first line is a normal 'key: <first line>', every continuation line
# starts with exactly one space. That way continuation lines are NOT
# Config-Keys fehlinterpretiert.
def emit(key, value):
    value = rewrite(str(value))
    parts = value.split('\n')
    if len(parts) <= 1:
        if value:
            lines.append(f'{key}: {value}')
        else:
            lines.append(f'{key}:')
    else:
        first = parts[0]
        lines.append(f'{key}: {first}' if first else f'{key}:')
        for cont in parts[1:]:
            # fuehrendes Leerzeichen = Fortsetzungszeile
            lines.append(f' {cont}')

for key, value in sorted(data.items()):
    if key in SKIP_KEYS:
        continue
    # Sections for named snapshots appear as nested dicts/lists with their own
    # key -> skip them, we only want the active config, not the snapshot
    # history.
    if isinstance(value, dict):
        # a whole snapshot section, do not take it over
        continue
    if isinstance(value, list) and value and all(
        isinstance(item, list) and len(item) == 2 for item in value
    ):
        for subkey, subvalue in value:
            if subkey in SKIP_KEYS:
                continue
            emit(subkey, subvalue)
        continue
    if isinstance(value, list):
        value = json.dumps(value)
    emit(key, value)

open(out, 'w').write('\n'.join(lines) + '\n')
" "$src_file" "$src_id" "$dst_id" "$dst_file" "$prefix" || {
        error "  Config conversion failed"
        rm -rf "$restore_dir"
        return 1
    }

    info "  Config: ${dst_file}"
    rm -rf "$restore_dir"
    return 0
}

# ================== List Disks ==================

show_disk_status() {
    local vmid="$1"
    local vm_config
    vm_config=$(qm config "$vmid" 2>/dev/null) || {
        error "Cannot read config for VM ${vmid}"
        return 1
    }
    local vm_name
    vm_name=$(echo "$vm_config" | awk -F': ' '/^name:/{print $2}')
    [[ -z "$vm_name" ]] && vm_name="(unnamed)"
    info "Disk backup status for VM ${vmid} (${vm_name}):"
    info ""

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

    while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        local key="${line%%:*}"
        local disk_tag="vm-${vmid}-${key}"
        printf "%-12s " "${key}:"

        if find_snap_by_tag "$disk_tag" "raw"; then
            local raw_snap="$FOUND_SNAP_ID"
            local _rinfo raw_time
            _rinfo=$(get_snap_info "$raw_snap") || true
            IFS=$'\t' read -r raw_time _ _ _ <<< "$_rinfo"
            printf "RAW (%s)\n" "$raw_snap"
            printf "%-12s   size: %s\n" "" "$(human_size "$(snap_tag_value "$raw_snap" "raw-size=")")"
            printf "%-12s   time: %s\n" "" "$raw_time"
        elif find_snap_by_tag "$disk_tag" "differential"; then
            local diff_snap="$FOUND_SNAP_ID"
            local _info diff_time parent_id parent_snap
            _info=$(get_snap_info "$diff_snap") || true
            IFS=$'\t' read -r diff_time parent_id parent_snap _ <<< "$_info"
            if [[ -n "$parent_id" ]] && snap_exists "$parent_id"; then
                printf "DIFF (%s) + FULL (%s)\n" "$diff_snap" "$parent_id"
                printf "%-12s   parent-snap: %s\n" "" "$parent_snap"
                printf "%-12s   diff time:   %s\n" "" "$diff_time"
            else
                printf "DIFF (%s) [parent forgotten!]\n" "$diff_snap"
            fi
        elif find_snap_by_tag "$disk_tag" "full"; then
            local full_snap="$FOUND_SNAP_ID"
            local _info full_time _ base_snap
            _info=$(get_snap_info "$full_snap") || true
            IFS=$'\t' read -r full_time _ _ base_snap <<< "$_info"
            printf "FULL (%s)\n" "$full_snap"
            printf "%-12s   base-snap: %s\n" "" "$base_snap"
            printf "%-12s   time:      %s\n" "" "$full_time"
        else
            printf "NO BACKUP\n"
        fi
    done < <(echo "$vm_config" | awk -v re="$disk_regex" '$0 ~ re {print}')

    printf "%-12s " "config:"
    if find_snap_by_tag "vm-${vmid}" "config"; then
        printf "CONFIG (%s)\n" "$FOUND_SNAP_ID"
    else
        printf "NO BACKUP\n"
    fi
}

# ================== LOCAL snapshot mount / extract ==================

# Handles a LOCAL/dir snapshot (called automatically when --snap points at
# one). Two modes of operation:
#   1) With a target directory ($2): extract exactly this snapshot there via
#      'restic restore' (no FUSE, no temp folder needed).
#   2) Without a target: mount the snapshot via FUSE into a fresh temp
#      directory and block in the foreground - for copying out individual
#      files from a second console. Ctrl+C unmounts cleanly and removes the
#      temp directory (trap).
mount_snapshot() {
    local snap_id="$1"
    local dest="${2:-}"
    local label="${3:-}"
    local rel="${4:-}"
    rel="${rel#/}"
    [[ -n "$rel" ]] && rel="${rel%/}/"

    if ! snap_exists "$snap_id"; then
        error "Snapshot ${snap_id} not found in repository"
        return 1
    fi

    # ---- Mode 1: extract straight into a target directory ----
    if [[ -n "$dest" ]]; then
        if [[ -e "$dest" && ! -d "$dest" ]]; then
            error "Target ${dest} exists and is not a directory"
            return 1
        fi
        mkdir -p "$dest" 2>/dev/null || {
            error "Could not create target directory ${dest}"
            return 1
        }
        # Warn if the target is not empty (restic overwrites/merges).
        if [[ -n "$(ls -A "$dest" 2>/dev/null)" ]]; then
            warn "Target directory ${dest} is not empty - existing files"
            warn "may be overwritten."
        fi

        local _rsize
        _rsize=$(get_snap_restore_size "$snap_id") || true
        info "Extracting snapshot ${snap_id} ($(human_size "$_rsize")) to ${dest} ..."

        if $DRY_RUN; then
            info "  [DRY-RUN] restic restore ${snap_id} --target ${dest}"
            return 0
        fi

        if ! restic restore "$snap_id" --target "$dest"; then
            error "restic restore of ${snap_id} to ${dest} failed"
            return 1
        fi
        info "Done: snapshot ${snap_id} is now available at ${dest}"
        return 0
    fi

    # ---- Mode 2: FUSE mount in the foreground ----
    # 'restic mount' needs FUSE. Report clearly and early if it is missing.
    if ! command -v fusermount &>/dev/null && ! command -v fusermount3 &>/dev/null; then
        warn "fusermount not found - 'restic mount' requires FUSE."
        warn "Install if needed: apt-get install -y fuse3"
    fi

    local mnt
    mnt=$(mktemp -d "/tmp/restic-mount-${snap_id}.XXXXXX") || {
        error "Could not create temporary mount directory"
        return 1
    }

    # Cleanup: unmount (if still mounted) and delete the temp folder.
    # Runs on Ctrl+C (INT), TERM and on a regular EXIT.
    local _cleaned=false
    _cleanup_mount() {
        $_cleaned && return 0
        _cleaned=true
        echo
        info "Ending mount, unmounting ..."
        if mountpoint -q "$mnt"; then
            fusermount -u "$mnt" 2>/dev/null \
                || fusermount3 -u "$mnt" 2>/dev/null \
                || umount "$mnt" 2>/dev/null \
                || warn "Unmounting ${mnt} failed - do it manually if needed: umount ${mnt}"
        fi
        rmdir "$mnt" 2>/dev/null || rm -rf "$mnt" 2>/dev/null || true
        info "Temporary directory removed."
    }
    trap '_cleanup_mount' INT TERM EXIT

    info "Mounting snapshot ${snap_id}${label:+ (${label})} at:"
    info "    ${mnt}"
    info ""
    info "The snapshot content is located at:"
    info "    ${mnt}/ids/${snap_id}/${rel}"
    info "(all snapshots are also available under ${mnt}/snapshots/ and ${mnt}/hosts/)"
    info ""
    info "Open a SECOND console to copy files, e.g.:"
    info "    cp -a ${mnt}/ids/${snap_id}/${rel}. /target/path/"
    info ""
    info ">>> Mount is running in the foreground - press Ctrl+C to stop. <<<"
    info ""

    # Blocks until Ctrl+C; the trap takes over afterwards.
    # Only the chosen snapshot is of interest (--tag is not unique here, so it
    # is reached through the short-id in the ids/ tree).
    local _rc=0
    restic mount "$mnt" || _rc=$?
    # 130 = terminated by SIGINT (Ctrl+C) -> the normal case, not an error.
    if [[ "$_rc" -ne 0 && "$_rc" -ne 130 ]]; then
        error "restic mount failed (exit code ${_rc})"
        _cleanup_mount
        trap - INT TERM EXIT
        return 1
    fi
    # Nach normalem Ende (falls restic selbst zurueckkehrt) ebenfalls aufraeumen.
    _cleanup_mount
    trap - INT TERM EXIT
    return 0
}

# ============ RAW image access (single file recovery) ============
#
# Makes a RAW snapshot usable without extracting the image:
#   1) the repository is mounted via FUSE, which exposes the .raw file of the
#      snapshot as a normal file
#   2) --mount-raw attaches that file with 'losetup -P' -> /dev/loopNp1, p2, ...
#      and mounts the partitions here on the host
#   3) --attach-raw hands the FILE to QEMU instead and lets the guest read it
#
# No disk space is needed for the image itself - reads are served from the
# repository through FUSE, which is also why browsing is noticeably slower than
# on a local disk.
#
# Read only by default: the FUSE view of the repository is read only anyway, so
# a writing guest would hit hard I/O errors. --rw puts a throwaway qcow2 overlay
# in front instead, so writes land next to the backup and never in it.
#
# Both ways hand out a BLOCK DEVICE, because that is what Proxmox accepts as a
# disk path ('qm set <vmid> -scsiN /dev/...'); a plain file path is rejected.
# Read only that is a loop device on the image. For --rw it must not be: a loop
# device always presents the RAW content of its backing file, and the overlay is
# a qcow2 CONTAINER - a guest reading it byte for byte finds the qcow2 header
# where the partition table belongs and reports the disk as uninitialised.
# 'qemu-nbd' understands qcow2 and exposes the disk INSIDE it as /dev/nbdN,
# which is the correct block device for the guest.

RAW_TMP=""            # temp dir: <tmp>/repo (FUSE), <tmp>/mnt (host mounts)
RAW_LOOPDEV=""
RAW_NBDDEV=""         # --rw: qcow2 overlay exposed as a block device
RAW_BLOCKDEV=""       # whichever of the two the callers hand out
RAW_FUSE_PID=""
RAW_OVERLAY=""
RAW_IMAGE=""
RAW_SID=""
declare -a RAW_MOUNTED=() RAW_VGS=() RAW_LVM_PARTS=()
RAW_RELEASED=false

# Resolve the snapshot, check the tools, mount the repo via FUSE and expose the
# image as a block device. Sets RAW_SID / RAW_IMAGE / RAW_BLOCKDEV (plus
# RAW_LOOPDEV or RAW_NBDDEV). $2 = "rw" for the writable overlay.
raw_open_image() {
    local snap_id="$1" mode="${2:-ro}"
    local _sj _tags rawfile _w=0

    _sj=$(restic snapshots --json "$snap_id" 2>/dev/null) || _sj=""
    if [[ -z "$_sj" || "$_sj" == "[]" || "$_sj" == "null" ]]; then
        error "Snapshot ${snap_id} not found in repository"
        return 1
    fi
    _tags=$(echo "$_sj" | jq -r '.[0].tags[]?' 2>/dev/null) || _tags=""
    if ! grep -qx "raw" <<< "$_tags"; then
        error "Snapshot ${snap_id} is not a RAW image backup."
        error "Use --list --type raw to find a suitable SNAP ID."
        return 1
    fi
    RAW_SID=$(echo "$_sj" | jq -r '.[0].short_id // empty' 2>/dev/null) || RAW_SID=""
    [[ -z "$RAW_SID" ]] && RAW_SID="$snap_id"

    rawfile=$(restic ls --json "$RAW_SID" 2>/dev/null \
        | jq -r 'select(.type? == "file") | .name' 2>/dev/null | head -1)
    if [[ -z "$rawfile" ]]; then
        error "Snapshot ${RAW_SID} contains no image file"
        return 1
    fi

    local _c
    for _c in losetup blkid; do
        command -v "$_c" &>/dev/null || { error "Missing: ${_c}"; return 1; }
    done
    if ! command -v fusermount &>/dev/null && ! command -v fusermount3 &>/dev/null; then
        error "fusermount not found - 'restic mount' requires FUSE."
        error "Install: apt-get install -y fuse3"
        return 1
    fi
    if [[ "$mode" == "rw" ]]; then
        for _c in qemu-img qemu-nbd; do
            command -v "$_c" &>/dev/null || {
                error "${_c} not found - needed for the writable overlay (--rw)."
                error "Install: apt-get install -y qemu-utils"
                return 1
            }
        done
    fi

    # 2 = nothing was opened, but that is not an error (dry-run)
    if $DRY_RUN; then
        info "[DRY-RUN] restic mount <tmp>/repo"
        if [[ "$mode" == "rw" ]]; then
            info "[DRY-RUN] qemu-img create -f qcow2 -b <tmp>/repo/ids/${RAW_SID}/${rawfile} -F raw <overlay>"
        fi
        if [[ "$mode" == "rw" ]]; then
            info "[DRY-RUN] qemu-nbd --connect=/dev/nbdN -f qcow2 <overlay>"
        else
            info "[DRY-RUN] losetup -f -P -r <image>"
        fi
        return 2
    fi

    RAW_TMP=$(mktemp -d "/tmp/restic-raw-${RAW_SID}.XXXXXX") || {
        error "Could not create temporary directory"
        return 1
    }
    mkdir -p "$RAW_TMP/repo" "$RAW_TMP/mnt"

    # --no-lock: the repository stays untouched, a parallel backup is not
    # blocked and read only media keep working.
    info "Opening repository via FUSE ..."
    restic mount --no-lock "$RAW_TMP/repo" &>/dev/null &
    RAW_FUSE_PID=$!
    while ! mountpoint -q "$RAW_TMP/repo"; do
        kill -0 "$RAW_FUSE_PID" 2>/dev/null || { error "restic mount terminated"; return 1; }
        sleep 0.5
        _w=$((_w + 1))
        [[ $_w -gt 120 ]] && { error "FUSE mount did not come up"; return 1; }
    done

    RAW_IMAGE="${RAW_TMP}/repo/ids/${RAW_SID}/${rawfile}"
    [[ -f "$RAW_IMAGE" ]] || {
        error "Image ${rawfile} not found below ${RAW_TMP}/repo/ids/${RAW_SID}/"
        return 1
    }

    # What a guest is about to see. An image whose first sector carries neither
    # a partition table nor a filesystem is reported as "uninitialised" there,
    # and that is worth saying here rather than letting the guest guess.
    local _pt _fs
    _pt=$(blkid -p -o value -s PTTYPE "$RAW_IMAGE" 2>/dev/null) || _pt=""
    _fs=$(blkid -p -o value -s TYPE "$RAW_IMAGE" 2>/dev/null) || _fs=""
    if [[ -n "$_pt" ]]; then
        info "Image ${rawfile}: partition table ${_pt}"
    elif [[ -n "$_fs" ]]; then
        info "Image ${rawfile}: ${_fs} directly on the disk, no partition table"
    else
        warn "Image ${rawfile} starts with neither a partition table nor a"
        warn "filesystem - a guest will report it as uninitialised. Check the"
        warn "backup itself: restic ls ${RAW_SID}"
    fi

    local -a lo_args=(-f -P --show)
    if [[ "$mode" == "rw" ]]; then
        # The overlay must sit on writable local storage, never in the repo.
        RAW_OVERLAY="${ATTACH_OVERLAY_DIR%/}/restic-overlay-${RAW_SID}.qcow2"
        mkdir -p "${ATTACH_OVERLAY_DIR%/}" 2>/dev/null || true
        rm -f "$RAW_OVERLAY"
        if ! qemu-img create -f qcow2 -b "$RAW_IMAGE" -F raw "$RAW_OVERLAY" &>/dev/null; then
            error "Could not create the overlay ${RAW_OVERLAY}"
            RAW_OVERLAY=""
            return 1
        fi
        info "Writable overlay: ${RAW_OVERLAY} (discarded on release)"
        info "The backup itself stays untouched."
    else
        lo_args+=(-r)
    fi

    # The overlay is a qcow2 container: a loop device would show that container
    # to the guest instead of the disk in it, so qemu-nbd does the job.
    if [[ "$mode" == "rw" ]]; then
        raw_nbd_connect || return 1
        RAW_BLOCKDEV="$RAW_NBDDEV"
        info "Image attached: ${RAW_NBDDEV} (writable overlay)"
        return 0
    fi

    RAW_LOOPDEV=$(losetup "${lo_args[@]}" "$RAW_IMAGE" 2>/dev/null) || RAW_LOOPDEV=""
    [[ -n "$RAW_LOOPDEV" ]] || { error "losetup failed for the image"; return 1; }
    command -v udevadm &>/dev/null && udevadm settle &>/dev/null || sleep 1
    RAW_BLOCKDEV="$RAW_LOOPDEV"
    info "Image attached: ${RAW_LOOPDEV} (read only)"
    return 0
}

# Expose RAW_OVERLAY as /dev/nbdN. The nbd module has a fixed number of
# devices, so a free one is one whose size is 0 and that no qemu-nbd holds.
raw_nbd_connect() {
    local d n _w=0
    if [[ ! -e /dev/nbd0 ]]; then
        modprobe nbd max_part=16 &>/dev/null || {
            error "Kernel module 'nbd' could not be loaded - needed for --rw."
            error "Try: modprobe nbd max_part=16"
            return 1
        }
        sleep 1
    fi
    for d in /dev/nbd*; do
        [[ -b "$d" ]] || continue
        n="${d##*/}"
        [[ "$n" =~ ^nbd[0-9]+$ ]] || continue
        [[ -e "/sys/block/${n}/pid" ]] && continue
        [[ "$(cat "/sys/block/${n}/size" 2>/dev/null || echo 0)" == "0" ]] || continue
        if qemu-nbd --connect="$d" --format=qcow2 --cache=writeback "$RAW_OVERLAY" &>/dev/null; then
            while [[ "$(cat "/sys/block/${n}/size" 2>/dev/null || echo 0)" == "0" ]]; do
                sleep 0.5
                _w=$((_w + 1))
                [[ $_w -gt 40 ]] && break
            done
            if [[ "$(cat "/sys/block/${n}/size" 2>/dev/null || echo 0)" == "0" ]]; then
                qemu-nbd --disconnect "$d" &>/dev/null || true
                error "${d} stayed empty after connecting the overlay"
                return 1
            fi
            RAW_NBDDEV="$d"
            command -v udevadm &>/dev/null && udevadm settle &>/dev/null || sleep 1
            return 0
        fi
    done
    error "No free /dev/nbdN device - all of them are in use."
    error "Check with: for d in /dev/nbd*; do qemu-nbd --disconnect \$d; done"
    return 1
}

# Undo raw_open_image and everything the two callers added, innermost first.
raw_release() {
    $RAW_RELEASED && return 0
    RAW_RELEASED=true
    echo
    info "Releasing image ..."
    local _m _vg
    # A disk handed to a VM has to go first: as long as the guest holds it,
    # QEMU keeps reading through the FUSE view that is torn down below.
    if [[ -n "$ATTACH_VM" && -n "$ATTACH_SLOT" ]]; then
        if qm set "$ATTACH_VM" "-delete" "$ATTACH_SLOT" &>/dev/null; then
            # QEMU keeps the image open until the guest releases the disk, and
            # the FUSE view below cannot go away while it does.
            local _w=0
            while qm config "$ATTACH_VM" 2>/dev/null | grep -q "^${ATTACH_SLOT}:"; do
                sleep 1
                _w=$((_w + 1))
                [[ $_w -ge 15 ]] && { warn "VM ${ATTACH_VM} still holds ${ATTACH_SLOT} - the guest may be using it"; break; }
            done
        else
            warn "Could not detach ${ATTACH_SLOT} from VM ${ATTACH_VM} - do it manually: qm set ${ATTACH_VM} -delete ${ATTACH_SLOT}"
        fi
        ATTACH_SLOT=""
    fi
    for _m in $(printf '%s\n' ${RAW_MOUNTED[@]+"${RAW_MOUNTED[@]}"} | sort -r); do
        umount "$_m" 2>/dev/null || umount -l "$_m" 2>/dev/null \
            || warn "Could not unmount ${_m}"
    done
    for _vg in ${RAW_VGS[@]+"${RAW_VGS[@]}"}; do
        vgchange -an "$_vg" &>/dev/null || warn "Could not deactivate VG ${_vg}"
    done
    if [[ -n "$RAW_LOOPDEV" ]]; then
        losetup -d "$RAW_LOOPDEV" 2>/dev/null || warn "Could not detach ${RAW_LOOPDEV}"
        RAW_LOOPDEV=""
    fi
    if [[ -n "$RAW_NBDDEV" ]]; then
        qemu-nbd --disconnect "$RAW_NBDDEV" &>/dev/null \
            || warn "Could not disconnect ${RAW_NBDDEV} (manually: qemu-nbd --disconnect ${RAW_NBDDEV})"
        RAW_NBDDEV=""
    fi
    RAW_BLOCKDEV=""
    [[ -n "$RAW_OVERLAY" ]] && { rm -f "$RAW_OVERLAY" 2>/dev/null || warn "Could not remove ${RAW_OVERLAY}"; }
    if [[ -n "$RAW_TMP" ]] && mountpoint -q "$RAW_TMP/repo"; then
        fusermount -u "$RAW_TMP/repo" 2>/dev/null \
            || fusermount3 -u "$RAW_TMP/repo" 2>/dev/null \
            || umount "$RAW_TMP/repo" 2>/dev/null \
            || warn "Could not unmount ${RAW_TMP}/repo"
    fi
    [[ -n "$RAW_FUSE_PID" ]] && kill "$RAW_FUSE_PID" 2>/dev/null || true
    [[ -n "$RAW_TMP" ]] && rm -rf "$RAW_TMP" 2>/dev/null || true
    info "Done."
}

# Block until the FUSE process goes away (Ctrl+C runs the trap instead).
raw_wait() {
    info ""
    info ">>> Running in the foreground - press Ctrl+C to release. <<<"
    info ""
    while kill -0 "$RAW_FUSE_PID" 2>/dev/null; do
        sleep 5
    done
}

# Mount one device read only below <tmp>/mnt, skipping what cannot be mounted.
# $1 device, $2 label for the target directory.
raw_mount_dev() {
    local dev="$1" label="$2" fstype tgt
    fstype=$(blkid -o value -s TYPE "$dev" 2>/dev/null) || fstype=""
    case "$fstype" in
        LVM2_member) RAW_LVM_PARTS+=("$dev"); info "  ${label}: LVM physical volume"; return 0 ;;
        ""|swap|crypto_LUKS|zfs_member)
            info "  ${label}: ${fstype:-unknown filesystem} - not mounted"
            return 0 ;;
    esac
    tgt="${RAW_TMP}/mnt/${label}"
    mkdir -p "$tgt"
    # norecovery/noload as a fallback: a dirty journal cannot be replayed on a
    # read only device.
    if mount -o ro "$dev" "$tgt" 2>/dev/null \
        || mount -o ro,norecovery "$dev" "$tgt" 2>/dev/null; then
        RAW_MOUNTED+=("$tgt")
        info "  ${label} (${fstype}) -> ${tgt}"
    else
        rmdir "$tgt" 2>/dev/null || true
        warn "  ${label} (${fstype}): mount failed - driver missing?"
    fi
    return 0
}

# Activate LVM found inside the image and mount its logical volumes.
raw_mount_lvm() {
    [[ ${#RAW_LVM_PARTS[@]} -gt 0 ]] || return 0
    if ! command -v vgchange &>/dev/null; then
        warn "LVM found in the image, but lvm2 is not installed"
        return 0
    fi
    pvscan --cache &>/dev/null || true
    local p vg foreign lv
    for p in "${RAW_LVM_PARTS[@]}"; do
        vg=$(pvs --noheadings -o vg_name "$p" 2>/dev/null | tr -d ' ') || vg=""
        [[ -z "$vg" ]] && continue
        printf '%s\n' ${RAW_VGS[@]+"${RAW_VGS[@]}"} | grep -qx "$vg" && continue
        # A VG is only activated when the host has no VG of the same name,
        # otherwise LVM would work on ambiguous names.
        foreign=$(pvs --noheadings -o pv_name,vg_name 2>/dev/null \
            | awk -v v="$vg" -v l="$RAW_LOOPDEV" '$2==v && index($1,l)!=1 {c++} END{print c+0}')
        if [[ "$foreign" -gt 0 ]]; then
            warn "  VG '${vg}' also exists on this host - not activated"
            warn "    (import it manually with vgimportclone if you need it)"
            continue
        fi
        if ! vgchange -ay "$vg" &>/dev/null; then
            warn "  VG '${vg}' could not be activated"
            continue
        fi
        RAW_VGS+=("$vg")
        for lv in /dev/"${vg}"/*; do
            [[ -b "$lv" ]] || continue
            raw_mount_dev "$lv" "${vg}-$(basename "$lv")"
        done
    done
    return 0
}

# --mount: one entry point for every snapshot that HOLDS FILES.
#   raw          -> the image as a read-only loop device, partitions mounted
#   lxc          -> the container root, exactly as it was backed up
#   local/config -> the file tree of the snapshot
# A zfs send stream (TYPE=FULL/DIFF) is a stream, not a filesystem: it only
# becomes readable once a restore has written it back to a zvol.
mount_any_snapshot() {
    local snap_id="$1" _sj _tags _sid _name _path _lbl
    _sj=$(restic snapshots --json "$snap_id" 2>/dev/null) || _sj=""
    if [[ -z "$_sj" || "$_sj" == "[]" || "$_sj" == "null" ]]; then
        error "Snapshot ${snap_id} not found in repository"
        return 1
    fi
    _tags=$(echo "$_sj" | jq -r '.[0].tags[]?' 2>/dev/null) || _tags=""
    _sid=$(echo "$_sj" | jq -r '.[0].short_id // empty' 2>/dev/null) || _sid=""
    [[ -z "$_sid" ]] && _sid="$snap_id"
    _name=$(echo "$_sj" | jq -r '.[0].tags[]? | select(startswith("name="))' 2>/dev/null | head -1) || _name=""
    _name="${_name#name=}"

    if grep -qx "raw" <<< "$_tags"; then
        mount_raw_snapshot "$_sid"
        return $?
    fi
    if grep -qx "lxc" <<< "$_tags"; then
        # backed up with 'cd <rootfs> && restic backup .', so the container
        # root sits at the top of the snapshot - no path prefix.
        mount_snapshot "$_sid" "" "LXC rootfs${_name:+ of ${_name}}" ""
        return $?
    fi
    if grep -qx "config" <<< "$_tags"; then
        mount_snapshot "$_sid" "" "config snapshot${_name:+ of ${_name}}" ""
        return $?
    fi
    if grep -qx "local" <<< "$_tags"; then
        # backed up by absolute path, which the snapshot tree mirrors.
        _path=$(echo "$_sj" | jq -r '.[0].paths[0] // empty' 2>/dev/null) || _path=""
        _lbl=$(grep -x "dir-.*" <<< "$_tags" | head -1) || _lbl=""
        mount_snapshot "$_sid" "" "LOCAL backup of ${_path:-${_lbl#dir-}}" "$_path"
        return $?
    fi

    error "Snapshot ${_sid} holds a zfs send stream (TYPE=FULL/DIFF in --list)."
    error "A stream is not a filesystem and cannot be mounted - restore it to a"
    error "free ID instead: --snap <config-id> --target <ID>"
    error "Mountable types: RAW, LXC-FS, LOCAL, CONFIG."
    return 1
}

# --mount-raw: mount every readable partition of the image on THIS host.
mount_raw_snapshot() {
    local _rc=0
    RAW_LVM_PARTS=()
    raw_open_image "$1" ro || _rc=$?
    [[ "$_rc" -eq 2 ]] && return 0
    [[ "$_rc" -eq 0 ]] || { raw_release; return "$_rc"; }
    trap 'raw_release' INT TERM EXIT

    local p
    for p in "${RAW_LOOPDEV}"p*; do
        [[ -b "$p" ]] || continue
        raw_mount_dev "$p" "$(basename "$p")"
    done
    # Image without a partition table (filesystem or PV directly on the disk)
    if [[ ! -b "${RAW_LOOPDEV}p1" ]]; then
        raw_mount_dev "$RAW_LOOPDEV" "$(basename "$RAW_LOOPDEV")"
    fi
    raw_mount_lvm

    if [[ ${#RAW_MOUNTED[@]} -eq 0 ]]; then
        warn "No filesystem could be mounted."
        warn "The loop device ${RAW_LOOPDEV} is available for manual inspection."
        warn "If this host cannot read the guest filesystem, hand the image to a"
        warn "VM that can: --attach-raw ${RAW_SID} --vm <VMID>"
    fi

    info ""
    info "Mounted read only below:"
    info "    ${RAW_TMP}/mnt/"
    info ""
    info "Open a SECOND console to copy files, e.g.:"
    info "    cp -a ${RAW_TMP}/mnt/<part>/etc/hosts /root/"
    info ""
    info "Note: reads go through FUSE into the repository, so browsing is"
    info "slower than on a local disk."
    raw_wait
    raw_release
    trap - INT TERM EXIT
    return 0
}

# Leftovers of an --attach-raw or --mount-raw run that was killed hard - SIGKILL,
# an OOM kill, a host crash. The trap never ran, so any of these can still be
# around: a disk in a VM config, a loop or nbd device, the FUSE mount, the temp
# directory, a writable overlay.
#
# Only our own artefacts are touched, recognised by:
#   VM config : a /dev/loopN or /dev/nbdN carrying BOTH backup=0 and
#               aio=threads - the flags --attach-raw always sets, and a
#               combination that nothing else on a Proxmox host produces
#   loop dev  : a backing file below /tmp/restic-raw-* or an overlay in
#               ATTACH_OVERLAY_DIR
#   nbd dev   : served by a qemu-nbd whose command line names one of our
#               overlays
# Anything outside those patterns is left alone.
#
# With a VM given, ONLY what that VM refers to is released. That matters: a
# loop device of a different VM may belong to a session that is still running,
# and tearing it down would break a restore in progress. Without a VM every
# leftover of ours is fair game, so that form must not be used while a session
# is active.
detach_raw_leftovers() {
    local only_vm="${1:-}"
    local -a vmids=()
    local -A ours=() doomed=() dirs=() files=()
    local id line key vol dev bf sid m f found=0

    # --- which loop devices are ours, and what do they read from? ---
    while IFS=$' \t' read -r dev bf; do
        [[ -n "$dev" && -n "$bf" ]] || continue
        case "$bf" in
            /tmp/restic-raw-*|"${ATTACH_OVERLAY_DIR%/}"/restic-overlay-*) ours["$dev"]="$bf" ;;
        esac
    done < <(losetup -l -O NAME,BACK-FILE --noheadings 2>/dev/null || true)

    # nbd devices carry no backing-file attribute - the qemu-nbd serving them
    # does, on its command line.
    local _n _pid
    for dev in /dev/nbd*; do
        [[ -b "$dev" ]] || continue
        _n="${dev##*/}"
        _pid=$(cat "/sys/block/${_n}/pid" 2>/dev/null) || continue
        [[ -n "$_pid" ]] || continue
        bf=$(tr '\0' '\n' < "/proc/${_pid}/cmdline" 2>/dev/null | grep -m1 'restic-overlay-') || bf=""
        [[ -n "$bf" ]] && ours["$dev"]="$bf"
    done

    if [[ -n "$only_vm" ]]; then
        vmids=("$only_vm")
    else
        mapfile -t vmids < <(qm list 2>/dev/null | awk 'NR>1 && NF {print $1}')
        # No VM filter: every loop device of ours is a leftover, including one
        # whose VM config entry has already been removed.
        for dev in "${!ours[@]}"; do doomed["$dev"]=1; done
    fi

    # --- disks still sitting in a VM config ---
    for id in ${vmids[@]+"${vmids[@]}"}; do
        if ! qm config "$id" &>/dev/null; then
            [[ -n "$only_vm" ]] && { error "VM ${id} does not exist"; return 1; }
            continue
        fi
        while IFS= read -r line; do
            key="${line%%:*}"
            vol="${line#*:}"
            vol="${vol# }"
            [[ "$vol" == *backup=0* && "$vol" == *aio=threads* ]] || continue
            dev="${vol%%,*}"
            case "$dev" in
                /dev/loop*|/dev/nbd*) doomed["$dev"]=1 ;;
                *)                    continue ;;
            esac
            found=$((found+1))
            info "VM ${id}: detaching ${key} (${vol})"
            $DRY_RUN && continue
            qm set "$id" "-delete" "$key" &>/dev/null \
                || warn "  qm set ${id} -delete ${key} failed - do it manually"
        done < <(qm config "$id" 2>/dev/null \
            | grep -E '^(ide[0-3]|sata[0-5]|scsi[0-9]+|virtio[0-9]+):' || true)
    done

    # --- temp directories and overlays belonging to the doomed devices ---
    for dev in "${!doomed[@]}"; do
        bf="${ours[$dev]:-}"
        [[ -n "$bf" ]] || continue
        case "$bf" in
            /tmp/restic-raw-*)
                m="${bf#/tmp/}"
                dirs["/tmp/${m%%/*}"]=1
                ;;
            *)
                # an overlay: /…/restic-overlay-<sid>.qcow2 - its temp directory
                # is /tmp/restic-raw-<sid>.XXXXXX and holds the FUSE mount
                files["$bf"]=1
                sid="${bf##*/restic-overlay-}"
                sid="${sid%.qcow2}"
                for m in /tmp/restic-raw-"${sid}".*; do
                    [[ -d "$m" ]] && dirs["$m"]=1
                done
                ;;
        esac
    done
    # Without a VM filter, also catch what never got as far as a loop device.
    if [[ -z "$only_vm" ]]; then
        for m in /tmp/restic-raw-*; do [[ -d "$m" ]] && dirs["$m"]=1; done
        for f in "${ATTACH_OVERLAY_DIR%/}"/restic-overlay-*.qcow2; do
            [[ -f "$f" ]] && files["$f"]=1
        done
    fi

    # --- host side mounts of an interrupted --mount-raw ---
    for m in "${!dirs[@]}"; do
        for f in "$m"/mnt/*; do
            [[ -d "$f" ]] || continue
            mountpoint -q "$f" || continue
            found=$((found+1))
            info "Unmounting ${f}"
            $DRY_RUN && continue
            umount "$f" 2>/dev/null || umount -l "$f" 2>/dev/null \
                || warn "  could not unmount ${f}"
        done
    done

    # --- loop / nbd devices ---
    for dev in "${!doomed[@]}"; do
        [[ -b "$dev" ]] || continue
        found=$((found+1))
        info "Detaching ${dev}"
        $DRY_RUN && continue
        if [[ "$dev" == /dev/nbd* ]]; then
            qemu-nbd --disconnect "$dev" &>/dev/null \
                || warn "  ${dev} could not be disconnected (qemu-nbd --disconnect ${dev})"
        elif ! losetup -d "$dev" 2>/dev/null; then
            warn "  ${dev} is still busy. A volume group from inside the image may"
            warn "  still be active: check 'lsblk ${dev}', then 'vgchange -an <vg>'"
        fi
    done

    # --- the FUSE view of the repository ---
    for m in "${!dirs[@]}"; do
        mountpoint -q "$m/repo" || continue
        found=$((found+1))
        info "Unmounting the repository view ${m}/repo"
        $DRY_RUN && continue
        fusermount -u "$m/repo" 2>/dev/null \
            || fusermount3 -u "$m/repo" 2>/dev/null \
            || umount "$m/repo" 2>/dev/null \
            || warn "  could not unmount ${m}/repo"
    done

    # --- overlays and temp directories ---
    for f in "${!files[@]}"; do
        [[ -f "$f" ]] || continue
        found=$((found+1))
        info "Removing the overlay ${f}"
        $DRY_RUN || rm -f "$f" 2>/dev/null || warn "  could not remove ${f}"
    done
    for m in "${!dirs[@]}"; do
        [[ -d "$m" ]] || continue
        # A still mounted FUSE view means the unmount above failed - removing the
        # directory would then hide a live mount instead of releasing it.
        mountpoint -q "$m/repo" && { warn "  ${m} still has a mounted repository view - left in place"; continue; }
        found=$((found+1))
        info "Removing the temp directory ${m}"
        $DRY_RUN || rm -rf "$m" 2>/dev/null || warn "  could not remove ${m}"
    done

    if [[ "$found" -eq 0 ]]; then
        info "Nothing to clean up."
    elif $DRY_RUN; then
        info "[DRY-RUN] ${found} item(s) found, nothing changed."
    else
        info "Done - ${found} item(s) released."
    fi
    return 0
}

# Lowest unused scsiN slot of a VM, "" when the controller is full.
attach_free_slot() {
    local vmid="$1" cfg n
    cfg=$(qm config "$vmid" 2>/dev/null) || return 1
    for n in $(seq 1 30); do
        grep -q "^scsi${n}:" <<< "$cfg" && continue
        printf 'scsi%s' "$n"
        return 0
    done
    return 1
}

# --attach-raw: hand the image to a running VM as an extra SCSI disk.
attach_raw_to_vm() {
    local snap_id="$1" vmid="$2" mode="$3"
    local cfg drive_opts

    if ! qm config "$vmid" &>/dev/null; then
        error "VM ${vmid} does not exist"
        return 1
    fi
    cfg=$(qm config "$vmid" 2>/dev/null)
    if [[ "$(qm status "$vmid" 2>/dev/null)" != *running* ]]; then
        error "VM ${vmid} is not running - hot-plugging a disk needs a running VM."
        error "Start it first, or use --mount-raw to read the image on this host."
        return 1
    fi
    if ! grep -qE '^scsihw:.*virtio-scsi' <<< "$cfg"; then
        warn "VM ${vmid} has no virtio-scsi controller (scsihw). Hot-plug will"
        warn "probably fail - set 'scsihw: virtio-scsi-single' on the VM."
    fi
    # An explicit hotplug list that omits 'disk' overrides the Proxmox default.
    if grep -qE '^hotplug:' <<< "$cfg" && ! grep -qE '^hotplug:.*disk' <<< "$cfg"; then
        warn "VM ${vmid} does not have 'disk' in its hotplug list - the guest may"
        warn "not see the new disk until it is rebooted."
    fi

    ATTACH_SLOT=$(attach_free_slot "$vmid") || {
        error "No free scsiN slot on VM ${vmid}"
        return 1
    }
    ATTACH_VM="$vmid"

    if $DRY_RUN; then
        local _ro=",ro=1"
        [[ "$mode" == "rw" ]] && _ro=""
        raw_open_image "$snap_id" "$mode" || true
        info "[DRY-RUN] qm set ${vmid} -${ATTACH_SLOT} <blockdev>,backup=0,aio=threads,cache=writeback${_ro}"
        ATTACH_VM=""; ATTACH_SLOT=""
        return 0
    fi

    local _rc=0
    raw_open_image "$snap_id" "$mode" || _rc=$?
    if [[ "$_rc" -ne 0 ]]; then
        ATTACH_VM=""; ATTACH_SLOT=""
        raw_release
        return "$_rc"
    fi
    trap 'raw_release' INT TERM EXIT

    # RAW_BLOCKDEV, not the file: Proxmox only takes a block device as a disk
    # path. Read only that is the loop device on the image, with --rw the nbd
    # device that unpacks the qcow2 overlay.
    # aio=threads: every read travels through FUSE into the repository, which is
    # slow and would stall an io_uring/native queue.
    # cache=writeback: O_DIRECT is a poor fit for a device backed by FUSE, and
    # the page cache saves repeated trips into the repository.
    # backup=0: this disk must never end up in a backup of the target VM.
    drive_opts="${RAW_BLOCKDEV},backup=0,aio=threads,cache=writeback"
    [[ "$mode" == "rw" ]] || drive_opts+=",ro=1"

    info "Attaching to VM ${vmid} as ${ATTACH_SLOT} ..."
    local _qmout _l
    if ! _qmout=$(qm set "$vmid" "-${ATTACH_SLOT}" "$drive_opts" 2>&1); then
        error "qm set ${vmid} -${ATTACH_SLOT} ${drive_opts} failed"
        # Proxmox says why - swallowing that turns every cause into the same
        # dead end.
        while IFS= read -r _l; do
            [[ -n "$_l" ]] && error "  ${_l}"
        done <<< "$_qmout"
        ATTACH_SLOT=""
        raw_release
        trap - INT TERM EXIT
        return 1
    fi

    info ""
    if [[ "$mode" == "rw" ]]; then
        info "Attached: VM ${vmid} now has ${ATTACH_SLOT} (writable overlay)"
    else
        info "Attached: VM ${vmid} now has ${ATTACH_SLOT} (read only)"
    fi
    info ""
    info "In the guest: rescan the SCSI bus if the disk does not appear,"
    info "then mount it there. Linux:"
    info "    echo '- - -' > /sys/class/scsi_host/host0/scan ; lsblk"
    info "Windows: Disk Management -> Rescan Disks (import a foreign disk or a"
    info "Storage Spaces pool read-only from there)."
    info ""
    info "Windows shows the disk as offline or uninitialised? It is a copy of a"
    info "disk the VM already has online, so the disk/GPT signature is a"
    info "duplicate. Windows only takes such a disk online after writing a new"
    info "signature - which a read only disk cannot do. So attaching it to the"
    info "SAME VM works, with --rw:"
    info "    Disk Management -> right-click the disk itself (the grey box on"
    info "    the left, not a partition) -> Online"
    info "Windows writes the new signature by itself, into the throwaway overlay"
    info "- the backup stays untouched. If Online is greyed out or fails, in"
    info "diskpart: select disk <N> / attributes disk clear readonly /"
    info "uniqueid disk id=<new GUID> / online disk. Read only works without"
    info "any of this on a DIFFERENT VM, where nothing collides."
    info ""
    if [[ "$mode" == "rw" ]]; then
        info "Writable via the overlay - the backup is NOT modified, and every"
        info "write is discarded when this command ends."
    else
        info "Read only: do NOT let the guest mount it writable, that fails hard."
    fi
    info ""
    info "To release the disk again:"
    info "  1) let go of it INSIDE the guest first"
    info "     Windows: Disk Management -> right-click the disk (the grey box on"
    info "              the left) -> Offline. Or in diskpart: list disk /"
    info "              select disk <N> / offline disk"
    info "     Linux:   umount <mountpoint>, then"
    info "              echo 1 > /sys/block/<sdX>/device/delete"
    info "  2) THEN press Ctrl+C here: the disk is unplugged from VM ${vmid} and"
    info "     overlay plus repository view are removed"
    info "A guest still holding the disk blocks the unplug - this command then"
    info "waits, warns, and leaves the disk in the VM. Take it offline in the"
    info "guest and run: ResticRestore.sh --detach-raw --vm ${vmid}"
    info ""
    info "Reads are served from the repository through FUSE. Expect it to be"
    info "slow, and expect guest I/O timeouts if you pull large amounts at once."
    raw_wait
    raw_release
    trap - INT TERM EXIT
    return 0
}

# ================== Main ==================

ACTION=""
SRC_ID=""
SRC_TYPE=""      # vm | ct - determined automatically
DST_ID=""
SNAPSHOT_ID=""
DATE_FILTER=""
DRY_RUN=false
SHOW_SIZE=false
FILTER_TYPE=""
FILTER_NAME=""
FILTER_CONFIG=""
RAW_SNAP=""
ATTACH_VM=""
ATTACH_SLOT=""
ATTACH_RW=false
# The qcow2 overlay for --attach-raw --rw. Must be on writable local storage.
ATTACH_OVERLAY_DIR="${ATTACH_OVERLAY_DIR:-/var/tmp}"

# --opt=value Schreibweise in --opt value normalisieren
_args=()
for _a in "$@"; do
    case "$_a" in
        --*=*) _args+=("${_a%%=*}" "${_a#*=}") ;;
        *)     _args+=("$_a") ;;
    esac
done
set -- ${_args[@]+"${_args[@]}"}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --list)       ACTION="list"; shift ;;
        --mount|--mount-raw)
                      ACTION="mount"; [[ $# -lt 2 ]] && { error "$1 requires a snapshot ID"; exit 1; }; RAW_SNAP="$2"; shift 2 ;;
        --attach-raw) ACTION="attach-raw"; [[ $# -lt 2 ]] && { error "--attach-raw requires a snapshot ID"; exit 1; }; RAW_SNAP="$2"; shift 2 ;;
        --detach-raw) ACTION="detach-raw"; shift ;;
        --vm)         [[ $# -lt 2 ]] && { error "--vm requires a VMID"; exit 1; }; ATTACH_VM="$2"; shift 2 ;;
        --rw)         ATTACH_RW=true; shift ;;
        --list-disks) ACTION="list-disks"; [[ $# -lt 2 ]] && { error "--list-disks requires a VMID"; exit 1; }; SRC_ID="$2"; shift 2 ;;
        --machine)    [[ $# -lt 2 ]] && { error "--machine requires an ID"; exit 1; }; SRC_ID="$2"; shift 2 ;;
        --target)     [[ $# -lt 2 ]] && { error "--target requires an ID"; exit 1; }; DST_ID="$2"; shift 2 ;;
        --snap)       [[ $# -lt 2 ]] && { error "--snap requires a snapshot ID"; exit 1; }; SNAPSHOT_ID="$2"; shift 2 ;;
        --date)       [[ $# -lt 2 ]] && { error "--date requires a date (YYYY-MM-DD)"; exit 1; }; DATE_FILTER="$2"; shift 2 ;;
        --type)       [[ $# -lt 2 ]] && { error "--type requires a value"; exit 1; }; FILTER_TYPE="$2"; shift 2 ;;
        --name)       [[ $# -lt 2 ]] && { error "--name requires a value"; exit 1; }; FILTER_NAME="$2"; shift 2 ;;
        --config)     [[ $# -lt 2 ]] && { error "--config requires a snapshot ID"; exit 1; }; FILTER_CONFIG="$2"; shift 2 ;;
        --dry-run)    DRY_RUN=true; shift ;;
        --size)       SHOW_SIZE=true; shift ;;
        --help|-h)    show_usage; exit 0 ;;
        *) error "Unknown option: $1"; show_usage; exit 1 ;;
    esac
done

# Without --list / --list-disks it is a restore request as soon as a
# selection (--machine or --snap) was given
if [[ -z "$ACTION" ]]; then
    if [[ -n "$SNAPSHOT_ID" || -n "$SRC_ID" ]]; then
        ACTION="restore"
    else
        show_usage
        exit 1
    fi
fi

for cmd in restic qm pct pvesm jq mountpoint; do
    command -v "$cmd" &>/dev/null || { error "Missing: ${cmd}"; exit 1; }
done
# zfs is only needed for ZFS restores - optional on pure LVM/dir hosts
command -v zfs &>/dev/null || warn "zfs not found - only RAW and file-based restores are possible"

# --- Clean up leftovers: pure host side, the backup drive is not needed ---
# Deliberately handled before the repository block below, so a cleanup works
# even when the backup medium is not plugged in at all.
if [[ "$ACTION" == "detach-raw" ]]; then
    command -v losetup &>/dev/null || { error "Missing: losetup"; exit 1; }
    detach_raw_leftovers "$ATTACH_VM"
    exit $?
fi

# --- Make sure the repository is reachable ---
# If opening the repo fails: try all UUIDs from the config until a medium with
# a readable repo is found (same as ResticBackup.sh). An already mounted but
# unusable medium is unmounted for that.

# Mounts all UUIDs one after another; only accepts media whose repo can
# actually be opened (for a restore a fresh medium without a repo is
# useless)
try_mount_repo() {
    local _uuid
    for _uuid in ${UUIDS[@]+"${UUIDS[@]}"}; do
        mount "UUID=${_uuid}" "${RESTIC_BASEFOLDER}" ${MOUNTOPT[@]+"${MOUNTOPT[@]}"} 2>/dev/null || true
        mountpoint -q "${RESTIC_BASEFOLDER}" || continue
        if restic cat config &>/dev/null; then
            info "Backup medium mounted (UUID ${_uuid})"
            return 0
        fi
        warn "UUID ${_uuid}: no readable restic repo on this medium - trying the next one"
        umount "${RESTIC_BASEFOLDER}" 2>/dev/null || true
    done
    return 1
}

if ! restic cat config &>/dev/null; then
    info "Repository ${RESTIC_REPOSITORY} not reachable - trying to mount backup media"
    mkdir -p "${RESTIC_BASEFOLDER}" 2>/dev/null || true
    if mountpoint -q "${RESTIC_BASEFOLDER}"; then
        warn "The existing mount holds no readable repo - unmounting and trying all UUIDs"
        if ! umount "${RESTIC_BASEFOLDER}"; then
            error "Unmounting ${RESTIC_BASEFOLDER} failed (in use?)"
            exit 1
        fi
    fi
    if ! try_mount_repo; then
        error "Repository could not be opened on any of the configured media."
        error "Possible causes:"
        error "  - backup medium not attached, or UUIDs in ${config} wrong/empty"
        error "  - password file missing: ${RESTIC_PASSWORDFILE}"
        error "    (created by ResticBackup.sh on its first run)"
        exit 1
    fi
fi

# --- Clear a leftover repository lock ---
# A run that was killed leaves a lock behind and every later restic call fails
# with "repository is already locked". 'restic unlock' removes exactly the
# locks that are no longer being refreshed.
# Deliberately WITHOUT --remove-all here: unlike ResticBackup.sh this script
# holds no flock, so a backup could legitimately be running in parallel and
# --remove-all would rip its lock away mid-run.
clear_stale_repo_lock() {
    restic list locks 2>/dev/null | grep -q . || return 0
    warn "Lock found in the repository (previous run aborted?) - removing stale locks"
    restic unlock &>/dev/null || true
    if restic list locks 2>/dev/null | grep -q .; then
        warn "A lock is still held - another restic run may be active."
        warn "If you are sure nothing is running: restic unlock --remove-all"
    else
        info "Stale lock removed"
    fi
}
clear_stale_repo_lock

# --- List snapshots ---
if [[ "$ACTION" == "list" ]]; then
    info "Snapshots in ${RESTIC_REPOSITORY}:"
    print_snapshot_table || warn "No snapshots found (matching the given filters)"
    exit 0
fi

if [[ "$ACTION" == "list-disks" ]]; then
    show_disk_status "$SRC_ID"
    exit 0
fi

# --- Browse a backup without restoring it (single file recovery) ---
if [[ "$ACTION" == "mount" ]]; then
    mount_any_snapshot "$RAW_SNAP"
    exit $?
fi

if [[ "$ACTION" == "attach-raw" ]]; then
    if [[ -z "$ATTACH_VM" ]]; then
        error "--attach-raw also needs --vm <VMID> (the running VM to attach to)"
        error "To read the image on this host instead: --mount ${RAW_SNAP}"
        exit 1
    fi
    if $ATTACH_RW; then
        attach_raw_to_vm "$RAW_SNAP" "$ATTACH_VM" rw
    else
        attach_raw_to_vm "$RAW_SNAP" "$ATTACH_VM" ro
    fi
    exit $?
fi

if [[ -z "$ACTION" ]]; then
    show_usage
    exit 1
fi

# --- pv is only needed for the actual data transfer of a restore ---
# Installing it in the script header would run apt-get on every --help and
# --list call as well.
if ! command -v pv &>/dev/null; then
    echo "pv not found. Starting installation..."
    apt-get update && apt-get install -y pv \
        || echo "WARNING: installing pv failed, restore runs without a progress display"
fi

# --- Special case: --snap points at a LOCAL/dir snapshot ---
# A LOCAL snapshot belongs to no VM/CT config. With --snap on such a snapshot
# the intent is unambiguous: provide the directory content. With --target
# <DIR> exactly this snapshot is extracted there, without --target it is
# mounted via FUSE into a foreground temp directory.
if [[ -n "$SNAPSHOT_ID" ]]; then
    _snap_json=$(restic snapshots --json "$SNAPSHOT_ID" 2>/dev/null) || _snap_json=""
    if [[ -z "$_snap_json" || "$_snap_json" == "[]" || "$_snap_json" == "null" ]]; then
        error "Snapshot ${SNAPSHOT_ID} not found in repository"
        exit 1
    fi
    _snap_tags=$(echo "$_snap_json" | jq -r '.[0].tags[]?' 2>/dev/null) || _snap_tags=""
    if grep -qx "local" <<< "$_snap_tags" && ! grep -qx "config" <<< "$_snap_tags"; then
        # normalize to the short_id for clean messages
        _local_sid=$(echo "$_snap_json" | jq -r '.[0].short_id // empty' 2>/dev/null) || _local_sid=""
        [[ -z "$_local_sid" ]] && _local_sid="$SNAPSHOT_ID"
        info "Snapshot ${_local_sid} is a LOCAL/dir backup (no VM/CT config)."
        if [[ -n "$DST_ID" ]]; then
            info "Extracting it directly into the target directory."
        else
            info "No --target given -> mounting it in the foreground."
        fi
        # With --target <DIR>: extract. Without: FUSE mount in the foreground.
        mount_snapshot "$_local_sid" "$DST_ID"
        exit $?
    fi
fi

# --- Restore-Vorbereitung ---

# --target is mandatory: it prevents accidental restores when the intention
# was only to filter with --machine/--snap
if [[ -z "$DST_ID" ]]; then
    error "A --target <ID> is mandatory for a restore."
    error "Use --list to show available backups."
    exit 1
fi

# Safety rule: existing machines are NEVER overwritten.
# There is deliberately no override option - delete the machine manually
# (qm destroy / pct destroy) or choose a different --target ID.
machine_exists() {
    qm config "$1" &>/dev/null || pct config "$1" &>/dev/null \
        || [[ -f "/etc/pve/qemu-server/$1.conf" || -f "/etc/pve/lxc/$1.conf" ]]
}
if machine_exists "$DST_ID"; then
    if $DRY_RUN; then
        warn "Target ID ${DST_ID} already exists - a real restore would ABORT here"
    else
        error "Target ID ${DST_ID} already exists - restore aborted."
        error "Existing machines are never overwritten. Options:"
        error "  1) delete the machine manually (qm destroy ${DST_ID} / pct destroy ${DST_ID})"
        error "  2) choose a different target ID: --target <free ID>"
        exit 1
    fi
fi

FIND_ARGS=()
if [[ -n "$DATE_FILTER" ]]; then
    info "Point-in-time restore: ${DATE_FILTER}"
    FIND_ARGS=(--before "$DATE_FILTER")
fi

# Resolve config snapshot + machine
CFG_SNAP=""
if [[ -n "$SNAPSHOT_ID" ]]; then
    [[ -n "$DATE_FILTER" ]] && warn "--date is ignored, --snap already selects the backup run unambiguously"
    FIND_ARGS=()
    _snap_json=$(restic snapshots --json "$SNAPSHOT_ID" 2>/dev/null) || _snap_json=""
    if [[ -z "$_snap_json" || "$_snap_json" == "[]" || "$_snap_json" == "null" ]]; then
        error "Snapshot ${SNAPSHOT_ID} not found in repository"
        exit 1
    fi
    _snap_tags=$(echo "$_snap_json" | jq -r '.[0].tags[]?' 2>/dev/null) || _snap_tags=""
    if ! grep -qx "config" <<< "$_snap_tags"; then
        error "Snapshot ${SNAPSHOT_ID} is not a config snapshot (TYPE=CONFIG in --list)."
        error "Pass the SNAP ID of a CONFIG row to --snap - the matching"
        error "disks are found automatically via their config-id tag."
        exit 1
    fi
    _machine_tag=$(grep -E '^(vm|ct)-[0-9]+$' <<< "$_snap_tags" | head -1) || _machine_tag=""
    if [[ -z "$_machine_tag" ]]; then
        error "Snapshot ${SNAPSHOT_ID} carries no vm-<id>/ct-<id> tag"
        exit 1
    fi
    _snap_src="${_machine_tag#*-}"
    SRC_TYPE="${_machine_tag%%-*}"
    if [[ -n "$SRC_ID" && "$SRC_ID" != "$_snap_src" ]]; then
        error "--machine ${SRC_ID} contradicts --snap ${SNAPSHOT_ID} (belongs to ${_machine_tag})"
        exit 1
    fi
    SRC_ID="$_snap_src"
    # Normalize to the short_id - the config-id tag of the disks contains the
    # short_id, not the long ID
    CFG_SNAP=$(echo "$_snap_json" | jq -r '.[0].short_id // empty' 2>/dev/null) || CFG_SNAP=""
    [[ -z "$CFG_SNAP" ]] && CFG_SNAP="$SNAPSHOT_ID"
else
    # Only --machine: determine the type automatically (vm or ct)
    if find_snap_by_tag "vm-${SRC_ID}" "config" "${FIND_ARGS[@]}"; then
        SRC_TYPE="vm"
        CFG_SNAP="$FOUND_SNAP_ID"
    elif find_snap_by_tag "ct-${SRC_ID}" "config" "${FIND_ARGS[@]}"; then
        SRC_TYPE="ct"
        CFG_SNAP="$FOUND_SNAP_ID"
    else
        if [[ -n "$DATE_FILTER" ]]; then
            error "No config snapshot found for machine ${SRC_ID} on or before ${DATE_FILTER}"
        else
            error "No config snapshot found for machine ${SRC_ID}"
        fi
        exit 1
    fi
fi

info "Machine: ${SRC_TYPE}-${SRC_ID} -> Target: ${DST_ID}"
info "Config snapshot: ${CFG_SNAP}"

# Map disks/filesystems strictly to the chosen backup run
CONFIG_ID_FILTER="$CFG_SNAP"
info "Restoring backup run config-id=${CONFIG_ID_FILTER}"

# =================== VM Restore ===================
if [[ "$SRC_TYPE" == "vm" ]]; then
    info "=== Restoring VM ${SRC_ID} -> ${DST_ID} ==="
    restore_config "$SRC_ID" "$DST_ID" "vm" "$CFG_SNAP" || exit 1

    DISK_REGEX='^(ide[0-3]|sata[0-5]|scsi[0-9]+|virtio[0-9]+|efidisk[0-9]+|tpmstate[0-9]+):'
    CONFIG_FILE="/etc/pve/qemu-server/${DST_ID}.conf"
    info "Restoring disks ..."
    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
        dst_volume=$(echo "$volume" | sed "s/vm-${SRC_ID}-/vm-${DST_ID}-/g")
        restore_vm_disk "$SRC_ID" "$key" "$dst_volume" "${FIND_ARGS[@]}" || true
    done < <(awk -v re="$DISK_REGEX" '$0 ~ re {print}' "$CONFIG_FILE" 2>/dev/null)

    info "=== VM ${DST_ID} restore complete ==="
    if ! $DRY_RUN; then
        info "Start with: qm start ${DST_ID}"
    fi
fi

# =================== LXC Restore ===================
if [[ "$SRC_TYPE" == "ct" ]]; then
    info "=== Restoring LXC ${SRC_ID} -> ${DST_ID} ==="
    restore_config "$SRC_ID" "$DST_ID" "ct" "$CFG_SNAP" || exit 1

    CONFIG_FILE="/etc/pve/lxc/${DST_ID}.conf"
    info "Restoring filesystems ..."
    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
        dst_volume=$(echo "$volume" \
            | sed -e "s/subvol-${SRC_ID}-/subvol-${DST_ID}-/g" \
                  -e "s/vm-${SRC_ID}-/vm-${DST_ID}-/g")
        restore_lxc_fs "$SRC_ID" "$key" "$dst_volume" "${FIND_ARGS[@]}" || true
    done < <(awk '/^rootfs:/{print}' "$CONFIG_FILE" 2>/dev/null)

    info "=== LXC ${DST_ID} restore complete ==="
    if ! $DRY_RUN; then
        info "Start with: pct start ${DST_ID}"
    fi
fi

