#!/usr/bin/env bash
# ===========================================================================
#  Ultimate BackUP  --  disk/partition backup, restore and mount
#
#  Combines cz-backup.sh, cz-restore.sh and cz-mount.sh into one tool.
#  Each of the three keeps its own private environment (see wrappers below),
#  so behaviour is identical to running them standalone.
#
#  Usage:  ./ultimate_backup.sh [directory]
#            directory = where images are written (backup) or read from
#                        (restore/mount). Default: current directory.
# ===========================================================================
set -uo pipefail

# Cross-distro tool discovery: Debian-family user PATH lacks /usr/sbin, /sbin.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

UB_VERSION="1"
WORK_DIR="${1:-.}"


# ===========================================================================
# Preflight dependency check
#
# Runs before the first prompt. A missing tool discovered halfway through a
# backup used to abort the run with a partition still shrunk; catching it
# here means nothing has been touched yet.
# ===========================================================================

ub_have() { command -v "$1" >/dev/null 2>&1; }

preflight_check() {
  local missing_core=() missing_opt=() c
  local -a core=(lsblk blkid findmnt losetup mount umount blockdev partprobe
                 awk sed grep find sort stat dd sudo sgdisk parted)

  for c in "${core[@]}"; do
    ub_have "$c" || missing_core+=("$c")
  done

  # sfdisk is what lets us resize a partition entry without deleting it.
  ub_have sfdisk || missing_opt+=("sfdisk (util-linux) - safest in-place partition resize; parted fallback will be used")

  # imaging engines
  ub_have partclone.dd      || missing_opt+=("partclone - required for partclone images and full-disk partclone backups")
  ub_have partclone.restore || missing_opt+=("partclone.restore - required to mount partclone images")
  ub_have partclone.ext4    || missing_opt+=("partclone.ext4 - required to image ext2/3/4 partitions")

  # filesystem tooling
  ub_have e2fsck    || missing_opt+=("e2fsprogs (e2fsck) - ext filesystem check")
  ub_have resize2fs || missing_opt+=("e2fsprogs (resize2fs) - REQUIRED for the ext4 shrink option")
  ub_have tune2fs   || missing_opt+=("e2fsprogs (tune2fs) - filesystem size reporting in List")
  ub_have fsck.fat  || missing_opt+=("dosfstools (fsck.fat) - FAT/EFI checking")
  ub_have ntfsfix   || missing_opt+=("ntfs-3g - NTFS handling")

  # compressors
  local anycomp=0
  for c in zstd gzip xz bzip2; do ub_have "$c" && anycomp=1; done
  ub_have zstd  || missing_opt+=("zstd - zst compression")
  ub_have gzip  || missing_opt+=("gzip - gz compression")
  ub_have xz    || missing_opt+=("xz - xz compression")
  ub_have bzip2 || missing_opt+=("bzip2 - bz2 compression")

  ub_have pv || missing_opt+=("pv - progress bars (a spinner is used without it)")

  if (( ${#missing_core[@]} > 0 )); then
    echo
    echo "ERROR: these essential commands are missing:"
    for c in "${missing_core[@]}"; do echo "  - $c"; done
    echo
    echo "Install them and run this script again. Nothing has been changed."
    echo "  Debian/Ubuntu: apt install util-linux gdisk parted coreutils gawk sed grep findutils sudo"
    echo "  Arch:          pacman -S util-linux gptfdisk parted coreutils gawk sed grep findutils sudo"
    echo "  Slackware:     these are all part of a full install (util-linux, gptfdisk, parted)"
    exit 1
  fi

  if (( anycomp == 0 )); then
    echo
    echo "ERROR: no compression tool found (zstd, gzip, xz or bzip2)."
    echo "Install at least one, or use an uncompressed backup mode."
    exit 1
  fi

  if (( ${#missing_opt[@]} > 0 )); then
    echo
    echo "Note: some optional components are not installed. Affected features:"
    for c in "${missing_opt[@]}"; do echo "  - $c"; done
    echo
    echo "Everything else still works. Press Enter to continue, or Ctrl-C to quit."
    read -r _ || true
  fi
}

banner() {
  cat <<'EOF'

###############################
##### ULtimate BackUP v1 #####
##############################

EOF
}


# ===========================================================================
# Shared input helpers
# Every prompt re-asks on bad input instead of aborting, and says what is
# actually accepted. Arithmetic is never performed on unvalidated text
# (that is what produced "sda6: unbound variable" crashes).
# ===========================================================================

ub_invalid() { echo "Invalid entry. $*"; }

# ask_num VAR "prompt" MIN MAX   -> integer within range
ask_num() {
  local __v="$1" __p="$2" __min="$3" __max="$4" __a
  while true; do
    read -r -p "$__p" __a || { echo; exit 1; }
    __a="${__a// /}"
    if [[ "$__a" =~ ^[0-9]+$ ]] && (( __a >= __min && __a <= __max )); then
      printf -v "$__v" '%s' "$__a"; return 0
    fi
    ub_invalid "Please enter a number between $__min and $__max."
  done
}

# ask_yesno VAR "prompt"   -> y or n  (blank counts as no)
ask_yesno() {
  local __v="$1" __p="$2" __a
  while true; do
    read -r -p "$__p" __a || { echo; exit 1; }
    case "${__a,,}" in
      y|yes) printf -v "$__v" '%s' y; return 0 ;;
      n|no|"") printf -v "$__v" '%s' n; return 0 ;;
      *) ub_invalid "Please answer y (yes) or n (no)." ;;
    esac
  done
}

# ask_opt VAR "prompt" "opt1" "opt2" ...  -> one of the listed tokens
# Accepts the option text case-insensitively.
ask_opt() {
  local __v="$1" __p="$2"; shift 2
  local __opts=("$@") __a __o
  while true; do
    read -r -p "$__p" __a || { echo; exit 1; }
    __a="${__a,,}"; __a="${__a// /}"
    for __o in "${__opts[@]}"; do
      if [[ "$__a" == "${__o,,}" ]]; then printf -v "$__v" '%s' "$__a"; return 0; fi
    done
    ub_invalid "Please enter one of: ${__opts[*]}"
  done
}

# ask_indices VAR "prompt" MAX [allow_all]
# Returns a space separated list of validated 1..MAX indices.
# With allow_all=1, "a"/"all" returns every index.
ask_indices() {
  local __v="$1" __p="$2" __max="$3" __all="${4:-0}" __a __t __bad __out
  while true; do
    read -r -p "$__p" __a || { echo; exit 1; }
    if (( __all == 1 )) && [[ "${__a,,}" == "a" || "${__a,,}" == "all" ]]; then
      __out=""; for ((__i=1; __i<=__max; __i++)); do __out+="$__i "; done
      printf -v "$__v" '%s' "${__out% }"; return 0
    fi
    __bad=""; __out=""
    for __t in $__a; do
      if [[ "$__t" =~ ^[0-9]+$ ]] && (( __t >= 1 && __t <= __max )); then
        __out+="$__t "
      else
        __bad+="$__t "
      fi
    done
    if [[ -n "$__bad" ]]; then
      ub_invalid "Not valid: ${__bad% }. Enter list numbers between 1 and $__max, separated by spaces$( (( __all == 1 )) && printf ', or a for all')."
      continue
    fi
    if [[ -z "$__out" ]]; then
      ub_invalid "Nothing entered. Enter list numbers between 1 and $__max$( (( __all == 1 )) && printf ', or a for all')."
      continue
    fi
    printf -v "$__v" '%s' "${__out% }"; return 0
  done
}

# ask_word VAR "prompt" WORD  -> must type WORD exactly (confirmation)
ask_word() {
  local __v="$1" __p="$2" __w="$3" __a
  read -r -p "$__p" __a || { echo; exit 1; }
  printf -v "$__v" '%s' "$__a"
  [[ "$__a" == "$__w" ]]
}

# ===========================================================================
# MODULE: BACKUP   (cz-backup.sh)
# Runs in a SUBSHELL: its helper functions, globals and EXIT trap are private
# to this module and cannot collide with the other two.
# ===========================================================================
run_backup() (
set -euo pipefail

# cz-backup.sh — merged version
# UI, ext4 shrink/grow, pre-shrink metadata: from the new script
# Full Clonezilla-like metadata suite (restore-compatible): from the old working script
#
# METADATA STRATEGY (two sets):
#   1. ORIGINAL layout — canonical filenames (pt.sf, pt.parted, pt.parted.compact,
#      GPT dumps). Written BEFORE any shrink. The current cz-restore.sh reads these
#      and therefore always recreates original geometry. A shrunk image restores
#      fine into a full-size partition (partclone needs target >= image); restore
#      then grows the fs with resize2fs for "original state".
#   2. SHRUNKEN layout — same files with a .shrunken suffix, plus shrunk-parts.
#      Written AFTER all shrinks, BEFORE imaging. Only exists when shrink was used.
#      The future restore script reads these when the user chooses to restore
#      partitions in their shrunken (no blank space) state.
#   *.pre-shrink.meta — per-partition originals; used to restore the live disk's
#      table after backup, and tells restore which partitions need resize2fs grow.

OUT_ROOT="${1:-.}"
WORKDIR="$(mktemp -d -t cz-backup.XXXXXX)"
OUT_DIR=""

# Cross-distro tool discovery: on Debian/Ubuntu-family systems /usr/sbin and
# /sbin are not in a regular user's PATH, so `command -v sgdisk`, partclone.*,
# partprobe etc. would fail even when installed. Prepend the standard system
# dirs so pick_cmd/command -v find tools regardless of distro PATH policy.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

LSBLK=""
SGDISK=""
PARTED=""
BLOCKDEV=""
AWK=""
SED=""
GREP=""
SUDO=""
PV=""
ZSTD=""
GZIP=""
XZ=""
BZIP2=""
SHA512SUM=""
PARTCLONE_INFO=""
SFDISK=""
COMP_METHOD=""
COMP_LEVEL=""
ZSTD_ULTRA=""
BACKUP_ENGINE="partclone"

cleanup() {
  rm -rf "$WORKDIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT

pick_cmd() {
  local varname="$1"; shift
  local p found=""
  for p in "$@"; do
    found="$(command -v "$p" 2>/dev/null || true)"
    if [[ -n "$found" ]]; then
      printf -v "$varname" '%s' "$found"
      return 0
    fi
  done
  return 1
}

need_cmd() {
  local varname="$1"; shift
  pick_cmd "$varname" "$@" || { echo "Missing required command: $*" >&2; exit 1; }
}

need_cmd LSBLK lsblk
need_cmd SGDISK sgdisk
need_cmd PARTED parted
need_cmd BLOCKDEV blockdev
need_cmd AWK awk
need_cmd SED sed
need_cmd GREP grep
need_cmd SUDO sudo
need_cmd FINDMNT findmnt
need_cmd PARTPROBE partprobe
need_cmd DD dd
# sfdisk resizes a partition entry in place (never deletes). Strongly
# preferred; parted resizepart is the fallback.
pick_cmd SFDISK sfdisk || true

command -v pv >/dev/null 2>&1 && PV="$(command -v pv)" || true
command -v zstd >/dev/null 2>&1 && ZSTD="$(command -v zstd)" || true
command -v gzip >/dev/null 2>&1 && GZIP="$(command -v gzip)" || true
command -v xz >/dev/null 2>&1 && XZ="$(command -v xz)" || true
command -v bzip2 >/dev/null 2>&1 && BZIP2="$(command -v bzip2)" || true
command -v sha512sum >/dev/null 2>&1 && SHA512SUM="$(command -v sha512sum)" || true

# partclone.info is not used by the backup itself (the restore script uses
# it); keep it optional so dd-only systems can still run backups.
pick_cmd PARTCLONE_INFO partclone.info /usr/sbin/partclone.info /usr/bin/partclone.info /sbin/partclone.info /bin/partclone.info || true

sudo -v
( while true; do sudo -n true; sleep 60; done ) >/dev/null 2>&1 &
SUDO_KEEPALIVE_PID=$!
ub_exit_trap() {
  local rc=$?
  # If we died with partitions still shrunk, put them back before leaving.
  rollback_shrinks || true
  kill "${SUDO_KEEPALIVE_PID:-0}" >/dev/null 2>&1 || true
  cleanup
  exit "$rc"
}
trap ub_exit_trap EXIT
trap 'echo; echo "Interrupted."; exit 130' INT TERM

# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------

get_num() {
  local v
  v="$("$1" "${@:2}" 2>/dev/null | head -n1 | tr -cd '0-9')"
  [[ "$v" =~ ^[0-9]+$ ]] && printf '%s' "$v" || echo 0
}

get_text() {
  "$1" "${@:2}" 2>/dev/null | head -n1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}

sanitize_token() {
  printf '%s' "${1:-}" | tr -d ';' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}

clean_model() {
  printf '%s' "${1:-}" | sed 's/\\x[0-9a-fA-F]\{2\}/ /g' | tr -cd '[:print:]' | sed 's/[[:space:]]\+/ /g;s/^ *//;s/ *$//'
}

disk_pttype() {
  local d="$1" v
  v="$(get_text "$LSBLK" -no PTTYPE "$d")"
  if [[ -z "$v" ]] && command -v blkid >/dev/null 2>&1; then
    v="$(sudo blkid -o value -s PTTYPE "$d" 2>/dev/null | head -n1 || true)"
  fi
  v="${v,,}"
  case "$v" in
    gpt|dos|mbr) echo "$v" ;;
    msdos) echo mbr ;;
    *) echo "$v" ;;
  esac
}

# lsblk reads FSTYPE from the udev database. On live/rescue systems without
# udev -- and briefly after a partition table change -- it returns nothing.
# That silently mis-detected filesystems: growth was skipped and, worse,
# an unknown type fell back to ext4 and would image NTFS with partclone.ext4.
# blkid reads the device itself, so it is used whenever lsblk comes up empty.
fs_from_lsblk() {
  local fs
  fs="$("$LSBLK" -dnro FSTYPE "$1" 2>/dev/null | head -n1 | tr -d ' ' || true)"
  if [[ -z "$fs" ]] && command -v blkid >/dev/null 2>&1; then
    fs="$(sudo blkid -o value -s TYPE "$1" 2>/dev/null | head -n1 || true)"
  fi
  fs="${fs,,}"
  case "$fs" in
    ext2|ext3|ext4|ntfs|vfat|xfs|btrfs|exfat|jfs|reiserfs|hfsplus|f2fs|nilfs2|ubifs|swap) echo "$fs" ;;
    fat|fat32|fat16|fat12) echo vfat ;;
    *) echo "$fs" ;;
  esac
}

label_from_lsblk() {
  get_text "$LSBLK" -dnro LABEL "$1"
}

get_mountpoint_ub() {
  "$LSBLK" -dnro MOUNTPOINT "$1" 2>/dev/null | sed 's/[[:space:]]*$//' || true
}

mountpoint_of() {
  "$LSBLK" -dnro MOUNTPOINT "$1" 2>/dev/null | tr -d ' ' || true
}

unmount_parts() {
  local -n arr="$1"
  local p mp
  for p in "${arr[@]}"; do
    mp="$(mountpoint_of "$p")"
    if [[ -n "$mp" ]]; then
      # Last line of defense: NEVER touch live system mounts. A lazy
      # unmount of / detaches the running root and wrecks the session.
      case "$mp" in
        /|/boot|/boot/efi)
          echo
          echo "REFUSING to unmount $p (mounted at $mp) — this is a live system mount."
          echo "Aborting: back up this disk from a live USB or external environment."
          exit 1
          ;;
      esac
      echo "Unmounting $p (mounted at $mp)..."
      sudo umount "$p" || sudo umount -l "$p" || true
    fi
  done
}

check_backup_folder_exists() {
  local base="$1" root="$2"
  [[ -d "$root/$base" || -d "$root/${base// /_}" || -d "$root/${base//_/ }" ]]
}

disk_sector_count() {
  local disk="$1" s
  s="$(get_num sudo "$BLOCKDEV" --getsz "$disk")"
  [[ "$s" -gt 0 ]] || s="$(( $(get_num "$LSBLK" -bno SIZE "$disk") / 512 ))"
  [[ "$s" -gt 0 ]] || return 1
  printf '%s' "$s"
}

partition_sector_count() {
  local part="$1" s
  s="$(get_num sudo "$BLOCKDEV" --getsz "$part")"
  [[ "$s" -gt 0 ]] || s="$(( $(get_num "$LSBLK" -bno SIZE "$part") / 512 ))"
  [[ "$s" -gt 0 ]] || return 1
  printf '%s' "$s"
}


# GPT type GUID with sane fallbacks (from the old working script).
# This is what makes pt.sf carry a valid type= even when lsblk PARTTYPE is empty.
gpt_type_for_part() {
  local fs pt
  fs="$("$LSBLK" -dnro FSTYPE "$1" 2>/dev/null | tr -d ' ')"
  pt="$("$LSBLK" -dnro PARTTYPE "$1" 2>/dev/null | tr -d ' ')"
  fs="${fs,,}"
  pt="${pt^^}"
  case "$pt" in
    C12A7328-F81F-11D2-BA4B-00A0C93EC93B) echo C12A7328-F81F-11D2-BA4B-00A0C93EC93B ;;
    0657FD6D-A4AB-43C4-84E5-0933C84B4F4F) echo 0657FD6D-A4AB-43C4-84E5-0933C84B4F4F ;;
    0FC63DAF-8483-4772-8E79-3D69D8477DE4) echo 0FC63DAF-8483-4772-8E79-3D69D8477DE4 ;;
    *) case "$fs" in
         vfat|fat*) echo C12A7328-F81F-11D2-BA4B-00A0C93EC93B ;;
         swap) echo 0657FD6D-A4AB-43C4-84E5-0933C84B4F4F ;;
         *) echo 0FC63DAF-8483-4772-8E79-3D69D8477DE4 ;;
       esac ;;
  esac
}

# MBR (msdos) partition type hex with sane fallbacks.
mbr_type_for_part() {
  local pt fs
  pt="$(get_text "$LSBLK" -no PARTTYPE "$1")"
  pt="${pt,,}"
  pt="${pt#0x}"
  if [[ "$pt" =~ ^[0-9a-f]{1,2}$ ]]; then
    printf '%s' "$pt"
    return
  fi
  fs="$(fs_from_lsblk "$1")"
  case "$fs" in
    vfat) echo c ;;
    swap) echo 82 ;;
    ntfs|exfat) echo 7 ;;
    *) echo 83 ;;
  esac
}

# ---------------------------------------------------------------------------
# Live-system safety checks
# The running root, /boot and EFI system partition must never be imaged from
# inside the running OS. Other partitions on the same disk are fine.
# ---------------------------------------------------------------------------

mount_source() {
  local src
  src="$("$FINDMNT" -no SOURCE "$1" 2>/dev/null || true)"
  # btrfs subvolumes report as /dev/xxx[/subvol] — strip the bracket suffix
  printf '%s' "${src%%\[*}"
}

# True if device $2 appears anywhere in the ancestor chain of block device $1
# (handles root on LUKS/LVM sitting on top of a partition).
# NOTE: -r (raw) is required — without it lsblk draws tree glyphs (└─) that
# break the name match, which silently disabled this check.
dev_in_chain_of() {
  local src="$1" dev="$2"
  [[ -n "$src" && -b "$src" ]] || return 1
  "$LSBLK" -srno NAME "$src" 2>/dev/null | grep -qx "$(basename "$dev")"
}

check_parts_not_live() {
  local -n parts_ref="$1"
  local root efi boot p m label
  root="$(mount_source /)"
  efi="$(mount_source /boot/efi)"
  boot="$(mount_source /boot)"
  for p in "${parts_ref[@]}"; do
    for m in "$root" "$efi" "$boot"; do
      [[ -n "$m" ]] || continue
      if [[ "$p" == "$m" ]] || dev_in_chain_of "$m" "$p"; then
        case "$m" in
          "$root") label="the running root filesystem (/)" ;;
          "$efi")  label="the EFI system partition in use (/boot/efi)" ;;
          *)       label="the boot partition in use (/boot)" ;;
        esac
        echo
        echo "ERROR: $p backs $label."
        echo "Imaging the live system partition from inside the running OS is not safe"
        echo "and would produce a corrupt/inconsistent image."
        echo "Boot a live USB or another external environment to back up this partition."
        exit 1
      fi
    done
  done
}

check_disk_not_live() {
  local disk="$1" diskbase root efi boot m label
  diskbase="$(basename "$disk")"
  root="$(mount_source /)"
  efi="$(mount_source /boot/efi)"
  boot="$(mount_source /boot)"
  for m in "$root" "$efi" "$boot"; do
    [[ -n "$m" && -b "$m" ]] || continue
    if "$LSBLK" -srno NAME "$m" 2>/dev/null | grep -qx "$diskbase"; then
      case "$m" in
        "$root") label="the running root filesystem" ;;
        "$efi")  label="the EFI system partition in use" ;;
        *)       label="the boot partition in use" ;;
      esac
      echo
      echo "ERROR: $disk contains $label ($m)."
      echo "A full-disk image of the live system disk is not safe and would be inconsistent."
      echo "Boot a live USB or another external environment to image this disk."
      exit 1
    fi
  done
}

# ---------------------------------------------------------------------------
# Filesystem check & repair before backup (like Clonezilla's fsck option)
# Partitions must be unmounted before calling.
# ---------------------------------------------------------------------------

fsck_partition() {
  local part="$1" fs rc
  fs="$(fs_from_lsblk "$part")"
  rc=0
  case "$fs" in
    ext2|ext3|ext4)
      echo "Checking $part (e2fsck -f -y)..."
      sudo e2fsck -f -y "$part" || rc=$?
      # e2fsck: 0 = clean, 1 = errors corrected, 2 = corrected + reboot advised
      if (( rc >= 4 )); then
        echo "ERROR: e2fsck could not repair $part (exit $rc)."
        return 1
      fi
      (( rc > 0 )) && echo "Note: e2fsck corrected errors on $part."
      ;;
    vfat)
      if command -v fsck.fat >/dev/null 2>&1; then
        echo "Checking $part (fsck.fat -a)..."
        sudo fsck.fat -a "$part" || rc=$?
        # fsck.fat: 0 = clean, 1 = errors corrected
        if (( rc >= 2 )); then
          echo "ERROR: fsck.fat could not repair $part (exit $rc)."
          return 1
        fi
        (( rc > 0 )) && echo "Note: fsck.fat corrected errors on $part."
      else
        echo "fsck.fat not installed; skipping check on $part."
      fi
      ;;
    *)
      echo "No check handler for '$fs' on $part; skipping."
      ;;
  esac
  return 0
}

# Ask whether to fsck, then run it over the array of partitions.
# Only asks if at least one partition has a checkable filesystem.
offer_fsck() {
  local -n fsck_parts_ref="$1"
  local p fs has_checkable=0 fsck_choice
  for p in "${fsck_parts_ref[@]}"; do
    fs="$(fs_from_lsblk "$p")"
    case "$fs" in ext2|ext3|ext4|vfat) has_checkable=1 ;; esac
  done
  (( has_checkable == 1 )) || return 0

  if [[ -n "${QUICK_FSCK:-}" ]]; then
    echo
    echo "Checking and repairing source filesystem(s) (quick preset)..."
    case "$QUICK_FSCK" in
      1) return 0 ;;
    esac
  else
    echo
    echo "Check and repair the source filesystem(s) before backup (fsck)?"
    echo "1) Skip checking"
    echo "2) Check and repair"
    while true; do
      read -r -p "Choice: " fsck_choice
      case "${fsck_choice,,}" in
        1|skip|n|no) return 0 ;;
        2|y|yes) break ;;
        *) echo "Please answer 1 or 2." ;;
      esac
    done
  fi

  for p in "${fsck_parts_ref[@]}"; do
    fsck_partition "$p" || { echo "Aborting backup: filesystem on $p is damaged."; exit 1; }
  done
}

restore_tool_for_fs() {
  case "$1" in
    ext4) echo partclone.ext4 ;;
    ext3) echo partclone.ext3 ;;
    ext2) echo partclone.ext2 ;;
    ntfs) echo partclone.ntfs ;;
    xfs) echo partclone.xfs ;;
    btrfs) echo partclone.btrfs ;;
    vfat) echo partclone.vfat ;;
    exfat) echo partclone.exfat ;;
    jfs) echo partclone.jfs ;;
    reiserfs) echo partclone.reiserfs ;;
    hfsplus) echo partclone.hfsp ;;
    f2fs) echo partclone.f2fs ;;
    nilfs2) echo partclone.nilfs2 ;;
    ubifs) echo partclone.ubifs ;;
    swap) echo partclone.dd ;;
    *) echo "" ;;
  esac
}

# ---------------------------------------------------------------------------
# Compression menus (new UI)
# ---------------------------------------------------------------------------

fmt_row() { printf "%-4s %-14s %-5s %-11s %-8s %s\n" "$1" "$2" "$3" "$4" "$5" "$6"; }
print_menu_header() { printf "%-4s %-14s %-5s %-11s %-8s %s\n" "No." "Command" "Lvl" "Speed" "Size" "Typical use"; }

choose_method() {
  echo
  echo "Choose compression method:"
  echo "1) zst"
  echo "2) gz"
  echo "3) xz"
  echo "4) bz2"
  echo "5) none"
  ask_num c "Choice: " 1 5
  case "$c" in
    1) COMP_METHOD=zst ;;
    2) COMP_METHOD=gz ;;
    3) COMP_METHOD=xz ;;
    4) COMP_METHOD=bz2 ;;
    5) COMP_METHOD="" ;;
  esac
  case "$COMP_METHOD" in
    zst) [[ -n "$ZSTD" ]] || { echo "zstd not installed"; exit 1; } ;;
    gz)  [[ -n "$GZIP" ]] || { echo "gzip not installed"; exit 1; } ;;
    xz)  [[ -n "$XZ" ]] || { echo "xz not installed"; exit 1; } ;;
    bz2) [[ -n "$BZIP2" ]] || { echo "bzip2 not installed"; exit 1; } ;;
  esac
}

choose_zst_level() {
  echo "Choose a zstd compression level (1-22). The number you enter IS the level:"
  print_menu_header
  fmt_row 1  "zstd -1 -T0"  1  "Very fast" "Large"    "Max speed, minimal compression"
  fmt_row 2  "zstd -2 -T0"  2  "Very fast" "Large"    "Very fast"
  fmt_row 3  "zstd -3 -T0"  3  "Fast"      "Medium"   "zstd default"
  fmt_row 5  "zstd -5 -T0"  5  "Fast"      "Medium"   "Clonezilla-like speed"
  fmt_row 6  "zstd -6 -T0"  6  "Fast"      "Medium"   "Good trade-off"
  fmt_row 9  "zstd -9 -T0"  9  "Fast"      "Smaller"  "Clonezilla z9p style"
  fmt_row 12 "zstd -12 -T0" 12 "Moderate"  "Small"    "More compression"
  fmt_row 15 "zstd -15 -T0" 15 "Slower"    "Small"    "Strong compression"
  fmt_row 18 "zstd -18 -T0" 18 "Slower"    "Small"    "Strong compression"
  fmt_row 19 "zstd -19 -T0" 19 "Slow"      "X small"  "Very strong compression"
  fmt_row 20 "zstd -20 -T0" 20 "Very slow" "Smallest" "Very strong (--ultra)"
  fmt_row 21 "zstd -21 -T0" 21 "Very slow" "Smallest" "Very strong (--ultra)"
  fmt_row 22 "zstd -22 -T0" 22 "Very slow" "Smallest" "Maximum (--ultra)"
  echo "(Any level 1-22 is accepted, not only the rows shown.)"
  ask_num lvl "Choice (recommended 9): " 1 22
  COMP_LEVEL="$lvl"
  ZSTD_ULTRA=""
  if (( lvl >= 20 )); then
    ZSTD_ULTRA="--ultra"
  fi
  return 0
}

choose_gz_level() {
  print_menu_header
  fmt_row 1 "gzip -1" 1 "Very fast" "Large" "Max speed, minimal compression"
  fmt_row 2 "gzip -2" 2 "Very fast" "Large" "Fast"
  fmt_row 3 "gzip -3" 3 "Fast" "Medium" "Good balance"
  fmt_row 4 "gzip -4" 4 "Fast" "Medium" "Good balance"
  fmt_row 5 "gzip -5" 5 "Fast" "Medium" "Good balance"
  fmt_row 6 "gzip -6" 6 "Medium" "Smaller" "Default balance"
  fmt_row 7 "gzip -7" 7 "Slower" "Smaller" "More compression"
  fmt_row 8 "gzip -8" 8 "Slower" "Smaller" "More compression"
  fmt_row 9 "gzip -9" 9 "Slow" "Smallest" "Highest compression"
  ask_num i "Choice (recommended 6): " 1 9
  COMP_LEVEL="$i"
}

choose_xz_level() {
  print_menu_header
  fmt_row 1 "xz -1" 1 "Fast" "Large" "Fastest"
  fmt_row 2 "xz -2" 2 "Fast" "Large" "Fast"
  fmt_row 3 "xz -3" 3 "Medium" "Medium" "Balanced"
  fmt_row 4 "xz -4" 4 "Medium" "Medium" "Balanced"
  fmt_row 5 "xz -5" 5 "Medium" "Medium" "Default-ish"
  fmt_row 6 "xz -6" 6 "Slower" "Smaller" "Good compression"
  fmt_row 7 "xz -7" 7 "Slower" "Smaller" "Good compression"
  fmt_row 8 "xz -8" 8 "Slow" "Small" "Strong compression"
  fmt_row 9 "xz -9" 9 "Slow" "Smallest" "Maximum"
  ask_num i "Choice (recommended 6): " 1 9
  COMP_LEVEL="$i"
}

choose_bz2_level() {
  print_menu_header
  fmt_row 1 "bzip2 -1" 1 "Fast" "Large" "Fastest"
  fmt_row 2 "bzip2 -2" 2 "Fast" "Large" "Fast"
  fmt_row 3 "bzip2 -3" 3 "Medium" "Medium" "Balanced"
  fmt_row 4 "bzip2 -4" 4 "Medium" "Medium" "Balanced"
  fmt_row 5 "bzip2 -5" 5 "Medium" "Medium" "Balanced"
  fmt_row 6 "bzip2 -6" 6 "Slower" "Smaller" "Good compression"
  fmt_row 7 "bzip2 -7" 7 "Slower" "Smaller" "Good compression"
  fmt_row 8 "bzip2 -8" 8 "Slow" "Small" "Strong compression"
  fmt_row 9 "bzip2 -9" 9 "Slow" "Smallest" "Highest compression"
  ask_num i "Choice (recommended 9): " 1 9
  COMP_LEVEL="$i"
}

# ---------------------------------------------------------------------------
# Full Clonezilla-like metadata suite (from the OLD working script)
# These are the files cz-restore.sh — and real Clonezilla habits — expect.
# ---------------------------------------------------------------------------

write_blk_lists() {
  local disk="$1" outdir="$2"
  "$LSBLK" -o NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINTS "$disk" > "$outdir/blkdev.list"
  "$LSBLK" -f "$disk" > "$outdir/blkid.list"
  "$LSBLK" -o NAME,FSTYPE,FSVER,LABEL,UUID,PARTUUID,PARTLABEL,PARTTYPE,MOUNTPOINT "$disk" > "$outdir/dev-fs.list"
}

write_disk_and_parts() {
  local outdir="$1" disk="$2" parts_line="$3" diskbase="$4"
  echo "$diskbase" > "$outdir/disk"
  echo "$parts_line" > "$outdir/parts"
}

write_geometry_files() {
  local disk="$1" outdir="$2" diskbase="$3"
  local sectors heads spt cyl
  sectors="$(disk_sector_count "$disk")" || return 1
  heads=255
  spt=63
  cyl=$((sectors / (heads * spt)))
  printf 'CHS=%s,%s,%s\n' "$cyl" "$heads" "$spt" > "$outdir/${diskbase}-chs.sf"
  sudo dd if="$disk" of="$outdir/${diskbase}-mbr" bs=512 count=1 status=none
}

# Optional suffix parameter (e.g. ".shrunken") for the second metadata set.
write_gpt_and_mbr() {
  local disk="$1" outdir="$2" diskbase="$3" suffix="${4:-}"
  local sectors
  sectors="$(disk_sector_count "$disk")" || return 1
  sudo dd if="$disk" of="$outdir/${diskbase}-gpt-1st${suffix}" bs=512 count=34 status=none
  sudo dd if="$disk" of="$outdir/${diskbase}-gpt-2nd${suffix}" bs=512 skip=$((sectors - 32)) count=32 status=none
  sudo "$SGDISK" --backup="$outdir/${diskbase}-gpt.gdisk${suffix}" "$disk" >/dev/null 2>&1 || true
  sudo "$SGDISK" --backup="$outdir/${diskbase}-gpt.sgdisk${suffix}" "$disk" >/dev/null 2>&1 || true
}

write_pt_sf() {
  local disk="$1" out="$2" diskbase="$3" parts_line="$4" table="${5:-gpt}"
  local ptuuid last_lba ptdev start sz puuid pname pttype p
  ptuuid="$("$LSBLK" -no PTUUID "$disk" 2>/dev/null | tr -d ' ')"
  last_lba=$(( $(disk_sector_count "$disk") - 1 ))
  if [[ "$table" == "gpt" ]]; then
    {
      printf 'label: gpt\n'
      printf 'label-id: %s\n' "$ptuuid"
      printf 'device: /dev/%s\n' "$diskbase"
      printf 'unit: sectors\n'
      printf 'first-lba: 34\n'
      printf 'last-lba: %s\n' "$last_lba"
      printf 'sector-size: 512\n'
      printf '\n\n'
    } > "$out"
  else
    {
      printf 'label: dos\n'
      printf 'label-id: 0x%s\n' "$ptuuid"
      printf 'device: /dev/%s\n' "$diskbase"
      printf 'unit: sectors\n'
      printf 'sector-size: 512\n'
      printf '\n\n'
    } > "$out"
  fi
  for p in $parts_line; do
    ptdev="/dev/$p"
    start="$(get_num "$LSBLK" -bno START "$ptdev")"
    sz="$(partition_sector_count "$ptdev")"
    if [[ "$table" == "gpt" ]]; then
      puuid="$("$LSBLK" -no PARTUUID "$ptdev" 2>/dev/null | tr -d ' ')"
      pname="$(sanitize_token "$("$LSBLK" -no PARTLABEL "$ptdev" 2>/dev/null || true)")"
      pttype="$(gpt_type_for_part "$ptdev")"
      if [[ -n "$pname" ]]; then
        printf '/dev/%s%s : start= %s, size= %s, type=%s, uuid=%s, name="%s"\n' "$diskbase" "${p#"${diskbase}"}" "$start" "$sz" "$pttype" "$puuid" "$pname" >> "$out"
      else
        printf '/dev/%s%s : start= %s, size= %s, type=%s, uuid=%s\n' "$diskbase" "${p#"${diskbase}"}" "$start" "$sz" "$pttype" "$puuid" >> "$out"
      fi
    else
      pttype="$(mbr_type_for_part "$ptdev")"
      printf '/dev/%s%s : start= %s, size= %s, type=%s\n' "$diskbase" "${p#"${diskbase}"}" "$start" "$sz" "$pttype" >> "$out"
    fi
  done
}

write_parted_human() {
  local disk="$1" out="$2" parts_line="$3" table="${4:-gpt}"
  local model size ptdev num start sz end fs name flags p table_label
  model="$(clean_model "$("$LSBLK" -dnro MODEL "$disk" 2>/dev/null)")"
  # parted prints the disk size in SECTORS here. Writing bytes made a 320GB
  # disk read back as 149TB.
  size="$(disk_sector_count "$disk")"
  table_label="gpt"
  [[ "$table" != "gpt" ]] && table_label="msdos"
  {
    printf 'Model: %s\n' "$model"
    printf 'Disk %s: %ss\n' "$disk" "$size"
    printf 'Sector size (logical/physical): 512B/512B\n'
    printf 'Partition Table: %s\n' "$table_label"
    printf 'Disk Flags:\n\n\n'
    printf 'Number Start End Size File system Name Flags\n'
  } > "$out"
  for p in $parts_line; do
    ptdev="/dev/$p"
    num="${p##*[!0-9]}"
    start="$(get_num "$LSBLK" -bno START "$ptdev")"
    sz="$(partition_sector_count "$ptdev")"
    end=$((start + sz - 1))
    fs="$("$LSBLK" -dnro FSTYPE "$ptdev" 2>/dev/null | tr -d ' ')"
    fs="${fs,,}"
    name="$(sanitize_token "$("$LSBLK" -no PARTLABEL "$ptdev" 2>/dev/null || true)")"
    flags=""
    case "$fs" in
      vfat|fat|fat12|fat16|fat32) fs="fat32"; flags="boot," ;;
      ext2|ext3|ext4) fs="ext4" ;;
      swap) fs="linux-swap(v1)"; flags="swap" ;;
    esac
    if [[ -n "$name" && -n "$flags" ]]; then
      printf '%s %ss %ss %ss %s %s %s\n' "$num" "$start" "$end" "$sz" "$fs" "$name" "$flags" >> "$out"
    elif [[ -n "$name" ]]; then
      printf '%s %ss %ss %ss %s %s\n' "$num" "$start" "$end" "$sz" "$fs" "$name" >> "$out"
    elif [[ -n "$flags" ]]; then
      printf '%s %ss %ss %ss %s %s\n' "$num" "$start" "$end" "$sz" "$fs" "$flags" >> "$out"
    else
      printf '%s %ss %ss %ss %s\n' "$num" "$start" "$end" "$sz" "$fs" >> "$out"
    fi
  done
}

write_clonezilla_img() {
  local outdir="$1" now="$2" parts_line="$3" imgname="$4" diskbase="$5"
  cat > "$outdir/clonezilla-img" <<EOF
This image was saved by Clonezilla at $now.
The log during saving:
----------------------------------------------------------
Starting /usr/sbin/ocs-sr at $now...
*****************************************************.
Clonezilla image dir: /home/partimag
The selected devices: $parts_line
PS. Next time you can run this command directly:
/usr/sbin/ocs-sr -q2 -c -j2 -z9p -i 0 -sfsck -scs -senc -p choose saveparts $imgname $parts_line
*****************************************************.
The selected devices: $parts_line
The following step is to save the hard disk/partition(s) on this machine as an image:
*****************************************************.
Machine: $(hostname 2>/dev/null || echo unknown)
EOF
}

write_info_files() {
  local outdir="$1" disk="$2" imgname="$3" now="$4" pttype="$5"
  printf 'disk=%s\ncreated=%s\nformat=clonezilla-like\nname=%s\nptable_type=%s\n' "$disk" "$now" "$imgname" "$pttype" > "$outdir/${disk##*/}.info"
  printf 'Image saved by: cz-backup.sh\nSource disk: %s\nImage name: %s\nCreated: %s\nPartition table type: %s\n' "$disk" "$imgname" "$now" "$pttype" > "$outdir/Info-saved-by-cmd.txt"
  printf 'Image created time: %s\n' "$(date '+%Y%m%d-%H%M')" > "$outdir/Info-img-size.txt"
  if [[ -n "$SHA512SUM" ]]; then
    printf 'IMG_ID=%s\n' "$("$SHA512SUM" "$outdir/clonezilla-img" | "$AWK" '{print $1}')" > "$outdir/Info-img-id.txt"
  fi
  {
    printf 'saved-by=cz-backup.sh\n'
    printf 'disk=%s\n' "$disk"
    printf 'image=%s\n' "$imgname"
    printf 'created=%s\n' "$now"
  } > "$outdir/Info-OS-prober.txt"
  : > "$outdir/Info-dmi.txt"
  : > "$outdir/Info-smart.txt"
  : > "$outdir/Info-lspci.txt"
  : > "$outdir/Info-lshw.txt"
  : > "$outdir/Info-packages.txt"
}

# METADATA SET 1: ORIGINAL layout. Canonical filenames — this is what the
# current cz-restore.sh parses (pt.parted.compact first, pt.sf fallback), so
# restore-by-default always recreates original geometry.
# Called BEFORE any shrink.
write_all_metadata() {
  local disk="$1" outdir="$2" diskbase="$3" parts_line="$4" imgname="$5" now="$6" pttype="$7"
  write_blk_lists "$disk" "$outdir"
  write_disk_and_parts "$outdir" "$disk" "$parts_line" "$diskbase"
  write_geometry_files "$disk" "$outdir" "$diskbase" || return 1
  # GPT structure dumps only make sense on GPT disks; the MBR sector is
  # already saved by write_geometry_files on both table types.
  if [[ "$pttype" == "gpt" ]]; then
    write_gpt_and_mbr "$disk" "$outdir" "$diskbase" || return 1
  fi
  write_clonezilla_img "$outdir" "$now" "$parts_line" "$imgname" "$diskbase"
  write_info_files "$outdir" "$disk" "$imgname" "$now" "$pttype"
  write_pt_sf "$disk" "$outdir/${diskbase}-pt.sf" "$diskbase" "$parts_line" "$pttype"
  write_parted_human "$disk" "$outdir/${diskbase}-pt.parted" "$parts_line" "$pttype"
  cp "$outdir/${diskbase}-pt.parted" "$outdir/${diskbase}-pt.parted.compact"
}

# METADATA SET 2: SHRUNKEN layout. Same file formats, ".shrunken" suffix.
# Written AFTER all shrinks and BEFORE imaging, so the sector geometry here
# matches the partclone images exactly. The future restore script reads these
# when the user chooses "restore partitions as shrunken (no blank space)".
# Also writes shrunk-parts: one partition basename per line — in "original
# size" restore mode, these are the partitions that need resize2fs grow after
# the image is restored.
write_shrunk_metadata() {
  local disk="$1" outdir="$2" diskbase="$3" parts_line="$4" pttype="$5"
  local -n shrunk_ref="$6"
  write_pt_sf "$disk" "$outdir/${diskbase}-pt.sf.shrunken" "$diskbase" "$parts_line" "$pttype"
  write_parted_human "$disk" "$outdir/${diskbase}-pt.parted.shrunken" "$parts_line" "$pttype"
  cp "$outdir/${diskbase}-pt.parted.shrunken" "$outdir/${diskbase}-pt.parted.compact.shrunken"
  if [[ "$pttype" == "gpt" ]]; then
    write_gpt_and_mbr "$disk" "$outdir" "$diskbase" ".shrunken" || true
  fi
  printf '%s\n' "${shrunk_ref[@]}" | sed 's|.*/||' > "$outdir/shrunk-parts"
}

# ---------------------------------------------------------------------------
# ext4 shrink / grow (new feature) — with GPT identity preservation
# ---------------------------------------------------------------------------

write_pre_shrink_meta() {
  local part="$1" outdir="$2" disk="$3"
  local base num start sectors end size fs label uuid partuuid parttype partlabel pttype
  base="$(basename "$part")"
  num="${base##*[!0-9]}"
  start="$(get_num "$LSBLK" -bno START "$part")"
  sectors="$(partition_sector_count "$part")" || { echo "ERROR: cannot read sector count for $part"; return 1; }
  end=$((start + sectors - 1))
  size="$(get_num "$LSBLK" -bno SIZE "$part")"
  fs="$(fs_from_lsblk "$part")"
  label="$(label_from_lsblk "$part")"
  uuid="$(get_text "$LSBLK" -no UUID "$part")"
  partuuid="$(get_text "$LSBLK" -no PARTUUID "$part")"
  parttype="$(get_text "$LSBLK" -no PARTTYPE "$part")"
  partlabel="$(sanitize_token "$(get_text "$LSBLK" -no PARTLABEL "$part")")"
  pttype="$(disk_pttype "$disk")"
  {
    echo "disk=$disk"
    echo "partition=$base"
    echo "partition_number=$num"
    echo "start_sector=$start"
    echo "end_sector=$end"
    echo "sector_count=$sectors"
    echo "byte_size=$size"
    echo "fstype=${fs:-unknown}"
    echo "label=${label:-}"
    echo "uuid=${uuid:-}"
    echo "partuuid=${partuuid:-}"
    echo "parttype=${parttype:-}"
    echo "partlabel=${partlabel:-}"
    echo "ptable_type=${pttype:-}"
    echo "mountpoint=$(mountpoint_of "$part")"
    echo "timestamp=$(date -u '+%Y-%m-%d %H:%M:%SZ')"
  } > "$outdir/${base}.pre-shrink.meta"
}

grow_ext4_partition() {
  local part="$1"
  [[ "$(fs_from_lsblk "$part")" == ext4 ]] || return 0
  [[ -z "$(mountpoint_of "$part")" ]] || sudo umount "$part" || true
  sudo e2fsck -f -y "$part" >/dev/null 2>&1 || sudo e2fsck -y "$part" >/dev/null 2>&1 || true
  sudo resize2fs "$part" >/dev/null 2>&1 || { echo "ERROR: resize2fs grow failed for $part"; return 1; }
}

# ---------------------------------------------------------------------------
# SAFE partition resize.
#
# The old code did "parted rm N" then "parted mkpart ...". If the recreate
# failed the partition was GONE. It also always passed "primary", because
# lsblk reports no PARTTYPE for MBR logical partitions -- so on any MBR disk
# with logical partitions (numbers 5+) the recreate was guaranteed to fail
# and destroy the entry.
#
# This resizes the existing entry in place instead. Nothing is ever deleted,
# so a failure leaves the partition exactly as it was. sfdisk -N keeps the
# partition type, its primary/logical/extended nature, GPT type GUID, unique
# GUID and name untouched.
# ---------------------------------------------------------------------------
resize_partition_inplace() {
  local disk="$1" partnum="$2" newsectors="$3"

  if [[ -n "$SFDISK" ]]; then
    if printf 'size=%s\n' "$newsectors" | sudo "$SFDISK" -N "$partnum" --force "$disk" >/dev/null 2>&1; then
      sudo "$PARTPROBE" "$disk" >/dev/null 2>&1 || true
      return 0
    fi
  fi

  # Fallback: parted resizepart. -s alone cannot answer the shrink warning,
  # so feed it a pseudo-tty answer. Still non-destructive on failure.
  local start end
  start="$(get_num "$LSBLK" -bno START "$(target_part_dev "$disk" "$partnum")")"
  [[ "$start" =~ ^[0-9]+$ ]] || return 1
  end=$(( start + newsectors - 1 ))
  if printf 'yes\n' | sudo "$PARTED" ---pretend-input-tty "$disk" unit s resizepart "$partnum" "${end}s" >/dev/null 2>&1; then
    sudo "$PARTPROBE" "$disk" >/dev/null 2>&1 || true
    return 0
  fi
  return 1
}

target_part_dev() {
  local disk="$1" num="$2"
  if [[ "$disk" =~ [0-9]$ ]]; then printf '%sp%s' "$disk" "$num"; else printf '%s%s' "$disk" "$num"; fi
}

# ---------------------------------------------------------------------------
# Crash safety for shrink.
#
# SHRINK_JOURNAL records "device|partnum|original_sectors" for every
# partition whose table entry we made smaller. If the script exits for ANY
# reason before the normal restore step runs, the EXIT trap puts the
# original sizes back and grows the filesystems again, so a missing
# dependency or a Ctrl-C can no longer leave a partition shrunk.
# ---------------------------------------------------------------------------
SHRINK_JOURNAL=()
SHRINK_DISK=""

rollback_shrinks() {
  (( ${#SHRINK_JOURNAL[@]} > 0 )) || return 0
  echo
  echo "!! Backup did not complete. Restoring original partition size(s)..."
  local entry dev num orig
  for entry in "${SHRINK_JOURNAL[@]}"; do
    IFS='|' read -r dev num orig <<<"$entry"
    if resize_partition_inplace "$SHRINK_DISK" "$num" "$orig"; then
      echo "   restored $dev to $orig sectors"
    else
      echo "   WARNING: could not restore $dev to $orig sectors."
      echo "            Original size was $orig sectors - fix with a partition editor."
    fi
  done
  sudo "$PARTPROBE" "$SHRINK_DISK" >/dev/null 2>&1 || true
  sleep 1
  for entry in "${SHRINK_JOURNAL[@]}"; do
    IFS='|' read -r dev num orig <<<"$entry"
    grow_ext4_partition "$dev" || true
  done
  SHRINK_JOURNAL=()
  echo "!! Rollback finished."
}

# Shrink one ext4 partition: filesystem first, then the table entry.
# Works for both MBR and GPT because the entry is edited in place.
shrink_ext4_partition() {
  local part="$1" disk="$2"
  local partname partnum start cursect minblocks minbytes newsectors

  partname="$(basename "$part")"
  partnum="${partname##*[!0-9]}"
  [[ "$partnum" =~ ^[0-9]+$ ]] || { echo "ERROR: cannot determine partition number for $part"; return 1; }
  [[ -z "$(mountpoint_of "$part")" ]] || { echo "ERROR: $part still mounted"; return 1; }

  cursect="$(get_num sudo "$BLOCKDEV" --getsz "$part")"
  [[ "$cursect" -gt 0 ]] || { echo "ERROR: cannot read size of $part"; return 1; }

  sudo e2fsck -f -y "$part" >/dev/null 2>&1 || sudo e2fsck -y "$part" >/dev/null 2>&1 || true
  minblocks="$(sudo resize2fs -P "$part" 2>&1 | awk -F': ' '/Estimated minimum size of the filesystem:/ {print $2; exit}' | tr -cd '0-9')"
  [[ "$minblocks" =~ ^[0-9]+$ ]] || { echo "ERROR: cannot determine minimum size for $part"; return 1; }

  # Leave a little headroom so the filesystem is not left 100% full.
  minbytes=$(( minblocks * 4096 ))
  newsectors=$(( (minbytes + 511) / 512 ))
  newsectors=$(( newsectors + 65536 ))          # +32 MiB of slack
  if (( newsectors >= cursect )); then
    echo "   $part is already close to its minimum size; leaving it unchanged."
    return 0
  fi

  sudo resize2fs "$part" "${newsectors}s" >/dev/null 2>&1 \
    || sudo resize2fs "$part" -M >/dev/null 2>&1 \
    || { echo "ERROR: resize2fs failed for $part"; return 1; }

  # Record BEFORE touching the table, so a crash here can still be undone.
  SHRINK_DISK="$disk"
  SHRINK_JOURNAL+=("$part|$partnum|$cursect")

  if ! resize_partition_inplace "$disk" "$partnum" "$newsectors"; then
    echo "ERROR: could not resize the partition entry for $part."
    echo "       The partition was NOT deleted; growing the filesystem back."
    grow_ext4_partition "$part" || true
    # drop the journal entry: the table was never changed
    unset 'SHRINK_JOURNAL[-1]'
    return 1
  fi
  return 0
}

# Put the shrunk partitions back to their original size after imaging.
restore_original_partition_sizes() {
  local disk="$1"
  (( ${#SHRINK_JOURNAL[@]} > 0 )) || return 0
  local entry dev num orig rc=0
  for entry in "${SHRINK_JOURNAL[@]}"; do
    IFS='|' read -r dev num orig <<<"$entry"
    if ! resize_partition_inplace "$disk" "$num" "$orig"; then
      echo "ERROR: failed to restore original size of $dev"
      rc=1
    fi
  done
  sudo "$PARTPROBE" "$disk" >/dev/null 2>&1 || true
  sleep 1
  return $rc
}

# ---------------------------------------------------------------------------
# Partition imaging
# ---------------------------------------------------------------------------

report_partclone_failure() {
  local rawlog="$1" part="$2"
  echo
  echo "ERROR: partclone failed for $part"
  if [[ -f "$rawlog" ]]; then
    if grep -E "is mounted at" "$rawlog" >/dev/null 2>&1; then
      local mp
      mp="$(mountpoint_of "$part")"
      echo "Reason: the partition appears to be mounted at '${mp:-unknown}'."
      echo "Action: you cannot image a mounted root or active partition from inside the running system."
      echo "Suggested fix: boot a live USB or other external environment, or unmount the partition before retrying."
    else
      echo "Partclone log (last 30 lines):"
      tail -n 30 "$rawlog" 2>/dev/null || true
    fi
  else
    echo "No partclone log found at $rawlog"
  fi
}

backup_partition() {
  local part="$1" outdir="$2" comp_method="$3" comp_level="$4"
  local partname fs pclone outbase outimg tmpimg rawlog pc_status size

  partname="$(basename "$part")"
  size="$(get_num "$LSBLK" -bno SIZE "$part")"

  if [[ "$BACKUP_ENGINE" == "dd" ]]; then
    # Raw sector copy — filesystem-agnostic, no partclone naming.
    outbase="${partname}.dd-img"
  else
    fs="$(fs_from_lsblk "$part")"
    if [[ -z "$fs" ]]; then
      echo "Skipping $part: cannot identify the filesystem."
      echo "  (Guessing here risked imaging e.g. NTFS with partclone.ext4.)"
      echo "  Use a dd backup mode for this partition instead."
      return 1
    fi
    pclone="$(restore_tool_for_fs "$fs")"
    [[ -n "$pclone" ]] || { echo "Skipping $part: no partclone tool for filesystem '$fs'"; return 1; }
    command -v "$pclone" >/dev/null 2>&1 || { echo "Skipping $part: $pclone not found"; return 1; }
    outbase="${partname}.${fs}-ptcl-img"
  fi

  outimg="$outdir/$outbase"
  [[ -n "$comp_method" ]] && outimg+=".$comp_method"
  # Same filesystem as the final image so "mv" is an atomic rename. Using
  # $WORKDIR (/tmp) meant mv had to COPY the whole image afterwards, which
  # looked like the script hanging at 100%.
  tmpimg="$outdir/.${outbase}.part"
  rawlog="$outdir/$outbase.log"

  # pv sits between the reader and the compressor when available
  pv_stage() {
    if [[ -n "$PV" && "$size" -gt 0 ]]; then "$PV" -ptebar -s "$size"; else cat; fi
  }

  # Reader for the pipeline; PIPESTATUS[0] reflects the inner command.
  # NOTE: partclone.dd (used for swap) does NOT accept -c — clone mode is
  # implied by the binary name. Passing it makes partclone print usage and
  # exit 0, silently producing an EMPTY image. Only the fs-specific tools
  # take -c.
  read_stream() {
    if [[ "$BACKUP_ENGINE" == "dd" ]]; then
      sudo "$DD" if="$part" bs=4M status=none
    elif [[ "$pclone" == "partclone.dd" ]]; then
      sudo "$pclone" -s "$part" -O -
    else
      sudo "$pclone" -c -s "$part" -O -
    fi
  }

  if [[ -n "$comp_method" ]]; then
    case "$comp_method" in
      zst) read_stream 2>"$rawlog" | pv_stage | "$ZSTD" -c -"$comp_level" $ZSTD_ULTRA -T0 > "$tmpimg" ;;
      gz)  read_stream 2>"$rawlog" | pv_stage | "$GZIP" -c -"$comp_level" > "$tmpimg" ;;
      xz)  read_stream 2>"$rawlog" | pv_stage | "$XZ" -c -"$comp_level" > "$tmpimg" ;;
      bz2) read_stream 2>"$rawlog" | pv_stage | "$BZIP2" -c -"$comp_level" > "$tmpimg" ;;
      *) echo "Invalid compression method: $comp_method"; return 1 ;;
    esac
    pc_status=${PIPESTATUS[0]}
  else
    read_stream 2>"$rawlog" | pv_stage > "$tmpimg"
    pc_status=${PIPESTATUS[0]}
  fi

  if [[ "$pc_status" -ne 0 ]]; then
    report_partclone_failure "$rawlog" "$part"
    rm -f "$tmpimg" >/dev/null 2>&1 || true
    return 1
  fi

  [[ -s "$tmpimg" ]] || { echo "Backup produced empty image for $part"; return 1; }
  mv -f "$tmpimg" "$outimg"
  return 0
}

print_partitions() {
  local disk="$1"
  local -a parts=()
  local p i lab
  mapfile -t parts < <("$LSBLK" -pnr -o NAME,TYPE "$disk" | "$AWK" '$2=="part"{print $1}')
  for i in "${!parts[@]}"; do
    p="${parts[$i]}"
    lab="$(label_from_lsblk "$p")"
    printf '[%d] %s%s fs:%s mount:%s\n' "$((i+1))" "$p" "${lab:+ Label:$lab}" "$(fs_from_lsblk "$p")" "$(mountpoint_of "$p")"
  done
}

# ---------------------------------------------------------------------------
# Main disk backup flow
# ---------------------------------------------------------------------------

backup_disk() {
  local disk="$1" outroot="$2" comp_method="$3" comp_level="$4"
  local diskbase custom_name outbase now parts_line backup_mode shrink_choice p pttype
  local -a PARTS=() BACKUP_PARTS=() SHRINK_PARTS=() SHRUNK_PARTS=()

  diskbase="$(basename "$disk")"
  pttype="$(disk_pttype "$disk")"

  local def_suffix="ptcl-img"
  [[ "$BACKUP_ENGINE" == "dd" ]] && def_suffix="img"

  while true; do
    echo
    echo "Enter a name for the backup folder (press Enter for default):"
    echo "Default: ${diskbase}-YYYYMMDD-HHMMSS-${def_suffix}"
    read -r -p "Backup name: " custom_name
    custom_name="${custom_name// /_}"
    [[ -n "$custom_name" ]] || custom_name="${diskbase}-$(date +%Y%m%d-%H%M%S)-${def_suffix}"
    if check_backup_folder_exists "$custom_name" "$outroot"; then
      echo "ERROR: Backup folder already exists!"
      continue
    fi
    break
  done

  outbase="$custom_name"
  OUT_DIR="$outroot/$outbase"
  mkdir -p "$OUT_DIR"

echo
echo "Selected device:"
echo "DISK: $disk"

model="$(clean_model "$("$LSBLK" -dnro MODEL -- "$disk" 2>/dev/null || true)")"

size_raw="$("$LSBLK" -dnrbno SIZE -- "$disk" 2>/dev/null || true)"
size_raw="${size_raw:-0}"

if [[ "$size_raw" -gt 0 ]]; then
  human_size="$(awk "BEGIN {printf \"%.1fG\", $size_raw/1024/1024/1024}")"
else
  human_size="Unknown"
fi

echo "MODEL: $model"
echo "SIZE: $human_size"

"$LSBLK" -- "$disk"

while true; do
  read -r -p "Is this the correct disk to back up? (y/N): " confirm_raw
  confirm_lc="${confirm_raw,,}"
  case "$confirm_lc" in
    y|yes) break ;;
    n|no|"") echo "Cancelled."; exit 0 ;;
    *) echo "Please answer y or yes, or n or no." ;;
  esac
done


  mapfile -t PARTS < <("$LSBLK" -pnr -o NAME,TYPE "$disk" | "$AWK" '$2=="part"{print $1}')
  ((${#PARTS[@]} > 0)) || { echo "No partitions found"; exit 1; }

  if [[ -n "${QUICK_PARTS:-}" ]]; then
    backup_mode="$QUICK_PARTS"
  else
    echo
    echo "Backup mode:"
    echo "1) Backup all partitions on the disk"
    echo "2) Backup only selected partitions"
    ask_num backup_mode "Choose backup mode: " 1 2
  fi
  BACKUP_PARTS=()
  case "$backup_mode" in
    1) BACKUP_PARTS=("${PARTS[@]}") ;;
    2)
      echo
      print_partitions "$disk"
      ask_indices choice "Enter assigned list number for partition [x] (space separated): " "${#PARTS[@]}" 1
      for n in $choice; do
        BACKUP_PARTS+=("${PARTS[$((n - 1))]}")
      done
      ;;
  esac

  local only_swap=1
  for p in "${BACKUP_PARTS[@]}"; do
    if [[ "$(fs_from_lsblk "$p")" != "swap" ]]; then
      only_swap=0
      break
    fi
  done
  if [[ "$only_swap" -eq 1 ]]; then
    echo "ERROR: You selected only swap partition(s). Backing up swap alone is not supported."
    echo "Select at least one normal filesystem partition, or use full-disk backup."
    exit 1
  fi

  # Refuse if any selected partition backs the running root, /boot or EFI.
  # Other partitions on the same disk are fine and remain allowed.
  check_parts_not_live BACKUP_PARTS

  # Verify the imaging tools exist BEFORE anything is shrunk. Finding out
  # afterwards used to abort the run with partitions left shrunk.
  if [[ "$BACKUP_ENGINE" != "dd" ]]; then
    local _fs _tool _miss=()
    for p in "${BACKUP_PARTS[@]}"; do
      _fs="$(fs_from_lsblk "$p")"
      [[ -n "$_fs" ]] || continue
      _tool="$(restore_tool_for_fs "$_fs")"
      if [[ -z "$_tool" ]]; then
        _miss+=("$p ($_fs): no partclone tool exists for this filesystem")
      elif ! command -v "$_tool" >/dev/null 2>&1; then
        _miss+=("$p ($_fs): $_tool is not installed")
      fi
    done
    if (( ${#_miss[@]} > 0 )); then
      echo
      echo "ERROR: cannot image every selected partition with partclone:"
      for _fs in "${_miss[@]}"; do echo "  - $_fs"; done
      echo
      echo "Install the missing partclone tools, deselect those partitions, or"
      echo "choose a dd backup mode. Nothing has been changed."
      exit 1
    fi
  fi

  # -------------------------------------------------------------------------
  # METADATA SET 1 (ORIGINAL layout): canonical filenames, written BEFORE any
  # shrink. The current cz-restore.sh parses these (pt.parted.compact first,
  # pt.sf fallback) and therefore recreates original geometry by default.
  # -------------------------------------------------------------------------
  echo
  echo "Saving partition table metadata (original layout)..."
  parts_line="$(printf '%s\n' "${BACKUP_PARTS[@]}" | sed 's|.*/||' | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
  now="$(date -u '+%Y-%m-%d %H:%M:%SZ')"
  write_all_metadata "$disk" "$OUT_DIR" "$diskbase" "$parts_line" "$outbase" "$now" "$pttype" || exit 1
  printf 'engine=%s\n' "$BACKUP_ENGINE" >> "$OUT_DIR/${diskbase}.info"

  # Optional ext4 shrink
  local has_ext4=0
  for p in "${BACKUP_PARTS[@]}"; do
    [[ "$(fs_from_lsblk "$p")" == ext4 ]] && has_ext4=1
  done

  if [[ "$has_ext4" -eq 1 ]] && ! command -v resize2fs >/dev/null 2>&1; then
    echo
    echo "Note: resize2fs is not installed, so the ext4 shrink option is unavailable."
    has_ext4=0
  fi

  if [[ "$has_ext4" -eq 1 ]]; then
    echo
    echo "Would you like to shrink the selected ext4 partition(s) before backup?"
    echo "1) Yes"
    echo "2) No"
    if [[ -n "${QUICK_SHRINK:-}" ]]; then
      shrink_choice="$QUICK_SHRINK"
      echo "Choice: $shrink_choice (quick preset)"
    else
      while true; do
        read -r -p "Choice: " shrink_choice_raw
        shrink_choice_lc="${shrink_choice_raw,,}"
        case "$shrink_choice_lc" in
          1|y|yes) shrink_choice="1"; break ;;
          2|n|no) shrink_choice="2"; break ;;
          *) echo "Please answer 1/2 or y/n." ;;
        esac
      done
    fi

    if [[ "$shrink_choice" == "1" ]]; then
      SHRINK_PARTS=()
      for p in "${BACKUP_PARTS[@]}"; do
        [[ "$(fs_from_lsblk "$p")" == ext4 ]] && SHRINK_PARTS+=("$p")
      done
      for p in "${SHRINK_PARTS[@]}"; do
        write_pre_shrink_meta "$p" "$OUT_DIR" "$disk" || exit 1
      done
      unmount_parts SHRINK_PARTS
      for p in "${SHRINK_PARTS[@]}"; do
        echo "Shrinking $p ..."
        if shrink_ext4_partition "$p" "$disk"; then
          SHRUNK_PARTS+=("$p")
        else
          echo "Shrink failed for $p. Nothing was deleted."
          echo "Aborting so the disk is left in a known state."
          exit 1
        fi
      done

      # ---------------------------------------------------------------------
      # METADATA SET 2 (SHRUNKEN layout): written AFTER all shrinks so the
      # geometry matches the partclone images exactly. ".shrunken" suffix
      # keeps the canonical files untouched. Future restore reads these when
      # the user picks "restore as shrunken"; shrunk-parts tells it which
      # partitions need resize2fs grow in "original size" mode instead.
      # ---------------------------------------------------------------------
      echo
      echo "Saving partition table metadata (shrunken layout)..."
      sudo partprobe "$disk" >/dev/null 2>&1 || true
      sleep 1
      write_shrunk_metadata "$disk" "$OUT_DIR" "$diskbase" "$parts_line" "$pttype" SHRUNK_PARTS || exit 1
    fi
  fi

  echo
  echo "Unmounting partitions that will be backed up..."
  unmount_parts BACKUP_PARTS

  # Optional filesystem check & repair (like Clonezilla's fsck option).
  # Runs after unmount, immediately before imaging.
  offer_fsck BACKUP_PARTS

  echo
  echo "Compression method: ${comp_method:-none}"
  echo "Compression level: ${comp_level:-n/a}"
  echo
  echo "Backing up partitions..."
  local idx=0 total="${#BACKUP_PARTS[@]}"
  for p in "${BACKUP_PARTS[@]}"; do
    idx=$((idx+1))
    echo "Backing up partition $idx/$total: $p"
    if backup_partition "$p" "$OUT_DIR" "$comp_method" "$comp_level"; then
      echo "Partition $p backed up successfully."
    else
      echo "ERROR: Backup failed for partition $p. Aborting backup run."
      exit 1
    fi
  done

  # Undo the shrink: original sizes back, filesystems grown to fill again.
  if ((${#SHRUNK_PARTS[@]} > 0)); then
    echo
    echo "Restoring original partition size(s)..."
    restore_original_partition_sizes "$disk" || exit 1

    echo
    echo "Growing restored ext4 filesystem(s) to fill partitions..."
    for p in "${SHRUNK_PARTS[@]}"; do
      grow_ext4_partition "$p" || exit 1
    done
    sudo "$PARTPROBE" "$disk" >/dev/null 2>&1 || true
    # Everything is back to normal: the crash-rollback no longer applies.
    SHRINK_JOURNAL=()
  fi

  echo
  echo "All partition backups completed."
  echo "Image folder: $OUT_DIR"
  echo "Done."
}

# ---------------------------------------------------------------------------
# Full-disk (entire block device) backup — partclone.dd of the whole disk,
# like Clonezilla's dd/savedisk-raw mode. Captures partition table, boot
# code, all partitions and inter-partition gaps in one image.
# ---------------------------------------------------------------------------

backup_full_disk() {
  local disk="$1" outroot="$2" comp_method="$3" comp_level="$4"
  local diskbase pttype custom_name outbase now parts_line
  local outname outimg tmpimg rawlog pc_status disk_bytes
  local -a PARTS=()

  diskbase="$(basename "$disk")"
  pttype="$(disk_pttype "$disk")"

  if [[ "$BACKUP_ENGINE" != "dd" ]]; then
    command -v partclone.dd >/dev/null 2>&1 || {
      echo "ERROR: partclone.dd is required for full-disk partclone imaging but was not found."
      exit 1
    }
  fi

  # A full-disk image of the disk hosting the running OS / EFI is never safe.
  check_disk_not_live "$disk"

  local def_suffix="ptcl-img"
  [[ "$BACKUP_ENGINE" == "dd" ]] && def_suffix="img"

  while true; do
    echo
    echo "Enter a name for the backup folder (press Enter for default):"
    echo "Default: ${diskbase}-YYYYMMDD-HHMMSS-${def_suffix}"
    read -r -p "Backup name: " custom_name
    custom_name="${custom_name// /_}"
    [[ -n "$custom_name" ]] || custom_name="${diskbase}-$(date +%Y%m%d-%H%M%S)-${def_suffix}"
    if check_backup_folder_exists "$custom_name" "$outroot"; then
      echo "ERROR: Backup folder already exists!"
      continue
    fi
    break
  done

  outbase="$custom_name"
  OUT_DIR="$outroot/$outbase"
  mkdir -p "$OUT_DIR"

  echo
  echo "Selected device:"
  echo "DISK: $disk"
  model="$(clean_model "$("$LSBLK" -dnro MODEL -- "$disk" 2>/dev/null || true)")"
  size_raw="$("$LSBLK" -dnrbno SIZE -- "$disk" 2>/dev/null || true)"
  size_raw="${size_raw:-0}"
  if [[ "$size_raw" -gt 0 ]]; then
    human_size="$(awk "BEGIN {printf \"%.1fG\", $size_raw/1024/1024/1024}")"
  else
    human_size="Unknown"
  fi
  echo "MODEL: $model"
  echo "SIZE: $human_size"
  "$LSBLK" -- "$disk"

  echo
  echo "NOTE: a full-disk image copies EVERY sector of the block device,"
  echo "including free space and inter-partition gaps. The image will be"
  echo "as large as the disk (minus compression)."
  while true; do
    read -r -p "Is this the correct disk to image in full? (y/N): " confirm_raw
    confirm_lc="${confirm_raw,,}"
    case "$confirm_lc" in
      y|yes) break ;;
      n|no|"") echo "Cancelled."; exit 0 ;;
      *) echo "Please answer y or yes, or n or no." ;;
    esac
  done

  mapfile -t PARTS < <("$LSBLK" -pnr -o NAME,TYPE "$disk" | "$AWK" '$2=="part"{print $1}')

  # A filesystem can live directly on the whole device with no partition
  # table (common on USB sticks). That mount also has to go, or partclone
  # refuses with "device is mounted at ...".
  local -a UMOUNT_TARGETS=("${PARTS[@]}")
  if [[ -n "$(get_mountpoint_ub "$disk")" ]]; then
    UMOUNT_TARGETS+=("$disk")
  fi

  # Full-disk covers every partition, so all of the disk's partitions must be
  # unmounted for a consistent image. Partitions on OTHER disks are untouched.
  if ((${#UMOUNT_TARGETS[@]} > 0)); then
    echo
    echo "Unmounting everything on $disk..."
    unmount_parts UMOUNT_TARGETS

    # Optional fsck before imaging, same as partition mode.
    offer_fsck UMOUNT_TARGETS
  fi

  echo
  echo "Saving partition table metadata..."
  parts_line="$(printf '%s\n' "${PARTS[@]:-}" | sed 's|.*/||' | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
  now="$(date -u '+%Y-%m-%d %H:%M:%SZ')"
  write_all_metadata "$disk" "$OUT_DIR" "$diskbase" "$parts_line" "$outbase" "$now" "$pttype" || exit 1
  printf 'backup_type=full-disk\nengine=%s\n' "$BACKUP_ENGINE" >> "$OUT_DIR/${diskbase}.info"

  if [[ "$BACKUP_ENGINE" == "dd" ]]; then
    outname="${diskbase}.dd-img"
  else
    outname="${diskbase}.dd-ptcl-img"
  fi
  outimg="$OUT_DIR/$outname"
  [[ -n "$comp_method" ]] && outimg+=".$comp_method"
  tmpimg="$OUT_DIR/.${outname}.part"
  rawlog="$OUT_DIR/$outname.log"
  disk_bytes="$(get_num "$LSBLK" -bno SIZE "$disk")"

  pv_stage() {
    if [[ -n "$PV" && "$disk_bytes" -gt 0 ]]; then "$PV" -ptebar -s "$disk_bytes"; else cat; fi
  }

  # Reader for the pipeline; PIPESTATUS[0] reflects the inner command.
  read_stream() {
    if [[ "$BACKUP_ENGINE" == "dd" ]]; then
      sudo "$DD" if="$disk" bs=4M status=none
    else
      sudo partclone.dd -s "$disk" -O -
    fi
  }

  echo
  echo "Compression method: ${comp_method:-none}"
  echo "Compression level: ${comp_level:-n/a}"
  echo
  echo "Imaging entire block device $disk..."
  if [[ -n "$comp_method" ]]; then
    case "$comp_method" in
      zst) read_stream 2>"$rawlog" | pv_stage | "$ZSTD" -c -"$comp_level" $ZSTD_ULTRA -T0 > "$tmpimg" ;;
      gz)  read_stream 2>"$rawlog" | pv_stage | "$GZIP" -c -"$comp_level" > "$tmpimg" ;;
      xz)  read_stream 2>"$rawlog" | pv_stage | "$XZ" -c -"$comp_level" > "$tmpimg" ;;
      bz2) read_stream 2>"$rawlog" | pv_stage | "$BZIP2" -c -"$comp_level" > "$tmpimg" ;;
      *) echo "Invalid compression method: $comp_method"; exit 1 ;;
    esac
    pc_status=${PIPESTATUS[0]}
  else
    read_stream 2>"$rawlog" | pv_stage > "$tmpimg"
    pc_status=${PIPESTATUS[0]}
  fi

  if [[ "$pc_status" -ne 0 ]]; then
    echo
    echo "ERROR: full-disk imaging failed for $disk"
    tail -n 30 "$rawlog" 2>/dev/null || true
    rm -f "$tmpimg" >/dev/null 2>&1 || true
    exit 1
  fi

  [[ -s "$tmpimg" ]] || { echo "Full-disk imaging produced an empty file."; exit 1; }
  mv -f "$tmpimg" "$outimg"

  echo
  echo "Full-disk backup completed."
  echo "Image folder: $OUT_DIR"
  echo "Done."
}

# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------

echo "Which disk would you like to back up?"
echo "Available disks:"
mapfile -t DISKS < <("$LSBLK" -pnr -o NAME,TYPE | "$AWK" '$2=="disk"{print $1}')
((${#DISKS[@]} > 0)) || { echo "No physical disks found."; exit 1; }

for i in "${!DISKS[@]}"; do
  d="${DISKS[$i]}"
  size_raw="$(get_num "$LSBLK" -dnrbno SIZE "$d")"
  human="Unknown"
  [[ "$size_raw" -gt 0 ]] && human="$(awk "BEGIN {printf \"%.1fG\", $size_raw/1024/1024/1024}")"
  printf '[%d] %s (%s) %s\n' "$((i+1))" "$d" "$human" "$(clean_model "$(get_text "$LSBLK" -dnro MODEL "$d")")"
done

echo
ask_num didx "Choose physical disk index: " 1 "${#DISKS[@]}"
TARGET_DISK="${DISKS[$((didx-1))]}"

if [[ -n "${QUICK_TYPE:-}" ]]; then
  BACKUP_TYPE="$QUICK_TYPE"
else
  echo
  echo "Backup type:"
  echo "1) Partition images (all or selected partitions on the disk)"
  echo "2) Full Disk image (entire block device)"
  ask_num type_sel "Choose backup type: " 1 2
  case "$type_sel" in
    1) BACKUP_TYPE="partitions" ;;
    2) BACKUP_TYPE="fulldisk" ;;
  esac
fi

if [[ -n "${QUICK_ENGINE:-}" ]]; then
  BACKUP_ENGINE="$QUICK_ENGINE"
  COMP_METHOD="${QUICK_COMP_METHOD:-}"
  COMP_LEVEL="${QUICK_COMP_LEVEL:-}"
  ZSTD_ULTRA=""
  case "$COMP_METHOD" in
    zst) [[ -n "$ZSTD" ]]  || { echo "zstd not installed";  exit 1; } ;;
    gz)  [[ -n "$GZIP" ]]  || { echo "gzip not installed";  exit 1; } ;;
    xz)  [[ -n "$XZ" ]]    || { echo "xz not installed";    exit 1; } ;;
    bz2) [[ -n "$BZIP2" ]] || { echo "bzip2 not installed"; exit 1; } ;;
  esac
  echo
  echo "Quick preset: ${BACKUP_ENGINE}, ${COMP_METHOD:-no compression} level ${COMP_LEVEL:-n/a}"
else
  echo
  echo "Backup mode:"
  echo "1) Partclone image with compression"
  echo "2) Partclone image without compression"
  echo "3) dd image with compression"
  echo "4) dd image without compression"
  ask_num mode_sel "Choose backup mode: " 1 4
  case "$mode_sel" in
    1)
      BACKUP_ENGINE="partclone"
      choose_method
      case "$COMP_METHOD" in
        zst) choose_zst_level ;;
        gz) choose_gz_level ;;
        xz) choose_xz_level ;;
        bz2) choose_bz2_level ;;
        "") COMP_LEVEL="" ;;
      esac
      ;;
    2) BACKUP_ENGINE="partclone"; COMP_METHOD=""; COMP_LEVEL="" ;;
    3)
      BACKUP_ENGINE="dd"
      choose_method
      case "$COMP_METHOD" in
        zst) choose_zst_level ;;
        gz) choose_gz_level ;;
        xz) choose_xz_level ;;
        bz2) choose_bz2_level ;;
        "") COMP_LEVEL="" ;;
      esac
      ;;
    4) BACKUP_ENGINE="dd"; COMP_METHOD=""; COMP_LEVEL="" ;;
  esac
fi

if [[ "$BACKUP_TYPE" == "fulldisk" ]]; then
  backup_full_disk "$TARGET_DISK" "$OUT_ROOT" "$COMP_METHOD" "$COMP_LEVEL"
else
  backup_disk "$TARGET_DISK" "$OUT_ROOT" "$COMP_METHOD" "$COMP_LEVEL"
fi

)

# ===========================================================================
# MODULE: RESTORE  (cz-restore.sh)
# Runs in a SUBSHELL: its helper functions, globals and EXIT trap are private
# to this module and cannot collide with the other two.
# ===========================================================================
run_restore() (
set -euo pipefail

# cz-restore.sh — restores backups made by cz-backup.sh (and Clonezilla-style
# partclone partition images).
#
# Capabilities:
#   - partclone partition images (*-ptcl-img[.zst|.gz|.xz|.bz2])
#   - dd partition images        (*.dd-img[.zst|.gz|.xz|.bz2])
#   - full-disk images           (sdX.dd-img / sdX.dd-ptcl-img)
#   - restore to original positions or append into free space
#   - shrunken-backup support: restore at original size (fs grown to fill)
#     or at shrunken size (small pad added, fs grown into the pad)
#   - post-restore fsck option, post-restore labeling option
#   - live-system safety guards (never touches the running / /boot /boot/efi)
#
# Target partition tables: this script creates partitions with sgdisk, i.e.
# GPT. Full-disk erase converts the target to GPT. Partial/append restore
# onto an MBR (dos) target is refused — see the warning it prints.

# Cross-distro tool discovery: Debian-family user PATH lacks /usr/sbin, /sbin.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

# Image directory: first argument, else the current directory. (Standalone
# this used to be the script's own location; taking an argument makes it
# consistent with cz-backup / cz-mount and usable from one central place.)
SRC_DIR="${1:-.}"
SRC_DIR="$(cd "$SRC_DIR" 2>/dev/null && pwd)" || { echo "No such directory: ${1:-.}" >&2; exit 1; }
WORKDIR="$(mktemp -d -t cz-restore.XXXXXX)"

AWK=""; SED=""; GREP=""; FIND=""; SORT=""; CAT=""
LSBLK=""; UMOUNT=""; FINDMNT=""; SGDISK=""; PARTPROBE=""; BLOCKDEV=""; PARTED=""; DD=""
PARTCLONE_INFO=""; NUMFMT=""; ZSTD=""; GZIP=""; XZ=""; BZIP2=""

cleanup() {
  rm -rf "$WORKDIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT

pick_cmd() {
  local varname="$1"; shift
  local p found=""
  for p in "$@"; do
    found="$(command -v "$p" 2>/dev/null || true)"
    if [[ -n "$found" ]]; then
      printf -v "$varname" '%s' "$found"
      return 0
    fi
  done
  return 1
}

need_cmd() {
  local varname="$1"; shift
  pick_cmd "$varname" "$@" || { echo "Missing required command: $*" >&2; exit 1; }
}

need_cmd AWK awk
need_cmd SED sed
need_cmd GREP grep
need_cmd FIND find
need_cmd SORT sort
need_cmd CAT cat
need_cmd LSBLK lsblk
need_cmd UMOUNT umount
need_cmd FINDMNT findmnt
need_cmd SGDISK sgdisk
need_cmd PARTPROBE partprobe
need_cmd BLOCKDEV blockdev
need_cmd PARTED parted
need_cmd DD dd

# Optional. partclone.info is only a metadata fallback when the pt files are
# missing; the restore itself does not require it.
pick_cmd PARTCLONE_INFO partclone.info || true
command -v numfmt >/dev/null 2>&1 && NUMFMT="$(command -v numfmt)" || true
command -v zstd >/dev/null 2>&1 && ZSTD="$(command -v zstd)" || true
command -v gzip >/dev/null 2>&1 && GZIP="$(command -v gzip)" || true
command -v xz >/dev/null 2>&1 && XZ="$(command -v xz)" || true
command -v bzip2 >/dev/null 2>&1 && BZIP2="$(command -v bzip2)" || true

# zstd ultra levels (20-22) use large windows; decompression needs --long=31.
ZSTD_DFLAGS=""
if [[ -n "$ZSTD" ]] && "$ZSTD" --help 2>&1 | grep -q -- '--long'; then
  ZSTD_DFLAGS="--long=31"
fi

sudo -v
( while true; do sudo -n true; sleep 60; done ) >/dev/null 2>&1 &
SUDO_KEEPALIVE_PID=$!
trap 'kill "$SUDO_KEEPALIVE_PID" >/dev/null 2>&1 || true; cleanup' EXIT

# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------

trim_s() { echo "${1%s}"; }

human_size() {
  if [[ -n "$NUMFMT" ]]; then
    "$NUMFMT" --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || echo "${1}B"
  else
    echo "${1}B"
  fi
}

clean_model() {
  printf '%s' "${1:-}" | sed 's/\\x[0-9a-fA-F]\{2\}/ /g' | tr -cd '[:print:]' | sed 's/[[:space:]]\+/ /g;s/^ *//;s/ *$//'
}

decompress_stream() {
  case "$1" in
    *.zst) [[ -n "$ZSTD" ]] || { echo "zstd required for $1" >&2; return 1; }; "$ZSTD" -dc $ZSTD_DFLAGS "$1" ;;
    *.gz)  [[ -n "$GZIP" ]] || { echo "gzip required for $1" >&2; return 1; }; "$GZIP" -dc "$1" ;;
    *.xz)  [[ -n "$XZ" ]] || { echo "xz required for $1" >&2; return 1; }; "$XZ" -dc "$1" ;;
    *.bz2) [[ -n "$BZIP2" ]] || { echo "bzip2 required for $1" >&2; return 1; }; "$BZIP2" -dc "$1" ;;
    *) "$CAT" "$1" ;;
  esac
}

strip_comp_ext() {
  local n="$1"
  n="${n%.zst}"; n="${n%.gz}"; n="${n%.xz}"; n="${n%.bz2}"
  printf '%s' "$n"
}

# Split an original device name into diskbase|partnum. Handles sdc1, sda12,
# vda3, nvme0n1p2, mmcblk0p1, loop0p1, etc. A whole-disk name (sdc, nvme0n1)
# yields an empty partnum.
split_dev_name() {
  local dev="$1"
  if [[ "$dev" =~ ^(.+[0-9])p([0-9]+)$ ]]; then
    printf '%s|%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
  elif [[ "$dev" =~ ^(.*[^0-9])([0-9]+)$ ]]; then
    printf '%s|%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
  else
    printf '%s|' "$dev"
  fi
}

# Build /dev path of partition N on a disk. If the disk name ends in a digit
# (nvme0n1, mmcblk0, loop0) the kernel inserts a 'p' separator.
target_part_path() {
  local disk="$1" num="$2"
  if [[ "$disk" =~ [0-9]$ ]]; then printf '%sp%s' "$disk" "$num"; else printf '%s%s' "$disk" "$num"; fi
}

disk_pttype() {
  local v
  v="$("$LSBLK" -no PTTYPE "$1" 2>/dev/null | head -n1 | tr -d ' ')"
  v="${v,,}"
  case "$v" in msdos) echo mbr ;; *) echo "$v" ;; esac
}

norm_fs_name() {
  case "$1" in
    fat|fat12|fat16|fat32) echo "vfat" ;;
    hfs) echo "hfsplus" ;;
    linux-swap*) echo "swap" ;;
    *) echo "$1" ;;
  esac
}

restore_tool_for_fs() {
  case "$1" in
    vfat) echo partclone.vfat ;;
    ext2) echo partclone.ext2 ;;
    ext3) echo partclone.ext3 ;;
    ext4) echo partclone.ext4 ;;
    ntfs) echo partclone.ntfs ;;
    xfs) echo partclone.xfs ;;
    btrfs) echo partclone.btrfs ;;
    exfat) echo partclone.exfat ;;
    jfs) echo partclone.jfs ;;
    reiserfs) echo partclone.reiserfs ;;
    hfsplus) echo partclone.hfsp ;;
    f2fs) echo partclone.f2fs ;;
    nilfs2) echo partclone.nilfs2 ;;
    ubifs) echo partclone.ubifs ;;
    *) echo "" ;;
  esac
}

# GPT type GUID or filesystem -> sgdisk type code
type_to_sgdisk() {
  local guid="${1:-}" fs="${2:-}"
  guid="${guid^^}"
  case "$guid" in
    C12A7328-F81F-11D2-BA4B-00A0C93EC93B) echo "ef00"; return ;;
    0657FD6D-A4AB-43C4-84E5-0933C84B4F4F) echo "8200"; return ;;
    0FC63DAF-8483-4772-8E79-3D69D8477DE4) echo "8300"; return ;;
  esac
  case "$fs" in
    vfat) echo "ef00" ;;
    swap) echo "8200" ;;
    *) echo "8300" ;;
  esac
}

# ---------------------------------------------------------------------------
# Metadata readers. suffix is "" (original layout) or ".shrunken".
# ---------------------------------------------------------------------------

# pt.sf line -> start|size|typeGUID
get_sf_meta() {
  local diskbase="$1" num="$2" suffix="${3:-}" f line start size ptype
  f="$SRC_DIR/${diskbase}-pt.sf${suffix}"
  [[ -f "$f" ]] || return 1
  line="$("$GREP" -E "^/dev/${diskbase}p?${num}[[:space:]]*:" "$f" 2>/dev/null | head -n1 || true)"
  [[ -n "$line" ]] || return 1
  start="$("$SED" -nE 's/.*start=[[:space:]]*([0-9]+).*/\1/p' <<<"$line")"
  size="$("$SED" -nE 's/.*size=[[:space:]]*([0-9]+).*/\1/p' <<<"$line")"
  ptype="$("$SED" -nE 's/.*type=([0-9A-Fa-f-]+).*/\1/p' <<<"$line")"
  [[ -n "$start" && -n "$size" ]] || return 1
  printf '%s|%s|%s' "$start" "$size" "${ptype:-}"
}

# pt.parted.compact row -> num|start|end|size|fs|name
get_compact_meta() {
  local diskbase="$1" num="$2" suffix="${3:-}" f line
  f="$SRC_DIR/${diskbase}-pt.parted.compact${suffix}"
  [[ -f "$f" ]] || return 1
  line="$("$AWK" -v n="$num" '$1==n {print; exit}' "$f" 2>/dev/null || true)"
  [[ -n "$line" ]] || return 1
  local a b c d e f2 g
  read -r a b c d e f2 g <<<"$line"
  b="$(trim_s "$b")"; c="$(trim_s "$c")"; d="$(trim_s "$d")"
  printf '%s|%s|%s|%s|%s|%s' "$a" "$b" "$c" "$d" "${e:-}" "${f2:-}"
}

# partclone image header -> fs|bytes (fallback only)
get_img_meta_from_partclone() {
  local img="$1" tmp fs bytes
  [[ -n "$PARTCLONE_INFO" ]] || { printf '||'; return 0; }
  tmp="$(mktemp)"
  if ! decompress_stream "$img" 2>/dev/null | "$PARTCLONE_INFO" -s - >"$tmp" 2>/dev/null; then
    rm -f "$tmp"
    printf '||'
    return 0
  fi
  fs="$("$AWK" -F': ' '/^File system:/ {print $2; exit} /^Filesystem:/ {print $2; exit}' "$tmp" 2>/dev/null || true)"
  bytes="$("$AWK" -F': ' '/^Device size:/ {print $2; exit} /^Total size:/ {print $2; exit}' "$tmp" 2>/dev/null || true)"
  rm -f "$tmp"
  fs="$(echo "${fs:-}" | tr '[:upper:]' '[:lower:]' | tr -d ' ')"
  bytes="$("$SED" -nE 's/^([0-9]+).*/\1/p' <<<"$bytes")"
  printf '%s|%s' "${fs:-}" "${bytes:-}"
}

# ---------------------------------------------------------------------------
# Live-system safety
# ---------------------------------------------------------------------------

mount_source() {
  local s
  s="$("$FINDMNT" -no SOURCE "$1" 2>/dev/null || true)"
  printf '%s' "${s%%\[*}"   # strip btrfs [subvol] suffix
}

# Is this device (or any of its ancestors' dependents) backing the running
# system? Uses raw lsblk output — tree glyphs break the match otherwise.
dev_is_live() {
  local dev="$1" m
  for m in "$(mount_source /)" "$(mount_source /boot/efi)" "$(mount_source /boot)" "$(mount_source /home)"; do
    [[ -n "$m" ]] || continue
    [[ "$dev" == "$m" ]] && return 0
    if [[ -b "$m" ]] && "$LSBLK" -srno NAME "$m" 2>/dev/null | "$GREP" -qx "$(basename "$dev")"; then
      return 0
    fi
  done
  return 1
}

disk_is_live() {
  local disk="$1" diskbase m
  diskbase="$(basename "$disk")"
  for m in "$(mount_source /)" "$(mount_source /boot/efi)" "$(mount_source /boot)" "$(mount_source /home)"; do
    [[ -n "$m" && -b "$m" ]] || continue
    if "$LSBLK" -srno NAME "$m" 2>/dev/null | "$GREP" -qx "$diskbase"; then
      return 0
    fi
  done
  return 1
}

abort_live() {
  echo
  echo "ERROR: $1 backs the running system (/, /boot, /boot/efi or /home)."
  echo "Restoring over the live system is not possible from inside the running OS."
  echo "Boot a live USB or another external environment instead."
  exit 1
}

unmount_devs() {
  local -n devs_ref="$1"
  local d mp any=0
  for d in "${devs_ref[@]}"; do
    [[ -b "$d" ]] || continue
    mp="$("$LSBLK" -dnro MOUNTPOINT "$d" 2>/dev/null | tr -d ' ' || true)"
    [[ -n "$mp" ]] || continue
    case "$mp" in
      /|/boot|/boot/efi)
        echo
        echo "REFUSING to unmount $d (mounted at $mp) — live system mount."
        echo "Aborting."
        exit 1
        ;;
    esac
    echo "Unmounting $d (mounted at $mp)..."
    sudo "$UMOUNT" "$d" || sudo "$UMOUNT" -l "$d" || true
    any=1
  done
  (( any == 0 )) && echo "No partitions needed unmounting."
  return 0
}

# ---------------------------------------------------------------------------
# Partition table operations
# ---------------------------------------------------------------------------

next_free_number() {
  local -n used_ref="$1"
  local n=1
  while [[ -n "${used_ref[$n]:-}" ]]; do n=$((n + 1)); done
  printf '%s' "$n"
}

get_partnum_from_dev() {
  local r
  r="$(split_dev_name "$(basename "$1")")"
  printf '%s' "${r##*|}"
}

# existing partition -> start|end (sectors)
get_part_range() {
  local p="$1" s sz
  s="$("$LSBLK" -bno START "$p" 2>/dev/null | head -n1 | tr -d ' ')"
  sz="$(sudo "$BLOCKDEV" --getsz "$p" 2>/dev/null | tr -d ' ')"
  [[ "$s" =~ ^[0-9]+$ && "$sz" =~ ^[0-9]+$ && "$sz" -gt 0 ]] || return 1
  printf '%s|%s' "$s" "$((s + sz - 1))"
}

ranges_overlap() {
  local s1="$1" e1="$2" s2="$3" e2="$4"
  (( e1 >= s2 && e2 >= s1 ))
}

get_range_sgdisk() {
  local disk="$1" num="$2" out s e
  out="$(sudo "$SGDISK" -i "$num" "$disk" 2>/dev/null || true)"
  s="$("$AWK" -F': ' '/^First sector:/ {print $2; exit}' <<<"$out" | "$AWK" '{print $1}')"
  e="$("$AWK" -F': ' '/^Last sector:/ {print $2; exit}' <<<"$out" | "$AWK" '{print $1}')"
  s="$(trim_s "$s")"; e="$(trim_s "$e")"
  [[ "$s" =~ ^[0-9]+$ && "$e" =~ ^[0-9]+$ ]] && { printf '%s|%s' "$s" "$e"; return 0; }
  return 1
}

get_live_range() {
  local disk="$1" num="$2" r
  r="$(get_range_sgdisk "$disk" "$num" 2>/dev/null || true)"
  [[ -n "$r" ]] && { printf '%s' "$r"; return 0; }
  return 1
}

wait_for_partition() {
  local dev="$1" i
  for i in $(seq 1 20); do
    [[ -b "$dev" ]] && return 0
    sleep 1
  done
  return 1
}

show_nums() {
  local -n arr_ref="$1"
  local nums=() k
  for k in "${!arr_ref[@]}"; do nums+=("$k"); done
  (( ${#nums[@]} > 0 )) || return 0
  echo "Numbers already in use: $(printf '%s\n' "${nums[@]}" | "$SORT" -n | tr '\n' ' ')"
}

create_partition_with_type() {
  local num="$1" start="$2" end="$3" type_code="$4" disk="$5" name="${6:-}"
  if sudo "$SGDISK" -n "${num}:${start}:${end}" -t "${num}:${type_code}" "$disk" >/dev/null; then
    [[ -n "$name" ]] && sudo "$SGDISK" -c "${num}:${name}" "$disk" >/dev/null 2>&1 || true
    return 0
  fi
  sudo "$SGDISK" -n "${num}:${start}:${end}" "$disk" >/dev/null
  sudo "$PARTPROBE" "$disk" >/dev/null 2>&1 || true
  sudo "$BLOCKDEV" --rereadpt "$disk" >/dev/null 2>&1 || true
  sleep 1
  local attempt
  for attempt in 1 2 3 4 5; do
    if sudo "$SGDISK" -t "${num}:${type_code}" "$disk" >/dev/null 2>&1; then
      [[ -n "$name" ]] && sudo "$SGDISK" -c "${num}:${name}" "$disk" >/dev/null 2>&1 || true
      return 0
    fi
    sleep 1
  done
  return 1
}

# ---------------------------------------------------------------------------
# Post-restore: grow, fsck, label
# ---------------------------------------------------------------------------

grow_fs() {
  local dev="$1"
  command -v resize2fs >/dev/null 2>&1 || { echo "resize2fs not found; cannot grow $dev"; return 1; }
  echo "Growing filesystem on $dev to fill the partition..."
  sudo e2fsck -f -y "$dev" >/dev/null 2>&1 || sudo e2fsck -y "$dev" >/dev/null 2>&1 || true
  sudo resize2fs "$dev" >/dev/null 2>&1 || { echo "WARNING: resize2fs grow failed for $dev"; return 1; }
}

fsck_partition() {
  local part="$1" fs="$2" rc
  rc=0
  case "$fs" in
    ext2|ext3|ext4)
      command -v e2fsck >/dev/null 2>&1 || { echo "e2fsck not installed; skipping $part."; return 0; }
      echo "Checking $part (e2fsck -f -y)..."
      sudo e2fsck -f -y "$part" || rc=$?
      if (( rc >= 4 )); then
        echo "WARNING: e2fsck could not fully repair $part (exit $rc)."
        return 1
      fi
      (( rc > 0 )) && echo "Note: e2fsck corrected errors on $part."
      ;;
    vfat)
      if command -v fsck.fat >/dev/null 2>&1; then
        echo "Checking $part (fsck.fat -a)..."
        sudo fsck.fat -a "$part" || rc=$?
        if (( rc >= 2 )); then
          echo "WARNING: fsck.fat could not fully repair $part (exit $rc)."
          return 1
        fi
        (( rc > 0 )) && echo "Note: fsck.fat corrected errors on $part."
      else
        echo "fsck.fat not installed; skipping check on $part."
      fi
      ;;
    *)
      : ;;
  esac
  return 0
}

# Set a filesystem label if the tool for that fs exists.
set_fs_label() {
  local dev="$1" fs="$2" label="$3"
  case "$fs" in
    ext2|ext3|ext4)
      command -v e2label >/dev/null 2>&1 && sudo e2label "$dev" "$label" && return 0 ;;
    vfat)
      command -v fatlabel >/dev/null 2>&1 && sudo fatlabel "$dev" "${label^^}" && return 0 ;;
    ntfs)
      command -v ntfslabel >/dev/null 2>&1 && sudo ntfslabel "$dev" "$label" >/dev/null && return 0 ;;
  esac
  return 1
}

# ---------------------------------------------------------------------------
# Restore engines
# ---------------------------------------------------------------------------

# restore_one <imgpath> <base> <target_dev> <fs> <engine>
# engine: partclone | dd
# swap images are raw copies (partclone.dd is a raw copier) -> dd write.
restore_one() {
  local imgpath="$1" base="$2" target_dev="$3" fs="$4" engine="$5"
  local log="$WORKDIR/${base}.log" tool
  local -a st=()

  if [[ "$engine" == "dd" || "$fs" == "swap" ]]; then
    set +e
    decompress_stream "$imgpath" | sudo "$DD" of="$target_dev" bs=4M conv=fsync status=none 2>"$log"
    st=("${PIPESTATUS[@]}")
    set -e
    if (( st[0] == 0 && st[1] == 0 )); then
      echo "✓ Restored $base -> $target_dev"
      return 0
    fi
  else
    tool="$(restore_tool_for_fs "$fs")"
    [[ -n "$tool" ]] || { echo "✗ No restore tool for filesystem '$fs' ($base)"; return 1; }
    command -v "$tool" >/dev/null 2>&1 || { echo "✗ $tool not installed ($base)"; return 1; }
    set +e
    decompress_stream "$imgpath" | sudo "$tool" -r -s - -o "$target_dev" -L "$log"
    st=("${PIPESTATUS[@]}")
    set -e
    if (( st[0] == 0 && st[1] == 0 )); then
      echo "✓ Restored $base -> $target_dev"
      return 0
    fi
  fi

  echo "✗ Restore failed for $base"
  tail -n 40 "$log" 2>/dev/null || true
  return 1
}

# ---------------------------------------------------------------------------
# Image discovery & parsing
# ---------------------------------------------------------------------------

find_images() {
  "$FIND" "$SRC_DIR" -maxdepth 1 -type f \( \
    -name "*-ptcl-img" -o -name "*-ptcl-img.zst" -o -name "*-ptcl-img.gz" -o -name "*-ptcl-img.xz" -o -name "*-ptcl-img.bz2" \
    -o -name "*.dd-img" -o -name "*.dd-img.zst" -o -name "*.dd-img.gz" -o -name "*.dd-img.xz" -o -name "*.dd-img.bz2" \
  \) | "$SORT"
}

mapfile -t ALL_FILES < <(find_images)

# Parsed metadata per image, aligned with IMAGES
IMAGES=()        # path
IMG_BASE=()      # basename
IMG_SRCNAME=()   # original device name (sdc2, nvme0n1p2, or disk for full-disk)
IMG_DISKBASE=()  # original disk (sdc, nvme0n1)
IMG_PNUM=()      # partition number ("" for full-disk images)
IMG_ENGINE=()    # partclone | dd
IMG_NAMEFS=()    # fs parsed from the filename ("" for dd images)

for f in "${ALL_FILES[@]}"; do
  base="$(basename "$f")"
  stripped="$(strip_comp_ext "$base")"
  devpart="${stripped%%.*}"
  engine="partclone"
  namefs=""
  fulldisk=0
  case "$stripped" in
    *.dd-ptcl-img)
      # Only ever produced for full-disk partclone.dd backups.
      engine="partclone"
      fulldisk=1
      ;;
    *.dd-img)
      engine="dd"
      # <disk>.dd-img (full disk) vs <partition>.dd-img: the backup always
      # writes the source disk name into 'disk' and '<disk>.info'.
      if [[ -f "$SRC_DIR/disk" && "$(tr -d ' \n' < "$SRC_DIR/disk")" == "$devpart" ]]; then
        fulldisk=1
      elif [[ -f "$SRC_DIR/${devpart}.info" ]]; then
        fulldisk=1
      fi
      ;;
    *-ptcl-img)
      tmp="${stripped%-ptcl-img}"
      namefs="${tmp##*.}"
      ;;
  esac
  if (( fulldisk == 1 )); then
    db="$devpart"
    pn=""
  else
    sp="$(split_dev_name "$devpart")"
    db="${sp%%|*}"
    pn="${sp##*|}"
  fi
  IMAGES+=("$f")
  IMG_BASE+=("$base")
  IMG_SRCNAME+=("$devpart")
  IMG_DISKBASE+=("$db")
  IMG_PNUM+=("$pn")
  IMG_ENGINE+=("$engine")
  IMG_NAMEFS+=("$namefs")
done

(( ${#IMAGES[@]} > 0 )) || { echo "No backup images found in $SRC_DIR"; exit 0; }

PART_INDICES=()
FULL_INDICES=()
for i in "${!IMAGES[@]}"; do
  if [[ -n "${IMG_PNUM[$i]}" ]]; then PART_INDICES+=("$i"); else FULL_INDICES+=("$i"); fi
done

echo "Found ${#IMAGES[@]} backup image(s):"
for i in "${!IMAGES[@]}"; do
  kind="partition"
  [[ -z "${IMG_PNUM[$i]}" ]] && kind="FULL DISK"
  printf "[%d] %s (%s) [%s, %s]\n" "$((i+1))" "${IMG_BASE[$i]}" \
    "$(human_size "$(stat -c %s "${IMAGES[$i]}")")" "$kind" "${IMG_ENGINE[$i]}"
done
if (( ${#PART_INDICES[@]} > 0 )); then
  echo "[a] All partition images (full disk)"
fi

echo
while true; do
  read -r -p "Select images to restore (space separated list numbers, or a/all): " choice
  SELECTED=()
  if [[ "${choice,,}" == "a" || "${choice,,}" == "all" ]]; then
    if (( ${#PART_INDICES[@]} == 0 )); then
      ub_invalid "There are no partition images here; choose a list number instead."
      continue
    fi
    for i in "${PART_INDICES[@]}"; do SELECTED+=("$i"); done
    break
  fi
  bad=""
  for idx in $choice; do
    if [[ "$idx" =~ ^[0-9]+$ ]] && (( idx >= 1 && idx <= ${#IMAGES[@]} )); then
      SELECTED+=("$((idx - 1))")
    else
      bad+="$idx "
    fi
  done
  if [[ -n "$bad" ]]; then
    ub_invalid "Not valid: ${bad% }. Enter list numbers between 1 and ${#IMAGES[@]}, separated by spaces, or a for all."
    continue
  fi
  (( ${#SELECTED[@]} > 0 )) && break
  ub_invalid "Nothing entered. Enter list numbers between 1 and ${#IMAGES[@]}, or a for all."
done

# Full-disk images must be restored alone.
FULLDISK_SEL=""
for i in "${SELECTED[@]}"; do
  if [[ -z "${IMG_PNUM[$i]}" ]]; then
    FULLDISK_SEL="$i"
  fi
done
if [[ -n "$FULLDISK_SEL" ]] && (( ${#SELECTED[@]} > 1 )); then
  echo "ERROR: a full-disk image must be restored on its own (it overwrites the entire disk)."
  exit 1
fi

# ---------------------------------------------------------------------------
# Target disk selection
# ---------------------------------------------------------------------------

mapfile -t DISKS < <("$LSBLK" -pnr -o NAME,TYPE | "$AWK" '$2=="disk"{print $1}')
(( ${#DISKS[@]} > 0 )) || { echo "No physical disks found."; exit 1; }

echo
echo "Which available physical disk to restore to?:"
for i in "${!DISKS[@]}"; do
  d="${DISKS[$i]}"
  model="$(clean_model "$("$LSBLK" -dnro MODEL "$d" 2>/dev/null || true)")"
  printf "[%d] %s (%s) %s\n" "$((i+1))" "$d" "$("$LSBLK" -dnro SIZE "$d")" "${model:-}"
done

ask_num didx "Choose physical disk index: " 1 "${#DISKS[@]}"
TARGET_DISK="${DISKS[$((didx - 1))]}"
TARGET_DISKBASE="$(basename "$TARGET_DISK")"

echo
echo "Selected device:"
echo "DISK: $TARGET_DISK"
model="$(clean_model "$("$LSBLK" -dnro MODEL -- "$TARGET_DISK" 2>/dev/null || true)")"
size_raw="$("$LSBLK" -dnrbno SIZE -- "$TARGET_DISK" 2>/dev/null | head -n1 | tr -d ' ' || true)"
size_raw="${size_raw:-0}"
if [[ "$size_raw" -gt 0 ]]; then
  human="$("$AWK" "BEGIN {printf \"%.1fG\", $size_raw/1024/1024/1024}")"
else
  human="Unknown"
fi
echo "MODEL: $model"
echo "SIZE: $human"
"$LSBLK" -- "$TARGET_DISK"

while true; do
  read -r -p "Is this the correct disk to restore to? (y/N): " confirm_raw
  case "${confirm_raw,,}" in
    y|yes) break ;;
    n|no|"") echo "Cancelled."; exit 0 ;;
    *) echo "Please answer y or yes, or n or no." ;;
  esac
done

# ---------------------------------------------------------------------------
# FULL-DISK image restore path
# ---------------------------------------------------------------------------

if [[ -n "$FULLDISK_SEL" ]]; then
  i="$FULLDISK_SEL"
  imgpath="${IMAGES[$i]}"
  base="${IMG_BASE[$i]}"
  engine="${IMG_ENGINE[$i]}"

  disk_is_live "$TARGET_DISK" && abort_live "$TARGET_DISK"

  echo
  echo "FULL-DISK RESTORE: $base -> $TARGET_DISK"
  echo "This overwrites EVERYTHING on $TARGET_DISK, including the partition table."
  echo "The target disk must be at least as large as the original source disk."
  read -r -p "Proceed? Type ERASE to continue: " go
  [[ "$go" == "ERASE" ]] || { echo "Cancelled."; exit 0; }

  mapfile -t TP < <("$LSBLK" -pnr -o NAME,TYPE "$TARGET_DISK" | "$AWK" '$2=="part"{print $1}')
  unmount_devs TP

  echo
  echo "Restoring full-disk image..."
  log="$WORKDIR/${base}.log"
  set +e
  if [[ "$engine" == "dd" ]]; then
    decompress_stream "$imgpath" | sudo "$DD" of="$TARGET_DISK" bs=4M conv=fsync status=none 2>"$log"
  else
    decompress_stream "$imgpath" | sudo partclone.dd -s - -o "$TARGET_DISK" -L "$log"
  fi
  st=("${PIPESTATUS[@]}")
  set -e
  if (( st[0] != 0 || st[1] != 0 )); then
    echo "✗ Full-disk restore failed."
    tail -n 40 "$log" 2>/dev/null || true
    exit 1
  fi

  sync
  sudo "$PARTPROBE" "$TARGET_DISK" >/dev/null 2>&1 || true
  sudo "$BLOCKDEV" --rereadpt "$TARGET_DISK" >/dev/null 2>&1 || true
  echo "✓ Full-disk restore complete."
  echo "All done."
  exit 0
fi

# ---------------------------------------------------------------------------
# PARTITION restore path
# ---------------------------------------------------------------------------

echo
echo "Restore mode:"
echo "1) Restore to original positions"
echo "2) Add restore partition(s) to next free space on the disk"
ask_num RESTORE_MODE "Choose restore mode: " 1 2
case "$RESTORE_MODE" in
  1) RESTORE_MODE="original" ;;
  2) RESTORE_MODE="append" ;;
esac

# --- Shrunken-backup handling -----------------------------------------------
# shrunk-parts lists the source partitions that were shrunk before imaging.
declare -A SHRUNK_SET=()
if [[ -f "$SRC_DIR/shrunk-parts" ]]; then
  while IFS= read -r sp; do
    [[ -n "$sp" ]] && SHRUNK_SET["$sp"]=1
  done < "$SRC_DIR/shrunk-parts"
fi

SHRINK_RESTORE="original"
SHRUNK_SELECTED=0
for i in "${SELECTED[@]}"; do
  if [[ -n "${SHRUNK_SET[${IMG_SRCNAME[$i]}]:-}" ]] && \
     [[ -f "$SRC_DIR/${IMG_DISKBASE[$i]}-pt.parted.compact.shrunken" ]]; then
    SHRUNK_SELECTED=1
  fi
done

if (( SHRUNK_SELECTED == 1 )); then
  echo
  echo "This backup contains ext4 partition(s) that were shrunk before imaging."
  echo "Do you want to restore them to the original size or keep them shrunken?"
  echo "1) Restore partition to original size"
  echo "2) Restore partition to smaller shrunken size (no blank space)"
  ask_num sc "Choice: " 1 2
  case "$sc" in
    1) SHRINK_RESTORE="original" ;;
    2) SHRINK_RESTORE="shrunken" ;;
  esac
fi

META_SUFFIX=""
[[ "$SHRINK_RESTORE" == "shrunken" ]] && META_SUFFIX=".shrunken"

# Pad added after the data on shrunken restores: 5% of the partition,
# minimum 64 MiB (131072 sectors). The fs is grown into the pad afterwards
# so the restored partition never ends up 100% full.
shrunken_pad() {
  local size="$1" pad
  pad=$(( size / 20 ))
  (( pad < 131072 )) && pad=131072
  printf '%s' "$pad"
}

# --- Build restore plan ------------------------------------------------------

PLAN_IMG=(); PLAN_IMGPATH=(); PLAN_FS=(); PLAN_NAME=(); PLAN_NUM=()
PLAN_SIZE=(); PLAN_START=(); PLAN_END=(); PLAN_TYPE=(); PLAN_ENGINE=()
PLAN_SRCNAME=(); PLAN_GROW=()

for i in "${SELECTED[@]}"; do
  img="${IMAGES[$i]}"
  base="${IMG_BASE[$i]}"
  diskbase="${IMG_DISKBASE[$i]}"
  pnum="${IMG_PNUM[$i]}"
  engine="${IMG_ENGINE[$i]}"
  srcname="${IMG_SRCNAME[$i]}"

  start_sector=""; size_sector=""; fs="${IMG_NAMEFS[$i]}"; name=""; type_guid=""

  line="$(get_compact_meta "$diskbase" "$pnum" "$META_SUFFIX" 2>/dev/null || true)"
  if [[ -z "$line" && -n "$META_SUFFIX" ]]; then
    # shrunken metadata missing for this one -> fall back to original layout
    line="$(get_compact_meta "$diskbase" "$pnum" "" 2>/dev/null || true)"
  fi
  if [[ -n "$line" ]]; then
    IFS='|' read -r _num start_sector end_sector size_sector cfs name <<<"$line"
    [[ -n "$cfs" ]] && fs="$cfs"
    [[ "$start_sector" =~ ^[0-9]+$ && "$size_sector" =~ ^[0-9]+$ ]] || { start_sector=""; size_sector=""; }
  fi

  if [[ -z "$size_sector" ]]; then
    sf="$(get_sf_meta "$diskbase" "$pnum" "$META_SUFFIX" 2>/dev/null || get_sf_meta "$diskbase" "$pnum" "" 2>/dev/null || true)"
    if [[ -n "$sf" ]]; then
      IFS='|' read -r start_sector size_sector type_guid <<<"$sf"
    fi
  else
    sf="$(get_sf_meta "$diskbase" "$pnum" "$META_SUFFIX" 2>/dev/null || get_sf_meta "$diskbase" "$pnum" "" 2>/dev/null || true)"
    [[ -n "$sf" ]] && type_guid="${sf##*|}"
  fi

  if [[ -z "$size_sector" && "$engine" == "partclone" ]]; then
    pmeta="$(get_img_meta_from_partclone "$img")"
    pfs="${pmeta%%|*}"
    bytes="${pmeta##*|}"
    [[ -n "$pfs" && -z "$fs" ]] && fs="$pfs"
    if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then
      size_sector=$(( (bytes + 511) / 512 ))
    fi
  fi

  [[ "$start_sector" =~ ^[0-9]+$ ]] || start_sector=0
  [[ "$size_sector" =~ ^[0-9]+$ && "$size_sector" -gt 0 ]] || { echo "Unable to determine size for $base"; exit 1; }

  fs="$(norm_fs_name "${fs:-}")"

  grow=0
  if [[ -n "${SHRUNK_SET[$srcname]:-}" && "$fs" =~ ^ext[234]$ ]]; then
    if [[ "$SHRINK_RESTORE" == "shrunken" ]]; then
      padded=$(( size_sector + $(shrunken_pad "$size_sector") ))
      # CAP at the ORIGINAL partition size: the original layout is
      # non-overlapping by construction, so the pad must never push a
      # partition past its original boundary into the next one. If the fs
      # barely shrank (nearly full), this degrades to the original size.
      orig_size=""
      oline="$(get_compact_meta "$diskbase" "$pnum" "" 2>/dev/null || true)"
      if [[ -n "$oline" ]]; then
        IFS='|' read -r _onum _ostart _oend orig_size _ofs _oname <<<"$oline"
      fi
      if [[ ! "$orig_size" =~ ^[0-9]+$ ]]; then
        osf="$(get_sf_meta "$diskbase" "$pnum" "" 2>/dev/null || true)"
        if [[ -n "$osf" ]]; then
          IFS='|' read -r _ostart orig_size _otype <<<"$osf"
        fi
      fi
      if [[ "$orig_size" =~ ^[0-9]+$ ]] && (( padded > orig_size )); then
        if (( orig_size >= size_sector )); then
          padded="$orig_size"
        else
          padded="$size_sector"   # metadata inconsistency: keep shrunk size
        fi
      fi
      size_sector="$padded"
    fi
    grow=1   # grow to fill in both modes (original size, or shrunken+pad)
  fi
  end_sector=$(( start_sector + size_sector - 1 ))

  PLAN_IMG+=("$base")
  PLAN_IMGPATH+=("$img")
  PLAN_FS+=("$fs")
  PLAN_NAME+=("$name")
  PLAN_NUM+=("$pnum")
  PLAN_SIZE+=("$size_sector")
  PLAN_START+=("$start_sector")
  PLAN_END+=("$end_sector")
  PLAN_TYPE+=("$(type_to_sgdisk "$type_guid" "$fs")")
  PLAN_ENGINE+=("$engine")
  PLAN_SRCNAME+=("$srcname")
  PLAN_GROW+=("$grow")
done

TOTAL_SIZE=0
for s in "${PLAN_SIZE[@]}"; do TOTAL_SIZE=$((TOTAL_SIZE + s)); done

echo
echo "Restore plan:"
for i in "${!PLAN_IMG[@]}"; do
  if [[ "$RESTORE_MODE" == "original" ]]; then
    dev="$(target_part_path "$TARGET_DISK" "${PLAN_NUM[$i]}")"
    printf "%s: start:%s end:%s (%s) fs:%s engine:%s target:%s\n" \
      "${PLAN_IMG[$i]}" "${PLAN_START[$i]}" "${PLAN_END[$i]}" \
      "$(human_size $(( PLAN_SIZE[i] * 512 )))" "${PLAN_FS[$i]:-?}" "${PLAN_ENGINE[$i]}" "$dev"
  else
    printf "%s: size:%s fs:%s engine:%s (position assigned from free space)\n" \
      "${PLAN_IMG[$i]}" "$(human_size $(( PLAN_SIZE[i] * 512 )))" "${PLAN_FS[$i]:-?}" "${PLAN_ENGINE[$i]}"
  fi
done

# --- Target partition table guard -------------------------------------------
TARGET_PTTYPE="$(disk_pttype "$TARGET_DISK")"

# --- fsck-after prompt (asked before the restore starts) ---------------------
CHECK_AFTER=0
maybe_offer_fsck() {
  local i has=0
  for i in "${!PLAN_FS[@]}"; do
    case "${PLAN_FS[$i]}" in ext2|ext3|ext4|vfat) has=1 ;; esac
  done
  (( has == 1 )) || return 0
  echo
  echo "Check and repair the restored filesystem(s) after restore (fsck)?"
  echo "1) Skip checking"
  echo "2) Check and repair"
  ask_num fc "Choice: " 1 2
  case "$fc" in
    1) CHECK_AFTER=0 ;;
    2) CHECK_AFTER=1 ;;
  esac
  return 0
}

# --- Post-restore steps shared by both modes ---------------------------------
post_restore_partition() {
  local i="$1" dev="$2"
  if [[ "${PLAN_GROW[$i]}" == "1" ]]; then
    grow_fs "$dev" || true
  fi
  if (( CHECK_AFTER == 1 )); then
    fsck_partition "$dev" "${PLAN_FS[$i]}" || true
  fi
}

offer_labels() {
  local -n devs_ref="$1"
  local i dev fs lab unl_devs=() unl_fs=()
  for i in "${!devs_ref[@]}"; do
    dev="${devs_ref[$i]}"
    fs="${PLAN_FS[$i]}"
    case "$fs" in ext2|ext3|ext4|vfat|ntfs) : ;; *) continue ;; esac
    lab="$("$LSBLK" -dnro LABEL "$dev" 2>/dev/null | tr -d ' ' || true)"
    if [[ -z "$lab" ]]; then
      unl_devs+=("$dev")
      unl_fs+=("$fs")
    fi
  done
  (( ${#unl_devs[@]} > 0 )) || return 0
  echo
  echo "Restored filesystem(s) with no label — file managers will show them as a"
  echo "long UUID. A label can be set now (note: this modifies the restored"
  echo "filesystem so it will differ slightly from the backup image)."
  read -r -p "Set filesystem label to the device name (e.g. ${TARGET_DISKBASE}2)? (y/N): " lc
  [[ "${lc,,}" == "y" || "${lc,,}" == "yes" ]] || return 0
  for i in "${!unl_devs[@]}"; do
    dev="${unl_devs[$i]}"
    if set_fs_label "$dev" "${unl_fs[$i]}" "$(basename "$dev")"; then
      echo "Labeled $dev as $(basename "$dev")"
    else
      echo "Could not label $dev (labeling tool for ${unl_fs[$i]} not installed?)"
    fi
  done
}

# =============================================================================
# APPEND MODE
# =============================================================================
if [[ "$RESTORE_MODE" == "append" ]]; then

  if [[ "$TARGET_PTTYPE" != "gpt" ]]; then
    echo
    echo "ERROR: target disk $TARGET_DISK uses a '$TARGET_PTTYPE' partition table."
    echo "This script creates partitions with sgdisk (GPT). Adding GPT entries to"
    echo "an MBR disk would convert the whole disk to GPT, which can break"
    echo "existing installations on it."
    echo "Workarounds: use a GPT target disk, or (if the disk may be wiped)"
    echo "restore to original positions with 'Erase full disk', which creates a"
    echo "fresh GPT table."
    exit 1
  fi

  mapfile -t EXISTING_PARTS < <("$LSBLK" -pnr -o NAME,TYPE "$TARGET_DISK" | "$AWK" '$2=="part"{print $1}')

  echo
  echo "Free spaces on $TARGET_DISK:"
  mapfile -t frees < <(
    disk_size="$("$LSBLK" -bdno SIZE "$TARGET_DISK" | head -n1 | tr -d ' ')"
    sectors=$(( disk_size / 512 ))
    usable_end=$(( sectors - 34 ))   # keep clear of the backup GPT header
    for p in "${EXISTING_PARTS[@]}"; do
      r="$(get_part_range "$p" 2>/dev/null || true)"
      [[ -n "$r" ]] && printf '%s %s\n' "${r%%|*}" "${r##*|}"
    done | "$SORT" -n -k1,1 | {
      prev_end=2047
      while read -r s e; do
        if (( s > prev_end + 1 )); then
          printf '%s %s %s\n' "$((prev_end + 1))" "$((s - 1))" "$((s - prev_end - 1))"
        fi
        (( e > prev_end )) && prev_end=$e
      done
      if (( usable_end > prev_end )); then
        printf '%s %s %s\n' "$((prev_end + 1))" "$usable_end" "$((usable_end - prev_end))"
      fi
    }
  )

  (( ${#frees[@]} > 0 )) || { echo "No free space found on the disk."; exit 1; }

  VALID_FREE=()
  for i in "${!frees[@]}"; do
    read -r s e sz <<<"${frees[$i]}"
    mark="[TOO SMALL]"
    if [[ "$sz" =~ ^[0-9]+$ ]] && (( sz >= TOTAL_SIZE )); then
      mark="[OK]"
      VALID_FREE+=("$((i+1))")
    fi
    printf "[%d] start:%s end:%s size:%s sectors (%s) %s\n" \
      "$((i+1))" "$s" "$e" "$sz" "$(human_size $((sz * 512)))" "$mark"
  done
  (( ${#VALID_FREE[@]} > 0 )) || { echo "No free space large enough (need $(human_size $((TOTAL_SIZE * 512))))."; exit 1; }

  while true; do
    read -r -p "Choose which free space to use (list number): " free_idx
    ok=0
    for x in "${VALID_FREE[@]}"; do [[ "$x" == "$free_idx" ]] && ok=1; done
    (( ok == 1 )) && break
    ub_invalid "Choose one of the entries marked [OK]: ${VALID_FREE[*]}"
  done
  read -r GAP_START GAP_END GAP_SIZE <<<"${frees[$((free_idx - 1))]}"

  # Assign next free partition numbers
  declare -A USED=()
  for dev in "${EXISTING_PARTS[@]}"; do
    n="$(get_partnum_from_dev "$dev")"
    [[ -n "$n" ]] && USED["$n"]=1
  done
  PLAN_FINAL_NUM=()
  for _ in "${PLAN_IMG[@]}"; do
    n="$(next_free_number USED)"
    USED["$n"]=1
    PLAN_FINAL_NUM+=("$n")
  done

  echo
  echo "New partitions to create:"
  CUR="$GAP_START"
  for i in "${!PLAN_IMG[@]}"; do
    dev="$(target_part_path "$TARGET_DISK" "${PLAN_FINAL_NUM[$i]}")"
    printf "%s -> %s (start:%s size:%s)\n" "${PLAN_IMG[$i]}" "$dev" "$CUR" "${PLAN_SIZE[$i]}"
    CUR=$(( CUR + PLAN_SIZE[i] ))
  done

  maybe_offer_fsck

  echo
  read -r -p "Proceed with restore on $TARGET_DISK? (y/N): " go
  [[ "${go,,}" == "y" || "${go,,}" == "yes" ]] || { echo "Cancelled."; exit 0; }

  TARGET_PARTS=()
  CUR="$GAP_START"
  for i in "${!PLAN_IMG[@]}"; do
    num="${PLAN_FINAL_NUM[$i]}"
    size_sector="${PLAN_SIZE[$i]}"
    start_sector="$CUR"
    (( start_sector + size_sector - 1 <= GAP_END )) || { echo "ERROR: not enough room in free space."; exit 1; }
    create_partition_with_type "$num" "$start_sector" "$((start_sector + size_sector - 1))" "${PLAN_TYPE[$i]}" "$TARGET_DISK" "${PLAN_NAME[$i]}" \
      || { echo "ERROR: failed to create partition $num"; exit 1; }
    TARGET_PARTS+=("$(target_part_path "$TARGET_DISK" "$num")")
    CUR=$(( start_sector + size_sector ))
  done

  sudo "$PARTPROBE" "$TARGET_DISK" >/dev/null 2>&1 || true
  sudo "$BLOCKDEV" --rereadpt "$TARGET_DISK" >/dev/null 2>&1 || true
  sleep 2

  echo
  echo "Created partitions:"
  for dev in "${TARGET_PARTS[@]}"; do
    wait_for_partition "$dev" || { echo "ERROR: expected $dev not found after creation" >&2; exit 1; }
    echo "$dev $("$LSBLK" -dnro SIZE "$dev")"
  done

  read -r -p "Press Enter to begin restore..." _
  for i in "${!PLAN_IMG[@]}"; do
    restore_one "${PLAN_IMGPATH[$i]}" "${PLAN_IMG[$i]}" "${TARGET_PARTS[$i]}" "${PLAN_FS[$i]}" "${PLAN_ENGINE[$i]}" || exit 1
    post_restore_partition "$i" "${TARGET_PARTS[$i]}"
  done

  offer_labels TARGET_PARTS
  echo
  echo "All done."
  exit 0
fi

# =============================================================================
# ORIGINAL-POSITION MODE
# =============================================================================

# Sanity check: the plan itself must be non-overlapping. Abort BEFORE any
# disk modification if two planned partitions collide.
for i in "${!PLAN_IMG[@]}"; do
  for j in "${!PLAN_IMG[@]}"; do
    (( j > i )) || continue
    if ranges_overlap "${PLAN_START[$i]}" "${PLAN_END[$i]}" "${PLAN_START[$j]}" "${PLAN_END[$j]}"; then
      echo
      echo "ERROR: restore plan is self-overlapping:"
      echo "  ${PLAN_IMG[$i]} (${PLAN_START[$i]}-${PLAN_END[$i]}) overlaps ${PLAN_IMG[$j]} (${PLAN_START[$j]}-${PLAN_END[$j]})"
      echo "No changes were made to the disk. This indicates inconsistent backup"
      echo "metadata; check the pt.parted.compact files in the image folder."
      exit 1
    fi
  done
done

# Live-target check (chain-aware: catches LUKS/LVM on top of a target too)
for i in "${!PLAN_IMG[@]}"; do
  dev="$(target_part_path "$TARGET_DISK" "${PLAN_NUM[$i]}")"
  if [[ -b "$dev" ]] && dev_is_live "$dev"; then
    abort_live "$dev"
  fi
done

echo
echo "Target action:"
echo "1) Erase full disk"
echo "2) Erase only overlapping partitions"
ask_num action "Choose action: " 1 2
case "$action" in
  1) ERASE_MODE="full" ;;
  2) ERASE_MODE="partial" ;;
esac

if [[ "$TARGET_PTTYPE" != "gpt" && "$ERASE_MODE" != "full" ]]; then
  echo
  echo "ERROR: target disk $TARGET_DISK uses a '$TARGET_PTTYPE' partition table."
  echo "This script creates partitions with sgdisk (GPT). Modifying an MBR disk"
  echo "in place would convert it to GPT and can break what is on it."
  echo "Workarounds: choose 'Erase full disk' (a fresh GPT table is created),"
  echo "or restore to a GPT disk."
  exit 1
fi

if [[ "$ERASE_MODE" == "full" ]] && disk_is_live "$TARGET_DISK"; then
  abort_live "$TARGET_DISK"
fi

mapfile -t EXISTING_PARTS < <("$LSBLK" -pnr -o NAME,TYPE "$TARGET_DISK" | "$AWK" '$2=="part"{print $1}')

# Determine which existing partitions overlap the plan (partial mode) and
# which are kept. Full mode erases everything.
DELETE_PARTS=()
KEEP_PARTS=()
declare -A KEEP_NUMS=()

if [[ "$ERASE_MODE" == "full" ]]; then
  DELETE_PARTS=("${EXISTING_PARTS[@]}")
else
  for p in "${EXISTING_PARTS[@]}"; do
    r="$(get_part_range "$p" 2>/dev/null || true)"
    if [[ -z "$r" ]]; then
      # Cannot read its range: be conservative, keep it.
      KEEP_PARTS+=("$p")
      n="$(get_partnum_from_dev "$p")"
      [[ -n "$n" ]] && KEEP_NUMS["$n"]=1
      continue
    fi
    ps="${r%%|*}"; pe="${r##*|}"
    hit=0
    for i in "${!PLAN_IMG[@]}"; do
      if ranges_overlap "$ps" "$pe" "${PLAN_START[$i]}" "${PLAN_END[$i]}"; then
        hit=1
        break
      fi
    done
    if (( hit == 1 )); then
      DELETE_PARTS+=("$p")
    else
      KEEP_PARTS+=("$p")
      n="$(get_partnum_from_dev "$p")"
      [[ -n "$n" ]] && KEEP_NUMS["$n"]=1
    fi
  done
fi

# Live check for everything that will be erased
for p in "${DELETE_PARTS[@]}"; do
  dev_is_live "$p" && abort_live "$p"
done

if (( ${#DELETE_PARTS[@]} > 0 )); then
  echo
  echo "Existing partition(s) that will be ERASED:"
  for p in "${DELETE_PARTS[@]}"; do
    echo "  $p ($("$LSBLK" -dnro SIZE "$p" 2>/dev/null || echo '?'))"
  done
fi
if (( ${#KEEP_PARTS[@]} > 0 )) && [[ "$ERASE_MODE" == "partial" ]]; then
  echo
  echo "Existing partition(s) that will be KEPT:"
  for p in "${KEEP_PARTS[@]}"; do
    echo "  $p ($("$LSBLK" -dnro SIZE "$p" 2>/dev/null || echo '?'))"
  done
fi

# Partition number conflicts: a kept partition already owns a plan number
declare -A REASSIGN_NEW=()
declare -A BLOCKED_FINAL=()
declare -A EXIST_RENUM_NEW=()
declare -A EXIST_RENUM_S_START=()
declare -A EXIST_RENUM_S_END=()

if [[ "$ERASE_MODE" == "partial" ]]; then
  for n in "${!KEEP_NUMS[@]}"; do BLOCKED_FINAL["$n"]=1; done

  for orig_num in "${PLAN_NUM[@]}"; do
    if [[ -n "${KEEP_NUMS[$orig_num]:-}" && -z "${REASSIGN_NEW[$orig_num]:-}" ]]; then
      echo
      echo "PARTITION NUMBER CONFLICT: $orig_num is in use by a kept partition."
      echo "Options:"
      echo "1) Change the number of the new partition to be restored"
      echo "2) Change the number of the existing old partition"
      echo "3) Abort"
      ask_num conflict_action "Choose: " 1 3
      case "$conflict_action" in
        1)
          while true; do
            show_nums BLOCKED_FINAL
            read -r -p "New number for restore partition $orig_num: " newn
            [[ "$newn" =~ ^[0-9]+$ ]] || continue
            [[ -z "${BLOCKED_FINAL[$newn]:-}" ]] || continue
            REASSIGN_NEW["$orig_num"]="$newn"
            BLOCKED_FINAL["$newn"]=1
            break
          done
          ;;
        2)
          oldn="$orig_num"
          pr="$(get_live_range "$TARGET_DISK" "$oldn" || true)"
          [[ -n "$pr" ]] || { echo "Could not read existing partition $oldn."; exit 1; }
          EXIST_RENUM_S_START["$oldn"]="${pr%%|*}"
          EXIST_RENUM_S_END["$oldn"]="${pr##*|}"
          while true; do
            show_nums BLOCKED_FINAL
            read -r -p "Move existing partition $oldn to new number: " newn
            [[ "$newn" =~ ^[0-9]+$ ]] || continue
            [[ -z "${BLOCKED_FINAL[$newn]:-}" ]] || continue
            EXIST_RENUM_NEW["$oldn"]="$newn"
            BLOCKED_FINAL["$newn"]=1
            unset 'KEEP_NUMS[$oldn]'
            KEEP_NUMS["$newn"]=1
            break
          done
          ;;
        *)
          echo "Aborted."
          exit 0
          ;;
      esac
    fi
  done
fi

echo
echo "Final restore mapping:"
for i in "${!PLAN_IMG[@]}"; do
  orig_num="${PLAN_NUM[$i]}"
  new_num="${REASSIGN_NEW[$orig_num]:-$orig_num}"
  dev="$(target_part_path "$TARGET_DISK" "$new_num")"
  printf "%s -> %s\n" "${PLAN_IMG[$i]}" "$dev"
done
if (( ${#EXIST_RENUM_NEW[@]} > 0 )); then
  echo
  echo "Existing partition renumbering:"
  for k in $("$SORT" -n <<<"$(printf '%s\n' "${!EXIST_RENUM_NEW[@]}")"); do
    printf "%s -> %s\n" "$k" "${EXIST_RENUM_NEW[$k]}"
  done
fi

maybe_offer_fsck

# Confirmation word: ERASE only when something will actually be destroyed.
if [[ "$ERASE_MODE" == "full" ]] || (( ${#DELETE_PARTS[@]} > 0 )); then
  CONFIRM_WORD="ERASE"
else
  CONFIRM_WORD="RESTORE"
fi
echo
read -r -p "Proceed with restore on $TARGET_DISK? Type $CONFIRM_WORD to continue: " go
[[ "$go" == "$CONFIRM_WORD" ]] || { echo "Cancelled."; exit 0; }

# Unmount: full erase -> everything on the disk; partial -> only what's erased.
echo
if [[ "$ERASE_MODE" == "full" ]]; then
  unmount_devs EXISTING_PARTS
else
  unmount_devs DELETE_PARTS
fi
sleep 1

# Erase
if [[ "$ERASE_MODE" == "full" ]]; then
  sudo "$SGDISK" --zap-all "$TARGET_DISK" >/dev/null
  sudo "$SGDISK" -o "$TARGET_DISK" >/dev/null
  if [[ "$TARGET_PTTYPE" != "gpt" ]]; then
    echo "Note: target disk converted to a fresh GPT partition table."
  fi
else
  for dev in "${DELETE_PARTS[@]}"; do
    num="$(get_partnum_from_dev "$dev")"
    [[ -n "$num" ]] || continue
    sudo "$SGDISK" -d "$num" "$TARGET_DISK" >/dev/null
  done
fi

sudo "$PARTPROBE" "$TARGET_DISK" >/dev/null 2>&1 || true
sudo "$BLOCKDEV" --rereadpt "$TARGET_DISK" >/dev/null 2>&1 || true
sleep 2

# Apply renumbering of kept partitions (entry moves only; data untouched)
if (( ${#EXIST_RENUM_NEW[@]} > 0 )); then
  for oldn in "${!EXIST_RENUM_NEW[@]}"; do
    newn="${EXIST_RENUM_NEW[$oldn]}"
    sstart="${EXIST_RENUM_S_START[$oldn]}"
    send="${EXIST_RENUM_S_END[$oldn]}"
    sudo "$SGDISK" -d "$oldn" "$TARGET_DISK" >/dev/null
    sudo "$SGDISK" -n "${newn}:${sstart}:${send}" -t "${newn}:8300" "$TARGET_DISK" >/dev/null
  done
  sudo "$PARTPROBE" "$TARGET_DISK" >/dev/null 2>&1 || true
  sudo "$BLOCKDEV" --rereadpt "$TARGET_DISK" >/dev/null 2>&1 || true
  sleep 2
fi

# Create restore partitions
TARGET_PARTS=()
echo
echo "Creating partitions..."
for i in "${!PLAN_IMG[@]}"; do
  orig_num="${PLAN_NUM[$i]}"
  num="${REASSIGN_NEW[$orig_num]:-$orig_num}"
  create_partition_with_type "$num" "${PLAN_START[$i]}" "${PLAN_END[$i]}" "${PLAN_TYPE[$i]}" "$TARGET_DISK" "${PLAN_NAME[$i]}" \
    || { echo "ERROR: failed to create partition $num"; exit 1; }
  TARGET_PARTS+=("$(target_part_path "$TARGET_DISK" "$num")")
done

sudo "$PARTPROBE" "$TARGET_DISK" >/dev/null 2>&1 || true
sudo "$BLOCKDEV" --rereadpt "$TARGET_DISK" >/dev/null 2>&1 || true
sleep 2

echo
echo "Created partitions:"
for dev in "${TARGET_PARTS[@]}"; do
  wait_for_partition "$dev" || { echo "ERROR: expected $dev not found after creation" >&2; exit 1; }
  echo "$dev $("$LSBLK" -dnro SIZE "$dev")"
done

read -r -p "Press Enter to begin restore..." _
for i in "${!PLAN_IMG[@]}"; do
  restore_one "${PLAN_IMGPATH[$i]}" "${PLAN_IMG[$i]}" "${TARGET_PARTS[$i]}" "${PLAN_FS[$i]}" "${PLAN_ENGINE[$i]}" || exit 1
  post_restore_partition "$i" "${TARGET_PARTS[$i]}"
done

offer_labels TARGET_PARTS
echo
echo "All done."

)

# ===========================================================================
# MODULE: MOUNT    (cz-mount.sh)
# Runs in a SUBSHELL: its helper functions, globals and EXIT trap are private
# to this module and cannot collide with the other two.
# ===========================================================================
run_mount() (
set -euo pipefail

# cz-mount.sh — browse the contents of backups made by cz-backup.sh
# (and Clonezilla-style partclone images) by mounting them read-only.
#
# Usage:
#   ./cz-mount.sh [directory] [filter]
#     directory : where the images are (default: current directory)
#     filter    : optional regex to filter filenames
#
# Handles:
#   - partclone partition images   *.<fs>-ptcl-img[.zst|.gz|.xz|.bz2]
#   - dd partition images          *.dd-img[.zst|.gz|.xz|.bz2]
#   - full-disk images             <disk>.dd-img / <disk>.dd-ptcl-img (+comp)
#   - MBR and GPT full-disk images (kernel partition scan via losetup -P)
#   - shrunken ext4 backups (they simply mount at their shrunken size)
#
# Nothing is ever written to the image files. Everything is read-only.
#
# Speed: uncompressed images are mounted DIRECTLY with no expansion at all.
# Compressed images are expanded once into a scratch dir and cached, so
# mounting the same image again is instant.

# Cross-distro tool discovery: Debian-family user PATH lacks /usr/sbin, /sbin.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

SRC_DIR="${1:-.}"
FILTER="${2:-}"

MNTROOT="${CZ_MNTROOT:-$HOME/cz-mnt}"
# Scratch holds expanded raw images. By default it is a temporary directory
# that is deleted when the script exits, so nothing is left behind.
# Set CZ_SCRATCH=/path to keep expansions between runs (faster re-mounts) or
# to place them on a disk with more room than /tmp.
SCRATCH="${CZ_SCRATCH:-}"
SCRATCH_IS_TEMP=0

FIND=""; GREP=""; SORT=""; AWK=""; SED=""; STAT=""; MOUNT=""; UMOUNT=""
LOSETUP=""; LSBLK=""; BLKID=""; SUDO=""; DD=""; PARTX=""; SFDISK=""
PV=""; NUMFMT=""; ZSTD=""; GZIP=""; XZ=""; BZIP2=""
PARTCLONE_RESTORE=""

MOUNTED_LIST=()     # mountpoints we created
LOOPS=()            # loop devices we attached
MADE_DIRS=()        # mountpoint dirs we created

pick_cmd() {
  local varname="$1"; shift
  local p found=""
  for p in "$@"; do
    found="$(command -v "$p" 2>/dev/null || true)"
    if [[ -n "$found" ]]; then
      printf -v "$varname" '%s' "$found"
      return 0
    fi
  done
  return 1
}

need_cmd() {
  local varname="$1"; shift
  pick_cmd "$varname" "$@" || { echo "Missing required command: $*" >&2; exit 1; }
}

need_cmd FIND find
need_cmd GREP grep
need_cmd SORT sort
need_cmd AWK awk
need_cmd SED sed
need_cmd STAT stat
need_cmd MOUNT mount
need_cmd UMOUNT umount
need_cmd LOSETUP losetup
need_cmd LSBLK lsblk
need_cmd SUDO sudo
need_cmd DD dd

# Optional — checked per image, never a hard requirement.
pick_cmd PARTX partx || true
pick_cmd SFDISK sfdisk || true
pick_cmd BLKID blkid || true
pick_cmd PV pv || true
pick_cmd NUMFMT numfmt || true
pick_cmd ZSTD zstd || true
pick_cmd GZIP gzip || true
pick_cmd XZ xz || true
pick_cmd BZIP2 bzip2 || true
# partclone.restore reads the image header and restores any filesystem, so
# the fs does not have to be known in advance.
pick_cmd PARTCLONE_RESTORE partclone.restore || true

# Faster parallel decompressors when present.
PIGZ=""; PBZIP2=""
pick_cmd PIGZ pigz || true
pick_cmd PBZIP2 lbzip2 pbzip2 || true

# zstd ultra levels (20-22) need a large window at decompression time.
ZSTD_DFLAGS=""
if [[ -n "$ZSTD" ]] && "$ZSTD" --help 2>&1 | "$GREP" -q -- '--long'; then
  ZSTD_DFLAGS="--long=31"
fi

# Ask for sudo up front, like cz-backup.sh / cz-restore.sh, and keep the
# timestamp alive so a long expansion cannot trigger a prompt mid-run.
sudo -v
( while true; do sudo -n true; sleep 60; done ) >/dev/null 2>&1 &
SUDO_KEEPALIVE_PID=$!

# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------

unmount_all() {
  local m i
  # Unmount in reverse order (deepest / most recent first)
  for (( i=${#MOUNTED_LIST[@]}-1 ; i>=0 ; i-- )); do
    m="${MOUNTED_LIST[$i]}"
    if "$GREP" -q " ${m} " /proc/mounts 2>/dev/null; then
      sudo "$UMOUNT" "$m" 2>/dev/null || sudo "$UMOUNT" -l "$m" 2>/dev/null || true
    fi
  done
  for (( i=${#LOOPS[@]}-1 ; i>=0 ; i-- )); do
    sudo "$LOSETUP" -d "${LOOPS[$i]}" 2>/dev/null || true
  done
  for (( i=${#MADE_DIRS[@]}-1 ; i>=0 ; i-- )); do
    rmdir "${MADE_DIRS[$i]}" 2>/dev/null || true
  done
  rmdir "$MNTROOT" 2>/dev/null || true
}

cleanup_exit() {
  local rc=$?
  if (( ${#MOUNTED_LIST[@]} > 0 || ${#LOOPS[@]} > 0 )); then
    echo
    echo "Cleaning up mounts..."
    unmount_all
  fi
  # Remove expanded images unless the user chose a persistent scratch dir.
  if (( SCRATCH_IS_TEMP == 1 )) && [[ -n "$SCRATCH" && -d "$SCRATCH" ]]; then
    rm -rf "$SCRATCH" >/dev/null 2>&1 || true
  fi
  kill "${SUDO_KEEPALIVE_PID:-0}" >/dev/null 2>&1 || true
  exit "$rc"
}
trap cleanup_exit EXIT
trap 'echo; echo "Interrupted."; exit 130' INT TERM

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

human_size() {
  local bytes="$1"
  if [[ -n "$NUMFMT" ]]; then
    "$NUMFMT" --to=iec --suffix=B --format='%.1f' "$bytes" 2>/dev/null || echo "${bytes}B"
  else
    "$AWK" -v b="$bytes" 'BEGIN{split("B KiB MiB GiB TiB PiB",s," ");n=1;
      while (b>=1024 && n<6){b/=1024;n++} printf "%.1f%s", b, s[n]}'
  fi
}

strip_comp_ext() {
  local n="$1"
  n="${n%.zst}"; n="${n%.gz}"; n="${n%.xz}"; n="${n%.bz2}"
  printf '%s' "$n"
}

is_compressed() {
  case "$1" in
    *.zst|*.gz|*.xz|*.bz2) return 0 ;;
    *) return 1 ;;
  esac
}

# Decompressor for a file, or 'cat'. Verifies the tool exists.
decompress_cmd() {
  case "$1" in
    *.zst) [[ -n "$ZSTD" ]] || { echo "zstd not installed (needed for $1)" >&2; return 1; }
           printf '%s -dc %s' "$ZSTD" "$ZSTD_DFLAGS" ;;
    *.gz)  if [[ -n "$PIGZ" ]]; then printf '%s -dc' "$PIGZ"
           elif [[ -n "$GZIP" ]]; then printf '%s -dc' "$GZIP"
           else echo "gzip not installed (needed for $1)" >&2; return 1; fi ;;
    *.xz)  [[ -n "$XZ" ]] || { echo "xz not installed (needed for $1)" >&2; return 1; }
           printf '%s -dc -T0' "$XZ" ;;
    *.bz2) if [[ -n "$PBZIP2" ]]; then printf '%s -dc' "$PBZIP2"
           elif [[ -n "$BZIP2" ]]; then printf '%s -dc' "$BZIP2"
           else echo "bzip2 not installed (needed for $1)" >&2; return 1; fi ;;
    *)     printf 'cat' ;;
  esac
}

# Split a device name into diskbase|partnum (sdc1, nvme0n1p2, mmcblk0p1, ...)
split_dev_name() {
  local dev="$1"
  if [[ "$dev" =~ ^(.+[0-9])p([0-9]+)$ ]]; then
    printf '%s|%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
  elif [[ "$dev" =~ ^(.*[^0-9])([0-9]+)$ ]]; then
    printf '%s|%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
  else
    printf '%s|' "$dev"
  fi
}

fs_of() {
  local target="$1" t=""
  if [[ -n "$BLKID" ]]; then
    t="$(sudo "$BLKID" -o value -s TYPE "$target" 2>/dev/null || true)"
  fi
  if [[ -z "$t" ]]; then
    t="$("$LSBLK" -dnro FSTYPE "$target" 2>/dev/null | head -n1 | tr -d ' ' || true)"
  fi
  printf '%s' "${t,,}"
}

label_of() {
  local target="$1" l=""
  if [[ -n "$BLKID" ]]; then
    l="$(sudo "$BLKID" -o value -s LABEL "$target" 2>/dev/null || true)"
  fi
  printf '%s' "$l"
}

is_tmpfs() {
  local d="$1" t
  t="$(df -P -T "$d" 2>/dev/null | "$AWK" 'NR==2{print $2}')"
  [[ "$t" == "tmpfs" || "$t" == "ramfs" ]]
}

avail_bytes() {
  df -PB1 "$1" 2>/dev/null | "$AWK" 'NR==2{print $4}'
}

spinner() {
  local pid="$1" msg="$2" prefix="${3:-}"
  local spin='|/-\' i=0
  while kill -0 "$pid" 2>/dev/null; do
    printf '\r[%c] %s%s' "${spin:i++%${#spin}:1}" "$prefix" "$msg"
    sleep 0.12
  done
  printf '\r%*s\r' 80 ""
}

unique_mnt() {
  local want="$1" n=2 cand="$1"
  while [[ -e "$MNTROOT/$cand" ]]; do
    cand="${want}_$n"
    n=$((n + 1))
  done
  printf '%s' "$cand"
}

# ---------------------------------------------------------------------------
# Image discovery and classification
# ---------------------------------------------------------------------------

find_images() {
  "$FIND" "$SRC_DIR" -type f \( \
    -name "*-ptcl-img" -o -name "*-ptcl-img.zst" -o -name "*-ptcl-img.gz" -o -name "*-ptcl-img.xz" -o -name "*-ptcl-img.bz2" \
    -o -name "*.dd-img" -o -name "*.dd-img.zst" -o -name "*.dd-img.gz" -o -name "*.dd-img.xz" -o -name "*.dd-img.bz2" \
  \) 2>/dev/null | "$SORT" | {
    if [[ -n "$FILTER" ]]; then "$GREP" -E "$FILTER" || true; else cat; fi
  }
}

mapfile -t ALL_FILES < <(find_images)
(( ${#ALL_FILES[@]} > 0 )) || {
  echo "No backup images found in: $SRC_DIR"
  echo "Looking for: *-ptcl-img[.zst|.gz|.xz|.bz2] and *.dd-img[.zst|.gz|.xz|.bz2]"
  exit 0
}

IMAGES=(); IMG_BASE=(); IMG_NAME=(); IMG_ENGINE=(); IMG_FULLDISK=(); IMG_NAMEFS=()

for f in "${ALL_FILES[@]}"; do
  base="$(basename "$f")"
  dir="$(dirname "$f")"
  stripped="$(strip_comp_ext "$base")"
  devpart="${stripped%%.*}"
  engine="partclone"; namefs=""; fulldisk=0

  case "$stripped" in
    *.dd-ptcl-img)
      # Produced by "partclone.dd -O -", which emits a RAW stream despite
      # the name. Treated as a raw full-disk image.
      engine="raw"
      fulldisk=1
      ;;
    *.dd-img)
      engine="raw"
      # <disk>.dd-img (full disk) vs <part>.dd-img: cz-backup records the
      # source disk name in 'disk' and creates '<disk>.info'.
      if [[ -f "$dir/disk" && "$(tr -d ' \n' < "$dir/disk")" == "$devpart" ]]; then
        fulldisk=1
      elif [[ -f "$dir/${devpart}.info" ]]; then
        fulldisk=1
      fi
      ;;
    *-ptcl-img)
      tmp="${stripped%-ptcl-img}"
      namefs="${tmp##*.}"
      ;;
  esac

  IMAGES+=("$f")
  IMG_BASE+=("$base")
  IMG_NAME+=("$devpart")
  IMG_ENGINE+=("$engine")
  IMG_FULLDISK+=("$fulldisk")
  IMG_NAMEFS+=("$namefs")
done

echo "Found ${#IMAGES[@]} image(s):"
PART_INDICES=()
for i in "${!IMAGES[@]}"; do
  kind="partition"
  if [[ "${IMG_FULLDISK[$i]}" == "1" ]]; then kind="FULL DISK"; else PART_INDICES+=("$i"); fi
  extra="${IMG_NAMEFS[$i]}"
  [[ -z "$extra" ]] && extra="${IMG_ENGINE[$i]}"
  printf "[%d] %s (%s) [%s, %s]\n" "$((i+1))" "${IMG_BASE[$i]}" \
    "$(human_size "$("$STAT" -c %s "${IMAGES[$i]}")")" "$kind" "$extra"
done
(( ${#PART_INDICES[@]} > 0 )) && echo "[a] All partition images (full disk)"

echo
while true; do
  read -r -p "Enter assigned list number for image [x] (space separated, or a/all): " choice
  SELECTED=()
  if [[ "${choice,,}" == "a" || "${choice,,}" == "all" ]]; then
    if (( ${#PART_INDICES[@]} == 0 )); then
      ub_invalid "There are no partition images here; choose a list number instead."
      continue
    fi
    SELECTED=("${PART_INDICES[@]}")
    break
  fi
  bad=""
  for n in $choice; do
    if [[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1 && n <= ${#IMAGES[@]} )); then
      SELECTED+=("$((n - 1))")
    else
      bad+="$n "
    fi
  done
  if [[ -n "$bad" ]]; then
    ub_invalid "Not valid: ${bad% }. Enter list numbers between 1 and ${#IMAGES[@]}, separated by spaces, or a for all."
    continue
  fi
  (( ${#SELECTED[@]} > 0 )) && break
  ub_invalid "Nothing entered. Enter list numbers between 1 and ${#IMAGES[@]}, or a for all."
done

# ---------------------------------------------------------------------------
# Scratch space
# ---------------------------------------------------------------------------

NEED_EXPAND=0
EST_BYTES=0
for i in "${SELECTED[@]}"; do
  if is_compressed "${IMAGES[$i]}" || [[ "${IMG_ENGINE[$i]}" == "partclone" ]] \
     || [[ -n "${IMG_NAMEFS[$i]}" ]]; then
    NEED_EXPAND=1
    sz="$("$STAT" -c %s "${IMAGES[$i]}")"
    # rough: compressed images expand ~3x; partclone raw is sparse
    EST_BYTES=$(( EST_BYTES + sz * 3 ))
  fi
done

if (( NEED_EXPAND == 1 )); then
  if [[ -z "$SCRATCH" ]]; then
    SCRATCH="$(mktemp -d -t cz-mount.XXXXXX)"
    SCRATCH_IS_TEMP=1
  else
    mkdir -p "$SCRATCH"
  fi
  if is_tmpfs "$SCRATCH"; then
    av="$(avail_bytes "$SCRATCH")"
    echo
    echo "NOTE: expansion scratch $SCRATCH is on tmpfs (RAM), $(human_size "${av:-0}") free."
    echo "Expanded images are written sparsely, but a large image can still"
    echo "exhaust memory. Set CZ_SCRATCH=/path/on/a/real/disk to avoid this."
    read -r -p "Continue? (Y/n): " c
    case "${c,,}" in n|no) exit 0 ;; esac
  else
    av="$(avail_bytes "$SCRATCH")"
    if [[ "$av" =~ ^[0-9]+$ ]] && (( av < EST_BYTES )); then
      echo
      echo "NOTE: $SCRATCH has $(human_size "$av") free; expansion may need"
      echo "roughly $(human_size "$EST_BYTES") (rough estimate). Raw files are"
      echo "written sparsely, so real usage is often far lower."
      read -r -p "Continue? (Y/n): " c
      case "${c,,}" in n|no) exit 0 ;; esac
    fi
  fi
fi

mkdir -p "$MNTROOT"

# ---------------------------------------------------------------------------
# Content-based image identification
#
# Filenames are only hints. What matters is what is actually inside:
#   - partclone images begin with the magic string "partclone-image"
#   - everything else is a raw sector stream
# NOTE: "partclone.dd -O -" emits a PLAIN RAW stream, not partclone format,
# so a *.dd-ptcl-img file is raw despite its name. This probe handles that
# automatically instead of trusting the extension.
# ---------------------------------------------------------------------------

probe_format() {
  local img="$1" dcmd magic
  dcmd="$(decompress_cmd "$img" 2>/dev/null)" || { printf 'unknown'; return 0; }
  magic="$( { $dcmd < "$img" 2>/dev/null | head -c 16; } 2>/dev/null | tr -d '\0' )"
  case "$magic" in
    partclone-image*) printf 'partclone' ;;
    *)                printf 'raw' ;;
  esac
}

has_partition_table() {
  local src="$1" out
  out="$(read_part_table "$src" 2>/dev/null || true)"
  [[ -n "$out" ]]
}

# ---------------------------------------------------------------------------
# Expansion
# ---------------------------------------------------------------------------

# expand_to_raw <img> <raw> <prefix>
# Streams the image into a raw file. Cached: if a usable raw already exists
# and is newer than the image, it is reused (instant re-mount).
# Choose the partclone restore binary and its flags.
# CRITICAL: the fs-specific tools (partclone.ext4 ...) REQUIRE -r to select
# restore mode, while partclone.restore REJECTS -r ("invalid option -- 'r'")
# because restore is implied by its name. Getting this wrong makes partclone
# print usage, exit 0, and produce nothing.
partclone_restore_cmd() {
  local namefs="$1"
  if [[ -n "$namefs" && "$namefs" != "dd" && "$namefs" != "swap" ]] \
     && command -v "partclone.$namefs" >/dev/null 2>&1; then
    printf 'partclone.%s -r -C --restore_raw_file' "$namefs"
    return 0
  fi
  if [[ -n "$PARTCLONE_RESTORE" ]]; then
    printf '%s -C --restore_raw_file' "$PARTCLONE_RESTORE"
    return 0
  fi
  return 1
}

# expand_to_raw <img> <raw> <prefix> <format> <namefs>
expand_to_raw() {
  local img="$1" raw="$2" prefix="$3" format="$4" namefs="${5:-}"
  local dcmd rc=0 total pid pccmd errlog

  if [[ -s "$raw" && "$raw" -nt "$img" ]]; then
    echo "→ Reusing expansion already made in this session"
    return 0
  fi
  rm -f "$raw"

  dcmd="$(decompress_cmd "$img")" || return 1
  total="$("$STAT" -c %s "$img")"
  errlog="$SCRATCH/$(basename "$raw").err"

  if [[ "$format" == "partclone" ]]; then
    pccmd="$(partclone_restore_cmd "$namefs")" || {
      echo "✗ No usable partclone restore tool found (install partclone)."
      return 1
    }
  fi

  # pv meters the COMPRESSED input, so the percentage is exact.
  (
    set -o pipefail
    if [[ "$format" == "partclone" ]]; then
      if [[ -n "$PV" ]]; then
        "$PV" -ptebar -s "$total" < "$img" | $dcmd | $pccmd -s - -o "$raw" -L "$RAWLOG" >/dev/null 2>"$errlog"
      else
        $dcmd < "$img" | $pccmd -s - -o "$raw" -L "$RAWLOG" >/dev/null 2>"$errlog"
      fi
    else
      if [[ -n "$PV" ]]; then
        "$PV" -ptebar -s "$total" < "$img" | $dcmd | "$DD" of="$raw" bs=4M conv=sparse,fsync status=none 2>"$errlog"
      else
        $dcmd < "$img" | "$DD" of="$raw" bs=4M conv=sparse,fsync status=none 2>"$errlog"
      fi
    fi
  ) &
  pid=$!
  [[ -z "$PV" ]] && spinner "$pid" "expanding..." "$prefix"
  set +e
  wait "$pid"
  rc=$?
  set -e

  if (( rc != 0 )) || [[ ! -s "$raw" ]]; then
    echo "✗ Expansion failed for $(basename "$img")"
    if [[ -s "$errlog" ]]; then
      echo "  --- error output ---"
      tail -n 12 "$errlog" | "$SED" 's/^/  /'
    fi
    if [[ -s "$RAWLOG" ]]; then
      echo "  --- partclone log ---"
      tail -n 12 "$RAWLOG" | "$SED" 's/^/  /'
    fi
    rm -f "$raw"
    return 1
  fi
  return 0
}

# ---------------------------------------------------------------------------
# Mounting
# ---------------------------------------------------------------------------

# try_mount <source> <mountpoint> — read-only, with sensible fallbacks.
try_mount() {
  local src="$1" mnt="$2" fs opts="ro"
  fs="$(fs_of "$src")"

  # Mounting a regular file needs a loop device; mount can set one up itself.
  [[ -f "$src" ]] && opts="ro,loop"

  case "$fs" in
    swap)
      echo "  (swap — nothing to mount)"
      return 2
      ;;
    "")
      echo "  (no recognisable filesystem — nothing to mount)"
      return 2
      ;;
  esac

  # 1) plain read-only, explicit type
  if sudo "$MOUNT" -o "$opts" -t "$fs" "$src" "$mnt" 2>/dev/null; then return 0; fi
  # 2) let the kernel choose the driver (matters for ntfs/ntfs3/ntfs-3g)
  if sudo "$MOUNT" -o "$opts" "$src" "$mnt" 2>/dev/null; then return 0; fi
  # 3) ext* with an unreplayed journal refuses a read-only mount; noload
  #    skips journal replay (contents are as-of the last checkpoint)
  case "$fs" in
    ext2|ext3|ext4)
      if sudo "$MOUNT" -o "${opts},noload" -t "$fs" "$src" "$mnt" 2>/dev/null; then
        echo "  (mounted with noload: journal was not replayed)"
        return 0
      fi
      ;;
    ntfs)
      if command -v ntfs-3g >/dev/null 2>&1 && sudo ntfs-3g -o ro "$src" "$mnt" 2>/dev/null; then
        return 0
      fi
      ;;
  esac
  return 1
}

# Offer a repair on the SCRATCH COPY only (never the backup image) when a
# mount fails. This is why there is no up-front fsck: checking a clean
# filesystem wastes minutes, and a failed mount is the only real signal.
offer_repair_and_retry() {
  local src="$1" mnt="$2" fs c
  fs="$(fs_of "$src")"
  case "$fs" in ext2|ext3|ext4|vfat|ntfs) : ;; *) return 1 ;; esac
  # A raw file we expanded can be repaired; the original image never is.
  echo
  echo "  Mount failed. The filesystem may be dirty (backed up while in use,"
  echo "  or an unclean shutdown before the backup)."
  echo "  A repair can be attempted on the temporary copy only — the backup"
  echo "  image itself is never modified."
  read -r -p "  Attempt repair and retry mount? (y/N): " c
  [[ "${c,,}" == "y" || "${c,,}" == "yes" ]] || return 1
  case "$fs" in
    ext2|ext3|ext4)
      command -v e2fsck >/dev/null 2>&1 || { echo "  e2fsck not installed."; return 1; }
      sudo e2fsck -f -y "$src" || true
      ;;
    vfat)
      command -v fsck.fat >/dev/null 2>&1 || { echo "  fsck.fat not installed."; return 1; }
      sudo fsck.fat -a "$src" || true
      ;;
    ntfs)
      command -v ntfsfix >/dev/null 2>&1 || { echo "  ntfsfix not installed."; return 1; }
      sudo ntfsfix "$src" || true
      ;;
  esac
  try_mount "$src" "$mnt"
}

register_mount() {
  MOUNTED_LIST+=("$1")
  echo "✓ Mounted: $1"
  ls -A "$1" 2>/dev/null | head -5 | "$SED" 's/^/    /' || true
}

# Sets MNT_RESULT. NOTE: this must not be used via $(...) — a command
# substitution runs in a subshell and the MADE_DIRS append would be lost,
# leaving directories behind at cleanup.
MNT_RESULT=""
make_mnt() {
  local name
  name="$(unique_mnt "$1")"
  MNT_RESULT="$MNTROOT/$name"
  mkdir -p "$MNT_RESULT"
  MADE_DIRS+=("$MNT_RESULT")
}

# Mount every partition inside a whole-disk image (file or already-expanded
# raw). losetup -P asks the kernel to scan the partition table, which works
# for both MBR and GPT.
# Read a partition table straight out of an image file / device.
# Emits "NR START SECTORS" lines. Works for both MBR and GPT.
read_part_table() {
  local src="$1"
  if [[ -n "$PARTX" ]]; then
    sudo "$PARTX" -s -o NR,START,SECTORS "$src" 2>/dev/null \
      | "$AWK" 'NR>1 && $1 ~ /^[0-9]+$/ {print $1, $2, $3}' && return 0
  fi
  if [[ -n "$SFDISK" ]]; then
    sudo "$SFDISK" -d "$src" 2>/dev/null | "$AWK" '
      /start=/ {
        nr = $1; sub(/.*[^0-9]/, "", nr)
        start = ""; size = ""
        for (i = 1; i <= NF; i++) {
          if ($i ~ /^start=/)      { start = $(i+1); gsub(/[^0-9]/, "", start) }
          else if ($i ~ /^size=/)  { size  = $(i+1); gsub(/[^0-9]/, "", size) }
        }
        if (nr != "" && start != "" && size != "" && size > 0) print nr, start, size
      }'
    return 0
  fi
  return 1
}

# Mount every partition inside a whole-disk image.
# Primary path: losetup -P, which asks the kernel to scan the table (MBR or
# GPT) and create /dev/loopXpN nodes.
# Fallback: some environments cannot create partition nodes (loop max_part=0,
# containers, older util-linux). Then each partition is attached with an
# explicit offset/sizelimit instead, which needs no partition scanning.
mount_full_disk() {
  local src="$1" label="$2" ro_flag="$3"
  local loop parts p pname mnt fs lab pnum rc n=0
  local -a table=()

  loop="$(sudo "$LOSETUP" -fP $ro_flag --show "$src" 2>/dev/null || true)"
  if [[ -z "$loop" ]]; then
    echo "✗ Could not attach $src to a loop device"
    return 1
  fi
  LOOPS+=("$loop")
  sudo partprobe "$loop" >/dev/null 2>&1 || true
  sleep 1

  mapfile -t parts < <("$LSBLK" -pnr -o NAME,TYPE "$loop" 2>/dev/null | "$AWK" '$2=="part"{print $1}')

  if (( ${#parts[@]} == 0 )); then
    # No partition nodes. Is there actually a table in there?
    mapfile -t table < <(read_part_table "$loop" 2>/dev/null || read_part_table "$src" 2>/dev/null || true)

    if (( ${#table[@]} > 0 )); then
      echo "  Kernel did not create partition nodes; using offset mounts instead."
      echo "  Partition table found: ${#table[@]} partition(s)"
      sudo "$LOSETUP" -d "$loop" 2>/dev/null || true
      LOOPS=("${LOOPS[@]/$loop}")
      local nr start secs sub
      for line in "${table[@]}"; do
        read -r nr start secs <<<"$line"
        [[ "$nr" =~ ^[0-9]+$ && "$start" =~ ^[0-9]+$ && "$secs" =~ ^[0-9]+$ ]] || continue
        (( secs > 0 )) || continue
        sub="$(sudo "$LOSETUP" -f --show -r --offset $(( start * 512 )) --sizelimit $(( secs * 512 )) "$src" 2>/dev/null || true)"
        [[ -n "$sub" ]] || { echo "  ✗ could not attach partition $nr"; continue; }
        LOOPS+=("$sub")
        fs="$(fs_of "$sub")"; lab="$(label_of "$sub")"
        printf "  → partition %s (%s%s)\n" "$nr" "${fs:-unknown}" "${lab:+, label: $lab}"
        make_mnt "${label}p${nr}"; mnt="$MNT_RESULT"
        set +e; try_mount "$sub" "$mnt"; rc=$?; set -e
        if (( rc == 0 )); then
          register_mount "$mnt"; n=$((n + 1))
        else
          rmdir "$mnt" 2>/dev/null || true
        fi
      done
      (( n > 0 )) && return 0
      return 1
    fi

    echo "  No partition table found — trying the image as a single filesystem."
    make_mnt "$label"; mnt="$MNT_RESULT"
    set +e; try_mount "$loop" "$mnt"; rc=$?; set -e
    if (( rc == 0 )); then register_mount "$mnt"; return 0; fi
    rmdir "$mnt" 2>/dev/null || true
    echo "✗ Nothing mountable in $label"
    return 1
  fi

  echo "  Partition table found: ${#parts[@]} partition(s)"
  for p in "${parts[@]}"; do
    pname="$(basename "$p")"
    pnum="$(split_dev_name "$pname")"; pnum="${pnum##*|}"
    fs="$(fs_of "$p")"
    lab="$(label_of "$p")"
    printf "  → partition %s (%s%s)\n" "${pnum:-?}" "${fs:-unknown}" "${lab:+, label: $lab}"

    make_mnt "${label}p${pnum:-x}"; mnt="$MNT_RESULT"
    set +e; try_mount "$p" "$mnt"; rc=$?; set -e
    if (( rc == 0 )); then
      register_mount "$mnt"
      n=$((n + 1))
      continue
    fi
    if (( rc == 2 )); then          # swap / no filesystem: not an error
      rmdir "$mnt" 2>/dev/null || true
      continue
    fi
    set +e; offer_repair_and_retry "$p" "$mnt"; rc=$?; set -e
    if (( rc == 0 )); then
      register_mount "$mnt"
      n=$((n + 1))
    else
      rmdir "$mnt" 2>/dev/null || true
    fi
  done
  (( n > 0 )) && return 0
  return 1
}

mount_one() {
  local idx="$1" item_no="$2" item_total="$3"
  local img base name namefs prefix raw mnt rc fmt src

  img="${IMAGES[$idx]}"
  base="${IMG_BASE[$idx]}"
  name="${IMG_NAME[$idx]}"
  namefs="${IMG_NAMEFS[$idx]}"
  prefix="[$item_no/$item_total] "

  echo
  echo "── $base"

  # Swap holds no files — skip before any expensive expansion.
  if [[ "$namefs" == "swap" ]]; then
    echo "→ Swap image — nothing to mount, skipping."
    return 0
  fi

  # What is actually inside the file decides how it is handled.
  fmt="$(probe_format "$img")"
  if [[ "$fmt" == "unknown" ]]; then
    echo "✗ Cannot read $base (missing decompressor?)"
    return 1
  fi

  if [[ "$fmt" == "raw" ]] && ! is_compressed "$img"; then
    # Zero-copy: a raw uncompressed image is usable exactly as it is.
    echo "→ Raw uncompressed image: using directly (no expansion)"
    src="$img"
  else
    RAWLOG="$SCRATCH/${name}.partclone.log"
    raw="$SCRATCH/${name}.raw"
    if [[ "$fmt" == "partclone" ]]; then
      echo "→ Expanding partclone image ${namefs:+($namefs) }..."
    else
      echo "→ Decompressing raw image..."
    fi
    expand_to_raw "$img" "$raw" "$prefix" "$fmt" "$namefs" || return 1
    src="$raw"
  fi

  # Whole disk or single filesystem? Decided by content, not by the filename.
  if has_partition_table "$src"; then
    echo "→ Image contains a partition table"
    set +e; mount_full_disk "$src" "$name" "-r"; rc=$?; set -e
    return "$rc"
  fi

  make_mnt "$name"; mnt="$MNT_RESULT"
  set +e; try_mount "$src" "$mnt"; rc=$?; set -e
  if (( rc == 0 )); then register_mount "$mnt"; return 0; fi
  if (( rc == 2 )); then rmdir "$mnt" 2>/dev/null || true; return 0; fi

  if [[ "$src" == "$img" ]]; then
    # Repairing here would modify the backup file itself — never do that.
    rmdir "$mnt" 2>/dev/null || true
    echo "✗ Could not mount $base read-only (filesystem is probably dirty)."
    echo "  Repair is NOT offered: it would modify the backup file itself."
    return 1
  fi

  set +e; offer_repair_and_retry "$src" "$mnt"; rc=$?; set -e
  if (( rc == 0 )); then register_mount "$mnt"; return 0; fi

  echo "✗ Could not mount $base"
  rmdir "$mnt" 2>/dev/null || true
  return 1
}

# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------

total=${#SELECTED[@]}
count=0
failed=0
for idx in "${SELECTED[@]}"; do
  count=$((count + 1))
  mount_one "$idx" "$count" "$total" || failed=$((failed + 1))
done

echo
echo "=== Mounted locations ==="
if (( ${#MOUNTED_LIST[@]} == 0 )); then
  echo "None."
  (( failed > 0 )) && echo "$failed image(s) could not be mounted."
  exit 1
fi
printf '%s\n' "${MOUNTED_LIST[@]}"
(( failed > 0 )) && echo "($failed image(s) could not be mounted.)"

echo
echo "Open these paths in any file manager (Nautilus, Nemo, Dolphin, Thunar,"
echo "PCManFM, Caja, ranger, mc) or browse them in a terminal."
echo
echo "When you are finished browsing in File explorer, press Enter to:"
echo "  - unmount all images"
echo "  - detach loop devices"
echo "  - remove $MNTROOT"
read -r -p "Press Enter when done..." _

echo "Unmounting..."
unmount_all
MOUNTED_LIST=(); LOOPS=(); MADE_DIRS=()
echo "Done."

)

# ===========================================================================
# MODULE: LIST PARTITION INFO OF CURRENT BACKUP DIRECTORY
# Reads the metadata that cz-backup (and Clonezilla) leave in an image
# folder and reports what was backed up: the source disk, every partition
# in on-disk order with geometry and identifiers, the image file for each,
# and full original-vs-shrunken figures for anything that was shrunk.
# Nothing is read from the live disks and nothing is modified.
# ===========================================================================
run_list() (
  set -uo pipefail

  L_AWK="$(command -v awk)"
  L_SED="$(command -v sed)"
  L_SORT="$(command -v sort)"
  L_FIND="$(command -v find)"

  hs() {  # human size from bytes
    local b="${1:-0}"
    [[ "$b" =~ ^[0-9]+$ ]] || { printf 'n/a'; return; }
    if command -v numfmt >/dev/null 2>&1; then
      numfmt --to=iec --suffix=B --format='%.1f' "$b" 2>/dev/null || printf '%sB' "$b"
    else
      "$L_AWK" -v b="$b" 'BEGIN{split("B KiB MiB GiB TiB PiB",s," ");n=1;
        while(b>=1024&&n<6){b/=1024;n++} printf "%.1f%s", b, s[n]}'
    fi
  }

  sect2b() { "$L_AWK" -v s="${1:-0}" 'BEGIN{printf "%.0f", s*512}'; }

  kv() {  # kv <file> <key>   -> value of key=value
    [[ -f "$1" ]] || return 0
    "$L_SED" -n "s/^$2=//p" "$1" 2>/dev/null | head -n1
  }

  strip_comp() {
    local n="$1"; n="${n%.zst}"; n="${n%.gz}"; n="${n%.xz}"; n="${n%.bz2}"
    printf '%s' "$n"
  }

  comp_of() {
    case "$1" in
      *.zst) printf 'zstd' ;; *.gz) printf 'gzip' ;;
      *.xz)  printf 'xz'   ;; *.bz2) printf 'bzip2' ;;
      *)     printf 'none' ;;
    esac
  }

  # device name -> partition number (sdc2 -> 2, nvme0n1p2 -> 2)
  pnum_of() {
    local d="$1"
    if [[ "$d" =~ ^(.+[0-9])p([0-9]+)$ ]]; then printf '%s' "${BASH_REMATCH[2]}"
    elif [[ "$d" =~ ^(.*[^0-9])([0-9]+)$ ]]; then printf '%s' "${BASH_REMATCH[2]}"
    else printf ''; fi
  }

  # Known GPT type GUIDs -> readable name
  guid_name() {
    case "${1^^}" in
      C12A7328-F81F-11D2-BA4B-00A0C93EC93B) printf 'EFI System' ;;
      0FC63DAF-8483-4772-8E79-3D69D8477DE4) printf 'Linux filesystem' ;;
      0657FD6D-A4AB-43C4-84E5-0933C84B4F4F) printf 'Linux swap' ;;
      E6D6D379-F507-44C2-A23C-238F2A3DF928) printf 'Linux LVM' ;;
      21686148-6449-6E6F-744E-656564454649) printf 'BIOS boot' ;;
      EBD0A0A2-B9E5-4433-87C0-68B6B72699C7) printf 'Microsoft basic data' ;;
      DE94BBA4-06D1-4D40-A16A-BFD50179D6AC) printf 'Windows recovery' ;;
      *) printf '' ;;
    esac
  }

  # Does this directory look like a backup image folder?
  is_backup_dir() {
    local d="$1"
    [[ -f "$d/disk" || -f "$d/clonezilla-img" ]] && return 0
    compgen -G "$d/*-pt.parted.compact" >/dev/null 2>&1 && return 0
    compgen -G "$d/*-pt.sf" >/dev/null 2>&1 && return 0
    compgen -G "$d/*-ptcl-img*" >/dev/null 2>&1 && return 0
    compgen -G "$d/*.dd-img*" >/dev/null 2>&1 && return 0
    return 1
  }

  # -----------------------------------------------------------------------
  # Report one backup folder
  # -----------------------------------------------------------------------
  report_backup() {
    local dir="$1"
    local diskbase info_file compact sf shrunk_compact
    local src_disk created fmt name ptable engine btype
    local model dsize_s

    echo
    echo "==========================================================================="
    printf "BACKUP : %s\n" "$(basename "$dir")"
    printf "PATH   : %s\n" "$dir"
    echo "==========================================================================="

    # --- which disk does this image belong to? ---
    diskbase=""
    [[ -f "$dir/disk" ]] && diskbase="$(tr -d ' \n' < "$dir/disk")"
    if [[ -z "$diskbase" ]]; then
      local c
      c="$(compgen -G "$dir/*-pt.parted.compact" 2>/dev/null | head -n1 || true)"
      [[ -z "$c" ]] && c="$(compgen -G "$dir/*-pt.sf" 2>/dev/null | head -n1 || true)"
      if [[ -n "$c" ]]; then
        diskbase="$(basename "$c")"
        diskbase="${diskbase%-pt.parted.compact}"
        diskbase="${diskbase%-pt.sf}"
      fi
    fi

    info_file="$dir/${diskbase}.info"
    compact="$dir/${diskbase}-pt.parted.compact"
    [[ -f "$compact" ]] || compact="$dir/${diskbase}-pt.parted"
    sf="$dir/${diskbase}-pt.sf"
    shrunk_compact="$dir/${diskbase}-pt.parted.compact.shrunken"

    src_disk="$(kv "$info_file" disk)"
    created="$(kv "$info_file" created)"
    fmt="$(kv "$info_file" format)"
    name="$(kv "$info_file" name)"
    ptable="$(kv "$info_file" ptable_type)"
    engine="$(kv "$info_file" engine)"
    btype="$(kv "$info_file" backup_type)"

    # Clonezilla images have no .info; fall back to the parted header.
    if [[ -z "$src_disk" && -f "$compact" ]]; then
      src_disk="$("$L_AWK" '/^Disk /{print $2; exit}' "$compact" | tr -d ':')"
    fi
    [[ -z "$ptable" && -f "$compact" ]] && \
      ptable="$("$L_AWK" -F': ' '/^Partition Table:/{print $2; exit}' "$compact")"
    model="$("$L_AWK" -F': ' '/^Model:/{print $2; exit}' "$compact" 2>/dev/null || true)"
    dsize_s="$("$L_AWK" '/^Disk /{gsub(/s$/,"",$3); print $3; exit}' "$compact" 2>/dev/null || true)"

    printf "  Source disk     : %s\n" "${src_disk:-unknown}"
    [[ -n "$model"   ]] && printf "  Disk model      : %s\n" "$model"
    if [[ "$dsize_s" =~ ^[0-9]+$ ]]; then
      printf "  Disk size       : %s (%s sectors of 512B)\n" "$(hs "$(sect2b "$dsize_s")")" "$dsize_s"
    fi
    printf "  Partition table : %s\n" "${ptable:-unknown}"
    [[ -n "$created" ]] && printf "  Created         : %s\n" "$created"
    [[ -n "$name"    ]] && printf "  Image name      : %s\n" "$name"
    [[ -n "$fmt"     ]] && printf "  Image format    : %s\n" "$fmt"
    printf "  Backup engine   : %s\n" "${engine:-partclone (assumed)}"
    if [[ "$btype" == "full-disk" ]]; then
      printf "  Backup type     : full disk image (entire block device)\n"
    else
      printf "  Backup type     : partition images\n"
    fi

    # --- which partitions were shrunk before imaging? ---
    declare -A SHRUNK=()
    if [[ -f "$dir/shrunk-parts" ]]; then
      while IFS= read -r sp; do
        [[ -n "$sp" ]] && SHRUNK["$sp"]=1
      done < "$dir/shrunk-parts"
    fi

    # --- index the image files by partition name ---
    declare -A IMGF=()
    local f b devpart
    while IFS= read -r f; do
      [[ -n "$f" ]] || continue
      b="$(basename "$f")"
      devpart="$(strip_comp "$b")"
      devpart="${devpart%%.*}"
      IMGF["$devpart"]="$f"
    done < <("$L_FIND" "$dir" -maxdepth 1 -type f \
        \( -name '*-ptcl-img' -o -name '*-ptcl-img.zst' -o -name '*-ptcl-img.gz' \
           -o -name '*-ptcl-img.xz' -o -name '*-ptcl-img.bz2' \
           -o -name '*.dd-img' -o -name '*.dd-img.zst' -o -name '*.dd-img.gz' \
           -o -name '*.dd-img.xz' -o -name '*.dd-img.bz2' \) 2>/dev/null | "$L_SORT")

    # --- full-disk backups have a single image and no partition rows ---
    local FULLIMG=""
    if [[ "$btype" == "full-disk" ]]; then
      FULLIMG="${IMGF[$diskbase]:-}"
      echo
      if [[ -n "$FULLIMG" ]]; then
        local fsz feng
        fsz="$(stat -c %s "$FULLIMG")"
        feng="partclone"
        case "$(strip_comp "$(basename "$FULLIMG")")" in *.dd-img) feng="dd" ;; esac
        printf "  Whole-disk image: %s\n" "$(basename "$FULLIMG")"
        printf "                    %s, %s compression, %s engine\n" \
          "$(hs "$fsz")" "$(comp_of "$FULLIMG")" "$feng"
      else
        echo "  Whole-disk image: NOT FOUND in this folder"
      fi
      echo
      echo "  This is a whole-device image: every partition below is contained"
      echo "  inside that single image, not stored as separate partition images."
      echo "  The table shows the layout as it was when the image was taken."
    fi

    if [[ ! -f "$compact" ]]; then
      echo
      echo "  No partition table metadata found in this folder."
      local k tot=0
      for k in "${!IMGF[@]}"; do
        printf "  image: %s (%s)\n" "$(basename "${IMGF[$k]}")" "$(hs "$(stat -c %s "${IMGF[$k]}")")"
        tot=$(( tot + $(stat -c %s "${IMGF[$k]}") ))
      done
      (( tot > 0 )) && printf "  total image size: %s\n" "$(hs "$tot")"
      return 0
    fi

    echo
    echo "  Partitions recorded (in on-disk order):"

    local prev_end=2047 nparts=0 nimg=0 timg=0 tpart=0
    local -a MISSING=()

    # rows of the compact table, sorted by start sector
    while read -r pstart row; do
      [[ "$pstart" =~ ^[0-9]+$ ]] || continue
      local num start end size fs pname flags
      read -r num start end size fs pname flags <<<"$row"
      start="${start%s}"; end="${end%s}"; size="${size%s}"
      [[ "$size" =~ ^[0-9]+$ ]] || continue
      nparts=$((nparts + 1))
      tpart=$(( tpart + size ))

      # "name" may actually be a flags token when the partition had no label
      case "$pname" in
        boot,|swap|esp|boot|hidden|legacy_boot) flags="$pname"; pname="" ;;
      esac

      # free gap before this partition
      if (( start > prev_end + 1 )); then
        local gap=$(( start - prev_end - 1 ))
        printf "\n  ---- free space: %s sectors (%s)  [%s - %s]\n" \
          "$gap" "$(hs "$(sect2b "$gap")")" "$((prev_end + 1))" "$((start - 1))"
      fi

      # partition device name as recorded on the source disk
      local devname="${diskbase}${num}"
      [[ "$diskbase" =~ [0-9]$ ]] && devname="${diskbase}p${num}"

      echo
      printf "  [%s] %s\n" "$num" "$devname"
      printf "      range      : %s - %s  (%s sectors)\n" "$start" "$end" "$size"
      printf "      size       : %s\n" "$(hs "$(sect2b "$size")")"
      printf "      filesystem : %s\n" "${fs:-unknown}"
      [[ -n "$pname" ]] && printf "      label      : %s\n" "$pname"
      [[ -n "$flags" ]] && printf "      flags      : %s\n" "$flags"

      # identifiers from the sfdisk-style dump
      if [[ -f "$sf" ]]; then
        local sfline ptype puuid sfname gname
        sfline="$(grep -E "^/dev/${diskbase}p?${num}[[:space:]]*:" "$sf" 2>/dev/null | head -n1 || true)"
        if [[ -n "$sfline" ]]; then
          ptype="$("$L_SED" -nE 's/.*type=([^,]*).*/\1/p' <<<"$sfline" | tr -d ' ')"
          puuid="$("$L_SED" -nE 's/.*uuid=([^,]*).*/\1/p' <<<"$sfline" | tr -d ' ')"
          sfname="$("$L_SED" -nE 's/.*name="([^"]*)".*/\1/p' <<<"$sfline")"
          if [[ -n "$ptype" ]]; then
            gname="$(guid_name "$ptype")"
            printf "      part type  : %s%s\n" "$ptype" "${gname:+  ($gname)}"
          fi
          [[ -n "$puuid"  ]] && printf "      PARTUUID   : %s\n" "$puuid"
          [[ -n "$sfname" && "$sfname" != "$pname" ]] && printf "      part label : %s\n" "$sfname"
        fi
      fi

      # the image file for this partition
      local img="${IMGF[$devname]:-}"
      if [[ -n "$img" ]]; then
        local isz ratio ib
        isz="$(stat -c %s "$img")"
        ib="$(sect2b "$size")"
        ratio="$("$L_AWK" -v a="$isz" -v b="$ib" 'BEGIN{ if(b>0) printf "%.1f", (a*100)/b; else printf "0.0" }')"
        local ieng="partclone"
        case "$(strip_comp "$(basename "$img")")" in *.dd-img) ieng="dd" ;; esac
        printf "      image      : %s\n" "$(basename "$img")"
        printf "                   %s, %s compression, %s engine (%s%% of partition size)\n" \
          "$(hs "$isz")" "$(comp_of "$img")" "$ieng" "$ratio"
        nimg=$((nimg + 1))
        timg=$(( timg + isz ))
      elif [[ "$btype" == "full-disk" ]]; then
        printf "      image      : (contained in the whole-disk image)\n"
      else
        printf "      image      : NONE (this partition was not imaged)\n"
        MISSING+=("$devname")
      fi

      # ---- shrink figures ----
      local meta="$dir/${devname}.pre-shrink.meta"
      if [[ -n "${SHRUNK[$devname]:-}" || -f "$meta" ]]; then
        printf "      shrunk     : YES (ext4 shrunk before imaging)\n"
        local osz ossect ouuid olabel opuuid ots
        if [[ -f "$meta" ]]; then
          ossect="$(kv "$meta" sector_count)"
          osz="$(kv "$meta" byte_size)"
          ouuid="$(kv "$meta" uuid)"
          olabel="$(kv "$meta" label)"
          opuuid="$(kv "$meta" partuuid)"
          ots="$(kv "$meta" timestamp)"
          [[ "$osz" =~ ^[0-9]+$ ]] && \
            printf "        original size : %s (%s sectors)\n" "$(hs "$osz")" "${ossect:-?}"
        fi
        # shrunken geometry lives in the .shrunken compact table
        if [[ -f "$shrunk_compact" ]]; then
          local srow ssz
          srow="$("$L_AWK" -v n="$num" '$1==n {print; exit}' "$shrunk_compact" 2>/dev/null || true)"
          if [[ -n "$srow" ]]; then
            ssz="$("$L_AWK" '{print $4}' <<<"$srow")"; ssz="${ssz%s}"
            if [[ "$ssz" =~ ^[0-9]+$ ]]; then
              printf "        shrunken size : %s (%s sectors)\n" "$(hs "$(sect2b "$ssz")")" "$ssz"
              if [[ "$ossect" =~ ^[0-9]+$ ]] && (( ossect > ssz )); then
                printf "        space saved   : %s\n" "$(hs "$(sect2b $((ossect - ssz)))")"
              fi
            fi
          fi
        else
          printf "        shrunken size : (no .shrunken metadata in this folder)\n"
        fi
        [[ -n "${olabel:-}" ]] && printf "        original label: %s\n" "$olabel"
        [[ -n "${ouuid:-}"  ]] && printf "        original UUID : %s\n" "$ouuid"
        [[ -n "${opuuid:-}" ]] && printf "        PARTUUID      : %s\n" "$opuuid"
        [[ -n "${ots:-}"    ]] && printf "        recorded      : %s\n" "$ots"
        echo   "        restore note  : this partition can be restored at its"
        echo   "                        original size (filesystem grown to fill)"
        echo   "                        or at the smaller shrunken size."
      else
        printf "      shrunk     : no\n"
      fi

      (( end > prev_end )) && prev_end=$end
    done < <("$L_AWK" 'NF>=5 && $1 ~ /^[0-9]+$/ {s=$2; gsub(/s$/,"",s); print s, $0}' "$compact" \
             | "$L_SORT" -n -k1,1)

    # trailing free space
    if [[ "$dsize_s" =~ ^[0-9]+$ ]] && (( dsize_s > prev_end + 1 )); then
      local gap=$(( dsize_s - prev_end - 1 ))
      printf "\n  ---- free space: %s sectors (%s)  [%s - %s]\n" \
        "$gap" "$(hs "$(sect2b "$gap")")" "$((prev_end + 1))" "$((dsize_s - 1))"
    fi

    # images present that no metadata row described
    local -a EXTRA=()
    local k found rownum
    for k in "${!IMGF[@]}"; do
      found=0
      [[ "$btype" == "full-disk" && "$k" == "$diskbase" ]] && continue
      rownum="$(pnum_of "$k")"
      if [[ -n "$rownum" ]] && "$L_AWK" -v n="$rownum" '$1==n {f=1} END{exit !f}' "$compact" 2>/dev/null; then
        found=1
      fi
      (( found == 0 )) && EXTRA+=("$(basename "${IMGF[$k]}")")
    done

    echo
    echo "  Summary:"
    printf "      partitions recorded : %s\n" "$nparts"
    if [[ "$btype" == "full-disk" ]]; then
      if [[ -n "$FULLIMG" ]]; then
        nimg=1; timg="$(stat -c %s "$FULLIMG")"
      fi
      printf "      whole-disk image    : %s\n" "$( [[ -n "$FULLIMG" ]] && hs "$timg" || echo 'missing' )"
    else
      printf "      images present      : %s (total %s)\n" "$nimg" "$(hs "$timg")"
    fi
    printf "      recorded part. size : %s\n" "$(hs "$(sect2b "$tpart")")"
    if (( tpart > 0 && timg > 0 )); then
      printf "      overall compression : %s%% of source size\n" \
        "$("$L_AWK" -v a="$timg" -v b="$(sect2b "$tpart")" 'BEGIN{printf "%.1f", (a*100)/b}')"
    fi
    if (( ${#MISSING[@]} > 0 )); then
      printf "      NOT imaged          : %s\n" "${MISSING[*]}"
    fi
    if (( ${#EXTRA[@]} > 0 )); then
      printf "      images w/o metadata : %s\n" "${EXTRA[*]}"
    fi
  }

  # -----------------------------------------------------------------------
  # Find backup folders: the working directory itself, and/or its children
  # -----------------------------------------------------------------------
  local base="$WORK_DIR"
  base="$(cd "$base" 2>/dev/null && pwd)" || { echo "No such directory: $WORK_DIR"; exit 1; }

  local -a DIRS=()
  if is_backup_dir "$base"; then
    DIRS+=("$base")
  fi
  local d
  while IFS= read -r d; do
    [[ -n "$d" ]] || continue
    is_backup_dir "$d" && DIRS+=("$d")
  done < <("$L_FIND" "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | "$L_SORT")

  if (( ${#DIRS[@]} == 0 )); then
    echo
    echo "No backup image folders found in: $base"
    echo
    echo "Run this from inside a backup folder, or from a folder that contains"
    echo "them, or pass the path:  ./ultimate_backup.sh /path/to/backups"
    exit 0
  fi

  if (( ${#DIRS[@]} == 1 )); then
    report_backup "${DIRS[0]}"
  else
    echo
    echo "Backup folders found in $base:"
    local i
    for i in "${!DIRS[@]}"; do
      printf "[%d] %s\n" "$((i+1))" "$(basename "${DIRS[$i]}")"
    done
    echo "[a] All of them"
    echo
    local lsel idx
    while true; do
      read -r -p "Choose backup folder (list number, or a for all): " lsel
      if [[ "${lsel,,}" == "a" || "${lsel,,}" == "all" ]]; then
        for d in "${DIRS[@]}"; do report_backup "$d"; done
        break
      fi
      if [[ "$lsel" =~ ^[0-9]+$ ]] && (( lsel >= 1 && lsel <= ${#DIRS[@]} )); then
        idx=$((lsel - 1))
        report_backup "${DIRS[$idx]}"
        break
      fi
      ub_invalid "Enter a list number between 1 and ${#DIRS[@]}, or a for all."
    done
  fi
  echo
)


# ===========================================================================
# Quick-backup presets
# ===========================================================================

quick_preset_menu() {
  # $1 = partitions | fulldisk
  local kind="$1" sel label
  echo
  if [[ "$kind" == "fulldisk" ]]; then
    echo "Quick Backup - Full Disk image (entire block device)"
  else
    echo "Quick Backup - Partition images (all partitions on disk)"
  fi
  echo
  echo "1. Balanced partclone zst  - Fast and small backup (zst level 6, partclone, shrink ext4)"
  echo "2. Balanced dd zst         - Reasonably fast reasonably small backup (zst level 6, dd, shrink ext4)"
  echo "3. Balanced partclone gzip - Moderately fast and small backup (gz level 6, partclone, shrink ext4)"
  echo "4. Balanced dd gzip        - Reasonably fast reasonably small backup (gz level 6, dd, shrink ext4)"
  echo "5. Balanced partclone xz   - Slower and smaller backup (xz level 6, partclone, shrink ext4)"
  echo "6. Balanced dd xz          - Quite slow quite small backup (xz level 6, dd, shrink ext4)"
  echo
  ask_num sel "Choose preset: " 1 6
  case "$sel" in
    1) QUICK_ENGINE="partclone"; QUICK_COMP_METHOD="zst"; label="partclone + zstd-6" ;;
    2) QUICK_ENGINE="dd";        QUICK_COMP_METHOD="zst"; label="dd + zstd-6" ;;
    3) QUICK_ENGINE="partclone"; QUICK_COMP_METHOD="gz";  label="partclone + gzip-6" ;;
    4) QUICK_ENGINE="dd";        QUICK_COMP_METHOD="gz";  label="dd + gzip-6" ;;
    5) QUICK_ENGINE="partclone"; QUICK_COMP_METHOD="xz";  label="partclone + xz-6" ;;
    6) QUICK_ENGINE="dd";        QUICK_COMP_METHOD="xz";  label="dd + xz-6" ;;
  esac
  QUICK_COMP_LEVEL="6"
  QUICK_TYPE="$kind"
  QUICK_FSCK="2"          # always check and repair
  if [[ "$kind" == "partitions" ]]; then
    QUICK_PARTS="1"       # all partitions on the disk
    QUICK_SHRINK="1"      # shrink ext4 before imaging
  fi
  # No export needed: the module functions run as subshells of this shell and
  # inherit ordinary shell variables. (A bare "export" with unset optional
  # names would dump the whole environment.)
  echo
  echo "Preset: $label${QUICK_SHRINK:+, shrink ext4}, fsck check and repair."
}

# ===========================================================================
# Main menu
# ===========================================================================

banner
preflight_check

cat <<'EOF'
1. Backup
2. Restore
3. Mount
4. List Partition Info. of current backup directory
5. Quick Backup Partition images (all partitions on disk)
6. Quick Backup Full Disk image (entire block device)
EOF
echo
while true; do
  read -r -p "Choose: " MAIN_SEL
  case "${MAIN_SEL,,}" in
    1|b|backup|2|r|restore|3|m|mount|4|l|list|5|6) break ;;
    *) ub_invalid "Please enter 1-6 (or b, r, m, l)." ;;
  esac
done

case "${MAIN_SEL,,}" in
  1|b|backup)
    run_backup "$WORK_DIR"
    ;;
  2|r|restore)
    run_restore "$WORK_DIR"
    ;;
  3|m|mount)
    run_mount "$WORK_DIR"
    ;;
  4|l|list)
    run_list
    ;;
  5)
    quick_preset_menu partitions
    run_backup "$WORK_DIR"
    ;;
  6)
    quick_preset_menu fulldisk
    run_backup "$WORK_DIR"
    ;;
esac
exit $?
