#!/bin/bash
set -euo pipefail

# pve-import-cloud-images - Import upstream cloud images as Proxmox VE templates
#
# Scans distribution mirrors to discover the latest cloud images, optionally
# customizes them (e.g. installing qemu-guest-agent), and imports them as
# PVE templates ready for cloning and cloud-init deployment.
#
# Supported distributions (last two releases each, discovered dynamically):
#   Debian, Ubuntu LTS, Rocky Linux, openSUSE Leap, Oracle Linux, FreeBSD
#
# Workflow per image:
#   1. Scan public mirrors for available versions
#   2. Download cloud image (cached in /var/tmp/pve-cloud-images/)
#   3. Probe image for precise OS version via virt-cat (point releases)
#   4. Optionally inject qemu-guest-agent via virt-customize
#   5. Create VM shell with EFI, cloud-init, and serial console
#   6. Import disk and attach to SCSI bus
#   7. Convert VM to template
#
# Reference: https://iriarte.it/datacenter/2025/02/13/PVE-Cloud-Images.html

VERSION="1.3.0"

# --- Colors (disabled when stdout is not a terminal) --------------------------
if [[ -t 1 ]]; then
    C_RED='\033[0;31m'
    C_GREEN='\033[0;32m'
    C_YELLOW='\033[0;33m'
    C_CYAN='\033[0;36m'
    C_BOLD='\033[1m'
    C_RESET='\033[0m'
else
    C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_BOLD='' C_RESET=''
fi

# --- Helpers ------------------------------------------------------------------
msg()  { echo -e "${C_BOLD}${C_CYAN}::${C_RESET} $*"; }
ok()   { echo -e "   ${C_GREEN}[+]${C_RESET} $*"; }
warn() { echo -e "   ${C_YELLOW}[!]${C_RESET} $*"; }
err()  { echo -e "   ${C_RED}[-]${C_RESET} $*" >&2; }

DRY_RUN=0
ERRORS=0
DISCOVERY_TMPDIR=""

cleanup() {
    # Remove partial downloads
    if [[ -d "$CACHE_DIR" ]]; then
        rm -f "$CACHE_DIR"/*.part
        rm -f "$CACHE_DIR"/*.work.qcow2
    fi
    # Remove mirror-discovery scratch directory
    if [[ -n "$DISCOVERY_TMPDIR" && -d "$DISCOVERY_TMPDIR" ]]; then
        rm -rf "$DISCOVERY_TMPDIR"
    fi
}
trap cleanup EXIT

run() {
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} $*"
    else
        "$@"
    fi
}

# --- Defaults -----------------------------------------------------------------
FORCE=0
STORAGE="local-lvm"
BRIDGE="vmbr0"
DISK_FORMAT="raw"
DISK_SIZE=""
START_ID=9000
CACHE_DIR="/var/tmp/pve-cloud-images"
CUSTOMIZE=1
MODE=""
DISTRO_FILTER=""
MAX_VERSIONS=2
ARCH="x86_64"
TIMESTAMP=$(date +%Y%m%d%H%M)

# --- API mode -----------------------------------------------------------------
API_MODE=0
MODE_SET=0   # track whether --mode was explicitly provided
API_HOST=""
API_NODE=""
API_TOKEN=""
API_IMPORT_STORAGE="local"

# --- Image catalog (populated dynamically by mirror discovery) ----------------
declare -a IMG_KEY=() IMG_NAME=() IMG_URL=() IMG_FAMILY=() IMG_CUSTOMIZE=()
IMG_COUNT=0

_reg() {
    IMG_KEY+=("$1"); IMG_NAME+=("$2"); IMG_URL+=("$3")
    IMG_FAMILY+=("$4"); IMG_CUSTOMIZE+=("${5:-}")
    (( IMG_COUNT++ )) || true
}

# --- HTTP / HTML utilities ----------------------------------------------------
fetch_page() {
    local url="$1"
    if command -v curl &>/dev/null; then
        curl -sfL --max-time 15 "$url" 2>/dev/null
    elif command -v wget &>/dev/null; then
        wget -qO- --timeout=15 "$url" 2>/dev/null
    fi
}

# Extract href values from HTML, one per line, trailing slashes stripped
extract_hrefs() {
    grep -o 'href="[^"]*"' | sed 's/href="//;s/"$//;s|/$||;s|^\./||'
}

# --- Mirror discovery functions -----------------------------------------------
# Each outputs lines of: KEY|NAME|URL|FAMILY|CUSTOMIZE
# Sorted newest-first, limited to $MAX_VERSIONS entries.

discover_debian() {
    local base="https://cdimage.debian.org/images/cloud"
    local page
    page=$(fetch_page "$base/") || return 1

    # Codename directories: pure lowercase alpha, skip sid, deduplicate
    local codenames
    codenames=$(echo "$page" | extract_hrefs | grep -xE '[a-z]+' | grep -vE '^sid$' | sort -u)

    local entries=()
    for codename in $codenames; do
        local listing filename ver
        listing=$(fetch_page "$base/$codename/latest/") || continue
        # Prefer 'generic' variant (supports cloud-init + direct boot)
        filename=$(echo "$listing" | extract_hrefs \
            | grep -E '^debian-[0-9]+-generic-amd64\.qcow2$' | head -1)
        if [[ -z "$filename" ]]; then
            filename=$(echo "$listing" | extract_hrefs \
                | grep -E '^debian-[0-9]+-genericcloud-amd64\.qcow2$' | head -1)
        fi
        [[ -n "$filename" ]] || continue
        ver=$(echo "$filename" | sed 's/debian-\([0-9]*\).*/\1/')
        entries+=("${ver}|${codename}|${filename}")
    done

    printf '%s\n' "${entries[@]}" | sort -t'|' -k1 -rn | head -"$MAX_VERSIONS" \
    | while IFS='|' read -r ver codename filename; do
        echo "debian-${ver}|Debian ${ver} (${codename})|${base}/${codename}/latest/${filename}|debian|guest-agent"
    done
}

discover_ubuntu() {
    local base="https://cloud-images.ubuntu.com/releases"
    local page
    page=$(fetch_page "$base/") || return 1

    # Extract version directories, filter to LTS (even year + .04)
    local versions
    versions=$(echo "$page" | extract_hrefs \
        | grep -xE '[0-9]+\.[0-9]+' \
        | awk -F. '$2=="04" && $1%2==0' \
        | sort -Vr | head -"$MAX_VERSIONS")

    for ver in $versions; do
        local listing filename
        listing=$(fetch_page "$base/$ver/release/") || continue
        filename=$(echo "$listing" | extract_hrefs \
            | grep -E "^ubuntu-${ver}(\.[0-9]+)?-server-cloudimg-amd64\.img$" \
            | sort -V | tail -1)
        [[ -n "$filename" ]] || continue
        local key_ver=${ver//.}
        echo "ubuntu-${key_ver}|Ubuntu ${ver} LTS|${base}/${ver}/release/${filename}|ubuntu|guest-agent"
    done
}

discover_rocky() {
    local base="https://dl.rockylinux.org/pub/rocky"
    local page
    page=$(fetch_page "$base/") || return 1

    local versions
    versions=$(echo "$page" | extract_hrefs | grep -xE '[0-9]+' | sort -rn | head -"$MAX_VERSIONS")

    for ver in $versions; do
        local listing
        listing=$(fetch_page "$base/$ver/images/x86_64/") || continue

        # Determine the minor version from versioned filenames in the listing
        # Matches both GenericCloud-Base-9.7-... and GenericCloud-9.7-... patterns
        local minor_ver full_ver
        minor_ver=$(echo "$listing" | extract_hrefs \
            | grep -E "^Rocky-${ver}-GenericCloud(-Base)?-${ver}\.[0-9]+-" \
            | sed "s/Rocky-${ver}-GenericCloud\(-Base\)\?-\(${ver}\.[0-9]\+\)-.*/\2/" \
            | sort -V | tail -1)
        full_ver="${minor_ver:-$ver}"

        # GenericCloud (no LVM) — plain partitioned disk
        # Prefer GenericCloud-Base.latest (Rocky 10+), fall back to GenericCloud.latest (Rocky 9)
        local filename
        filename=$(echo "$listing" | extract_hrefs \
            | grep -E "^Rocky-${ver}-GenericCloud-Base\.latest\.x86_64\.qcow2$" | head -1)
        if [[ -z "$filename" ]]; then
            filename=$(echo "$listing" | extract_hrefs \
                | grep -E "^Rocky-${ver}-GenericCloud\.latest\.x86_64\.qcow2$" | head -1)
        fi
        if [[ -z "$filename" ]]; then
            # Match non-LVM builds: exclude filenames containing "-LVM"
            filename=$(echo "$listing" | extract_hrefs \
                | grep -E "^Rocky-${ver}-GenericCloud(-Base)?-[0-9].*\.x86_64\.qcow2$" \
                | grep -v '\-LVM' \
                | sort -V | tail -1)
        fi
        [[ -n "$filename" ]] && \
            echo "rocky-${full_ver}|Rocky Linux ${full_ver}|${base}/${ver}/images/x86_64/${filename}|rocky|"

        # GenericCloud-LVM — LVM-based root disk
        local lvm_filename
        lvm_filename=$(echo "$listing" | extract_hrefs \
            | grep -E "^Rocky-${ver}-GenericCloud-LVM\.latest\.x86_64\.qcow2$" | head -1)
        if [[ -z "$lvm_filename" ]]; then
            lvm_filename=$(echo "$listing" | extract_hrefs \
                | grep -E "^Rocky-${ver}-GenericCloud-LVM-.*\.x86_64\.qcow2$" \
                | sort -V | tail -1)
        fi
        [[ -n "$lvm_filename" ]] && \
            echo "rocky-${full_ver}-lvm|Rocky Linux ${full_ver} (LVM)|${base}/${ver}/images/x86_64/${lvm_filename}|rocky|"
    done
}


discover_opensuse() {
    local base="https://download.opensuse.org/distribution/leap"
    local page
    page=$(fetch_page "$base/") || return 1

    # Extract Leap version directories (skip 42.x legacy series)
    local versions
    versions=$(echo "$page" | extract_hrefs \
        | sed -n 's|^[0-9]*\.[0-9]*$|&|p' \
        | awk -F. '$1 >= 15 && $1 < 42' \
        | sort -Vr)

    local found=0
    for ver in $versions; do
        (( found >= MAX_VERSIONS )) && break
        local url=""

        # Prefer Cloud variant from distribution/leap appliances — this
        # path is served by the main mirror network and downloads reliably.
        # The Cloud:Images OBS repo (/repositories/Cloud:/Images:/) uses a
        # separate mirror tree where TLS and availability are unreliable.
        local listing filename=""
        listing=$(fetch_page "$base/$ver/appliances/") 2>/dev/null || true
        if [[ -n "$listing" ]]; then
            # Prefer versioned filename (with Build suffix) — mirrors may
            # not carry the short symlink that the origin server lists.
            filename=$(echo "$listing" | extract_hrefs \
                | grep -E "x86_64-.*Cloud-Build.*\.qcow2$" | grep -v 'encrypt\|xen' | head -1)
            if [[ -z "$filename" ]]; then
                for pattern in \
                    "openSUSE-Leap-${ver}-Minimal-VM.x86_64-Cloud.qcow2" \
                    "Leap-${ver}-Minimal-VM.x86_64-Cloud.qcow2"; do
                    filename=$(echo "$listing" | extract_hrefs | grep -F "$pattern" | head -1)
                    [[ -n "$filename" ]] && break
                done
            fi
            [[ -n "$filename" ]] && url="${base}/${ver}/appliances/${filename}"
        fi

        # Fall back to NoCloud variant from Cloud:Images OBS repo
        if [[ -z "$url" ]]; then
            local ci_base="https://download.opensuse.org/repositories/Cloud:/Images:/Leap_${ver}/images"
            local ci_listing
            ci_listing=$(fetch_page "$ci_base/") 2>/dev/null || true
            if [[ -n "$ci_listing" ]]; then
                local ci_filename
                ci_filename=$(echo "$ci_listing" | extract_hrefs \
                    | grep -E 'x86_64-.*-NoCloud-Build.*\.qcow2$' | head -1)
                if [[ -z "$ci_filename" ]]; then
                    ci_filename=$(echo "$ci_listing" | extract_hrefs \
                        | grep -F "x86_64-NoCloud.qcow2" | grep -v '\.sha256' | head -1)
                fi
                [[ -n "$ci_filename" ]] && url="${ci_base}/${ci_filename}"
            fi
        fi

        [[ -n "$url" ]] || continue
        echo "opensuse-${ver}|openSUSE Leap ${ver}|${url}|opensuse|ptp-fix"
        (( found++ )) || true
    done
}

discover_oracle() {
    local base="https://yum.oracle.com"
    local found=0

    for major in 10 9 8; do
        (( found >= MAX_VERSIONS )) && break
        local json
        json=$(fetch_page "$base/templates/OracleLinux/ol${major}-template.json") || continue

        # Parse KVM qcow2 filename from JSON (no jq dependency)
        local filename
        filename=$(echo "$json" | grep -o '"OL[0-9]*U[0-9]*_x86_64-kvm-b[0-9]*\.qcow2"' | tr -d '"' | head -1)
        [[ -n "$filename" ]] || continue

        local basepath
        basepath=$(echo "$json" | grep -o '"/templates/OracleLinux/OL'"$major"'/[^"]*"' | tr -d '"' | head -1)
        [[ -n "$basepath" ]] || continue

        local update
        update=$(echo "$filename" | sed 's/OL[0-9]*U\([0-9]*\).*/\1/')

        echo "oracle-${major}|Oracle Linux ${major}.${update}|${base}${basepath}/${filename}|oracle|"
        (( found++ )) || true
    done
}

discover_freebsd() {
    local base="https://download.freebsd.org/releases/VM-IMAGES"
    local page
    page=$(fetch_page "$base/") || return 1

    # Only RELEASE directories, skip ALPHA/BETA/RC
    local versions
    versions=$(echo "$page" | extract_hrefs \
        | sed -n 's/-RELEASE$//p' \
        | grep -xE '[0-9]+\.[0-9]+' \
        | sort -Vr | head -"$MAX_VERSIONS")

    for ver in $versions; do
        local listing filename
        listing=$(fetch_page "$base/${ver}-RELEASE/amd64/Latest/") || continue
        # BASIC-CLOUDINIT with UFS (cloud-init ready, xz-compressed)
        filename=$(echo "$listing" | extract_hrefs \
            | grep -F "FreeBSD-${ver}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz" | head -1)
        [[ -n "$filename" ]] || continue
        echo "freebsd-${ver}|FreeBSD ${ver}|${base}/${ver}-RELEASE/amd64/Latest/${filename}|freebsd|guest-agent"
    done
}

# --- Build catalog from mirrors (parallel) ------------------------------------
build_catalog() {
    local families
    if [[ -n "$DISTRO_FILTER" ]]; then
        families=("$DISTRO_FILTER")
    else
        families=(debian ubuntu rocky opensuse oracle freebsd)
    fi

    DISCOVERY_TMPDIR=$(mktemp -d)

    msg "Scanning distribution mirrors..."

    for family in "${families[@]}"; do
        # Disable strict mode in discovery subshells: grep returns 1 on no-match
        # which is expected when probing mirrors for available images.
        ( set +eo pipefail; discover_"${family}" ) > "$DISCOVERY_TMPDIR/$family" 2>/dev/null &
    done
    wait

    for family in "${families[@]}"; do
        if [[ -s "$DISCOVERY_TMPDIR/$family" ]]; then
            local count=0
            while IFS='|' read -r key name url fam customize; do
                [[ -n "$url" ]] || continue
                _reg "$key" "$name" "$url" "$fam" "$customize"
                (( count++ )) || true
            done < "$DISCOVERY_TMPDIR/$family"
            ok "${family}: ${count} image(s)"
        else
            warn "${family}: no images found"
        fi
    done
    echo

    rm -rf "$DISCOVERY_TMPDIR"
    DISCOVERY_TMPDIR=""
}

# --- Dependency checks --------------------------------------------------------
check_fetch_deps() {
    if ! command -v curl &>/dev/null && ! command -v wget &>/dev/null; then
        err "wget or curl is required for mirror scanning."
        exit 1
    fi
}

check_import_deps() {
    if (( API_MODE )); then
        if ! command -v curl &>/dev/null; then
            err "curl is required for API mode."
            exit 1
        fi
        return
    fi
    local missing=()
    for cmd in qm qemu-img; do
        command -v "$cmd" &>/dev/null || missing+=("$cmd")
    done
    if (( ${#missing[@]} > 0 )); then
        err "Missing required commands: ${missing[*]}"
        exit 1
    fi
    if (( CUSTOMIZE )); then
        if ! command -v virt-customize &>/dev/null; then
            warn "virt-customize not found — image customization disabled"
            warn "Install libguestfs-tools for guest-agent injection"
            CUSTOMIZE=0
        fi
    fi
}

# --- VMID helpers -------------------------------------------------------------
vmid_exists() {
    if (( API_MODE )); then
        pve_api GET "nodes/${API_NODE}/qemu/$1/status/current" &>/dev/null \
            || pve_api GET "nodes/${API_NODE}/lxc/$1/status/current" &>/dev/null
    else
        qm status "$1" &>/dev/null 2>&1 || pct status "$1" &>/dev/null 2>&1
    fi
}

# --- PVE REST API helpers -----------------------------------------------------
pve_api() {
    local method="$1" endpoint="$2"
    shift 2
    local -a curl_args=(-sk --max-time 30 -w '\n%{http_code}'
        -H "Authorization: PVEAPIToken=${API_TOKEN}")

    [[ "$method" != "GET" ]] && curl_args+=(-X "$method")

    for arg in "$@"; do
        curl_args+=(--data-urlencode "$arg")
    done

    local _raw _body _http_code
    _raw=$(curl "${curl_args[@]}" "${API_HOST}/api2/json/${endpoint}" 2>/dev/null) || {
        echo "pve_api: ${method} ${endpoint}: network error" >&2
        return 1
    }
    _http_code=$(echo "$_raw" | tail -1)
    _body=$(echo "$_raw" | sed '$d')

    if [[ "$_http_code" -ge 200 && "$_http_code" -lt 300 ]] 2>/dev/null; then
        echo "$_body"
    else
        echo "pve_api: ${method} ${endpoint}: HTTP ${_http_code} ${_body}" >&2
        return 1
    fi
}

pve_extract_upid() {
    grep -o '"data":"UPID:[^"]*"' | head -1 | sed 's/"data":"//;s/"$//'
}

pve_wait_task() {
    local upid="$1" timeout="${2:-120}"
    local elapsed=0

    while (( elapsed < timeout )); do
        local response status exitstatus
        response=$(pve_api GET "nodes/${API_NODE}/tasks/${upid}/status") || {
            sleep 3; (( elapsed += 3 )); continue
        }

        status=$(echo "$response" | grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4)

        if [[ "$status" == "stopped" ]]; then
            exitstatus=$(echo "$response" | grep -o '"exitstatus":"[^"]*"' | head -1 | cut -d'"' -f4)
            if [[ "$exitstatus" == "OK" ]]; then
                return 0
            else
                err "Task failed: ${exitstatus:-unknown}"
                return 1
            fi
        fi

        sleep 3
        (( elapsed += 3 ))
    done

    err "Task timed out after ${timeout}s: ${upid}"
    return 1
}

# --- Download -----------------------------------------------------------------
download_image() {
    local url="$1" dest="$2"
    if [[ -f "$dest" ]]; then
        ok "Cached: $(basename "$dest")"
        return 0
    fi
    msg "Downloading $(basename "$dest")..."
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} download → $(basename "$dest")"
        return 0
    fi
    mkdir -p "$(dirname "$dest")"
    # Prefer curl (consistent with fetch_page) — some mirror redirects
    # with percent-encoded paths are handled differently by wget.
    # Retry up to 3 times: openSUSE MirrorCache may route to a mirror
    # with transient TLS or connectivity issues.
    local attempt rc=1
    for attempt in 1 2 3; do
        if (( attempt > 1 )); then
            warn "Retry ${attempt}/3: $(basename "$dest")"
            rm -f "${dest}.part"
            sleep 2
        fi
        if command -v curl &>/dev/null; then
            curl -fL --progress-bar -o "${dest}.part" "$url" && { rc=0; break; }
        elif command -v wget &>/dev/null; then
            wget -q --show-progress -O "${dest}.part" "$url" && { rc=0; break; }
        fi
    done
    if (( rc )); then
        rm -f "${dest}.part"
        return 1
    fi
    mv "${dest}.part" "$dest"
}

# --- Probe image for OS version -----------------------------------------------
probe_image_version() {
    local image="$1" family="$2"
    local version=""

    # Debian: /etc/debian_version has the precise point release (e.g. 12.13)
    if [[ "$family" == "debian" ]]; then
        version=$(virt-cat -a "$image" /etc/debian_version 2>/dev/null \
            | tr -d '[:space:]')
        if [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then
            echo "$version"
            return 0
        fi
    fi

    # Generic: parse VERSION_ID from /etc/os-release
    local os_release
    os_release=$(virt-cat -a "$image" /etc/os-release 2>/dev/null) || return 1
    version=$(echo "$os_release" | sed -n 's/^VERSION_ID="\?\([^"]*\)"\?$/\1/p' | head -1)

    if [[ -n "$version" ]]; then
        echo "$version"
        return 0
    fi

    return 1
}

# --- Customize ----------------------------------------------------------------
customize_image() {
    local image="$1" action="$2" family="$3"
    (( CUSTOMIZE )) || return 0
    [[ -n "$action" ]] || return 0
    case "$action" in
        guest-agent)
            if [[ "$family" == "freebsd" ]]; then
                # FreeBSD UFS cannot be mounted by libguestfs (Linux has
                # read-only UFS2 support only) and FreeBSD uses nuageinit
                # (not cloud-init) so vendor-data directives aren't available.
                # Instructions are added to the template's VM notes instead.
                msg "FreeBSD: guest-agent must be installed manually (see VM notes)"
                return 0
            fi
            msg "Injecting qemu-guest-agent..."
            # libguestfs SLIRP networking uses 169.254.0.0/16 (not the
            # traditional 10.0.2.0/24).  The appliance does not bring up
            # the interface automatically, so we configure it manually.
            # Everything must run in a single --run-command because the
            # network state set via ip(8) does not persist across
            # separate virt-customize operations.
            # /etc/resolv.conf may be a dangling symlink (systemd-resolved),
            # so we force-create it via run-command and clean up afterwards.
            run virt-customize --network -a "$image" \
                --run-command 'ip link set eth0 up; ip addr add 169.254.2.15/16 dev eth0; ip route add default via 169.254.2.2; rm -f /etc/resolv.conf; echo nameserver 169.254.2.3 > /etc/resolv.conf; export DEBIAN_FRONTEND=noninteractive; apt-get -q update && apt-get -q -y install qemu-guest-agent || yum -y install qemu-guest-agent || zypper -n install qemu-guest-agent || true; rm -f /etc/resolv.conf'
            # virt-customize populates /etc/machine-id; reset it so each
            # clone gets a unique ID on first boot.
            run virt-customize -a "$image" \
                --run-command '[ -f /etc/machine-id ] && truncate -s 0 /etc/machine-id || true'
            ;;
        ptp-fix)
            msg "Enabling ptp_kvm module..."
            run virt-customize -a "$image" \
                --write /etc/modules-load.d/ptp_kvm.conf:ptp_kvm
            # virt-customize populates /etc/machine-id; reset it so each
            # clone gets a unique ID on first boot.
            run virt-customize -a "$image" \
                --run-command '[ -f /etc/machine-id ] && truncate -s 0 /etc/machine-id || true'
            ;;
    esac
}

# In API mode, use a cloud-init vendor-data snippet to install
# qemu-guest-agent on first boot.  This is used instead of virt-customize
# which is only available in local mode.
#
# NOTE: The PVE upload API only accepts 'iso' and 'vztmpl' content types;
# snippet uploads are not supported (Bugzilla #2208).  The snippet file
# must be pre-created on the storage as a one-time setup step.
VENDOR_SNIPPET_NAME="ci-qemu-guest-agent-vendor.yaml"
VENDOR_SNIPPET_VERIFIED=0   # 0=unknown, 1=exists, 2=missing

ensure_agent_vendor_snippet() {
    local snippet_storage="$1"

    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} cicustom vendor=${snippet_storage}:snippets/${VENDOR_SNIPPET_NAME}"
        return 0
    fi

    # Only check once per run
    if (( VENDOR_SNIPPET_VERIFIED == 0 )); then
        local listing
        listing=$(pve_api GET "nodes/${API_NODE}/storage/${snippet_storage}/content?content=snippets" 2>/dev/null) || listing=""

        if echo "$listing" | grep -q "${VENDOR_SNIPPET_NAME}"; then
            VENDOR_SNIPPET_VERIFIED=1
        else
            VENDOR_SNIPPET_VERIFIED=2
            warn "Vendor snippet '${VENDOR_SNIPPET_NAME}' not found on ${snippet_storage}"
            echo -e "   The PVE API does not support snippet uploads."
            echo -e "   Create it once on the PVE host:"
            echo -e "   ${C_CYAN}cat > /path/to/${snippet_storage}/snippets/${VENDOR_SNIPPET_NAME} << 'EOF'"
            echo -e "   #cloud-config"
            echo -e "   package_update: true"
            echo -e "   packages:"
            echo -e "     - qemu-guest-agent"
            echo -e "   runcmd:"
            echo -e "     - systemctl enable --now qemu-guest-agent"
            echo -e "   EOF${C_RESET}"
        fi
    fi

    (( VENDOR_SNIPPET_VERIFIED == 1 ))
}

# Add a VM description/notes with guest-agent install instructions for
# FreeBSD templates.  libguestfs cannot mount UFS2 (Linux kernel only
# supports read-only), and FreeBSD uses nuageinit instead of cloud-init
# so vendor-data package directives are not available.
set_freebsd_agent_notes() {
    local vmid="$1"
    local notes
    notes=$(printf '%s\n' \
        "FreeBSD cloud template." \
        "" \
        "Install QEMU guest agent after first boot (as root):" \
        '```' \
        "pkg install -y qemu-guest-agent" \
        "sysrc qemu_guest_agent_enable=YES" \
        "service qemu-guest-agent start" \
        '```' \
        "" \
        "To automate via cloud-init, use cicustom with a" \
        "user-data snippet containing packages and runcmd:" \
        '```' \
        "qm set <vmid> --cicustom \"user=${STORAGE}:snippets/freebsd-agent.yml\"" \
        '```' \
        "Note: cicustom user= replaces PVE-generated user-data," \
        "so the snippet must include hostname, SSH keys, etc.")
    if (( API_MODE )); then
        pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
            "description=${notes}" &>/dev/null || true
    else
        qm set "$vmid" --description "$notes" &>/dev/null || true
    fi
}

# --- Import a single image as template (local mode) ---------------------------
import_single_local() {
    local idx="$1"
    local key="${IMG_KEY[$idx]}"
    local name="${IMG_NAME[$idx]}"
    local url="${IMG_URL[$idx]}"
    local customize="${IMG_CUSTOMIZE[$idx]}"
    local vmid=$(( START_ID + idx + 1 ))
    # PVE VM names must be valid DNS hostnames (no underscores)
    local safe_arch="${ARCH//_/-}"
    local tpl_name="ci-${key}-${TIMESTAMP}.${safe_arch}"
    local filename work_image
    filename=$(basename "$url")

    msg "Importing ${C_BOLD}${name}${C_RESET} → ${tpl_name} (VMID ${vmid})"

    # Check for collision
    if vmid_exists "$vmid"; then
        if (( FORCE )); then
            warn "VMID ${vmid} exists — destroying (--force)"
            run qm destroy "$vmid" --purge 2>/dev/null || true
        else
            warn "VMID ${vmid} exists — skipping (use --force to replace)"
            return 0
        fi
    fi

    # Download
    download_image "$url" "${CACHE_DIR}/${filename}" || {
        err "Download failed: ${name}"; return 1
    }

    # Decompress xz-compressed images (e.g. FreeBSD)
    local image_file="${CACHE_DIR}/${filename}"
    if [[ "$filename" == *.xz ]]; then
        local decompressed="${image_file%.xz}"
        if [[ ! -f "$decompressed" ]]; then
            msg "Decompressing $(basename "$filename")..."
            if (( DRY_RUN )); then
                echo -e "   ${C_YELLOW}[dry-run]${C_RESET} xz -dk ${image_file}"
            else
                xz -dk "$image_file"
            fi
        else
            ok "Cached: $(basename "$decompressed")"
        fi
        image_file="$decompressed"
    fi

    # Working copy (so the cache stays pristine)
    work_image="${CACHE_DIR}/${key}.work.qcow2"
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} cp ${image_file} ${work_image}"
    else
        cp "$image_file" "$work_image"
    fi

    # Probe image for precise OS version (e.g. Debian point release)
    if (( ! DRY_RUN )) && command -v virt-cat &>/dev/null; then
        local probed_ver
        probed_ver=$(probe_image_version "$work_image" "${IMG_FAMILY[$idx]}") || true
        if [[ -n "$probed_ver" ]]; then
            local suffix=""
            [[ "$key" == *-lvm ]] && suffix="-lvm"
            local new_key="${IMG_FAMILY[$idx]}-${probed_ver}${suffix}"
            if [[ "$new_key" != "$key" ]]; then
                ok "Probed OS version: ${probed_ver}"
                key="$new_key"
                tpl_name="ci-${key}-${TIMESTAMP}.${safe_arch}"
            fi
        fi
    fi

    # Customize (guest-agent, ptp_kvm, etc.)
    customize_image "$work_image" "$customize" "${IMG_FAMILY[$idx]}"

    # Resize boot disk (optional)
    if [[ -n "$DISK_SIZE" ]]; then
        msg "Resizing disk → ${DISK_SIZE}"
        run qemu-img resize "$work_image" "$DISK_SIZE"
    fi

    # Create VM shell (no disks — they're added in later steps)
    local ostype="l26"
    [[ "${IMG_FAMILY[$idx]}" == "freebsd" ]] && ostype="other"

    run qm create "$vmid" --name "$tpl_name" \
        --cores 2 --memory 2048 \
        --net0 "virtio,firewall=1,bridge=${BRIDGE}" \
        --scsihw virtio-scsi-pci \
        --bios ovmf --machine q35 \
        --agent enabled=1 --ostype "$ostype" \
        --cpu cputype=max \
        --serial0 socket --vga serial0

    # Import cloud image disk into PVE storage.
    # This is the first disk operation on a fresh VM, so the imported disk
    # will be disk-0.  EFI and cloud-init are added afterwards.
    msg "Importing disk → ${STORAGE} (format: ${DISK_FORMAT})"
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} qm importdisk ${vmid} ${work_image} ${STORAGE} --format ${DISK_FORMAT}"
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} qm set ${vmid} --scsi0 ${STORAGE}:vm-${vmid}-disk-0,discard=on,ssd=1"
    else
        local import_out
        import_out=$(qm importdisk "$vmid" "$work_image" "$STORAGE" \
                     --format "$DISK_FORMAT" 2>&1) || {
            err "Disk import failed for ${name}"
            qm destroy "$vmid" --purge 2>/dev/null || true
            rm -f "$work_image"
            return 1
        }
        echo "$import_out" | tail -1

        # Parse volume reference from import output.  Supported formats:
        #   PVE 8.x: Successfully imported disk as 'unused0:STORAGE:vm-VMID-disk-N'
        #   PVE 9.x: unused0: successfully imported disk 'STORAGE:VMID/vm-VMID-disk-N.fmt'
        local disk_ref
        disk_ref=$(echo "$import_out" \
                   | sed -n "s/.*'unused[0-9]*:\(.*\)'.*/\1/p; s/.*imported disk '\([^']*\)'.*/\1/p" \
                   | tail -1)
        if [[ -z "$disk_ref" ]]; then
            err "Could not parse imported disk reference"
            qm destroy "$vmid" --purge 2>/dev/null || true
            rm -f "$work_image"
            return 1
        fi
        qm set "$vmid" --scsi0 "${disk_ref},discard=on,ssd=1"
    fi

    # EFI disk (OVMF)
    run qm set "$vmid" --efidisk0 "${STORAGE}:0,efitype=4m"

    # Cloud-init drive (on IDE to avoid SCSI bus numbering issues)
    run qm set "$vmid" --ide2 "${STORAGE}:cloudinit"

    # Boot order
    run qm set "$vmid" --boot order=scsi0

    # FreeBSD: add guest-agent install instructions to VM notes
    if [[ "${IMG_FAMILY[$idx]}" == "freebsd" ]]; then
        set_freebsd_agent_notes "$vmid"
    fi

    # Seal as template
    run qm template "$vmid"

    # Clean up working copy (keep cached download)
    rm -f "$work_image"

    ok "Template ${C_BOLD}${tpl_name}${C_RESET} (VMID ${vmid}) ready"
}

# --- Import a single image as template (API mode) ----------------------------
import_single_api() {
    local idx="$1"
    local key="${IMG_KEY[$idx]}"
    local name="${IMG_NAME[$idx]}"
    local url="${IMG_URL[$idx]}"
    local customize="${IMG_CUSTOMIZE[$idx]}"
    local vmid=$(( START_ID + idx + 1 ))
    # PVE VM names must be valid DNS hostnames (no underscores)
    local safe_arch="${ARCH//_/-}"
    local tpl_name="ci-${key}-${TIMESTAMP}.${safe_arch}"
    local filename
    filename=$(basename "$url")
    # PVE download-url rejects .img extension; rename to .qcow2 for import
    [[ "$filename" == *.img ]] && filename="${filename%.img}.qcow2"

    msg "Importing ${C_BOLD}${name}${C_RESET} → ${tpl_name} (VMID ${vmid}) [API mode]"

    # Skip xz-compressed images (e.g. FreeBSD) in API mode
    if [[ "$filename" == *.xz ]]; then
        warn "Skipping ${name}: .xz compressed images not supported in API mode"
        warn "Use --mode local for this image"
        return 0
    fi

    # Warn about skipped customization (guest-agent is handled via vendor-data)
    if [[ -n "$customize" && "$customize" != "guest-agent" ]]; then
        warn "Customization '${customize}' skipped in API mode (use cloud-init instead)"
    fi

    # Check for collision
    if vmid_exists "$vmid"; then
        if (( FORCE )); then
            warn "VMID ${vmid} exists — destroying (--force)"
            if (( DRY_RUN )); then
                echo -e "   ${C_YELLOW}[dry-run]${C_RESET} DELETE nodes/${API_NODE}/qemu/${vmid}?purge=1"
            else
                local response upid
                response=$(pve_api DELETE \
                    "nodes/${API_NODE}/qemu/${vmid}?purge=1&destroy-unreferenced-disks=1") || true
                upid=$(echo "$response" | pve_extract_upid)
                [[ -z "$upid" ]] || pve_wait_task "$upid" 60
            fi
        else
            warn "VMID ${vmid} exists — skipping (use --force to replace)"
            return 0
        fi
    fi

    # Download image to PVE import storage
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} POST nodes/${API_NODE}/storage/${API_IMPORT_STORAGE}/download-url"
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET}   url=${url}"
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET}   filename=${filename}, content=import"
    else
        # Check if image is already on the import storage
        if pve_api GET \
            "nodes/${API_NODE}/storage/${API_IMPORT_STORAGE}/content/${API_IMPORT_STORAGE}:import/${filename}" \
            &>/dev/null; then
            ok "Image already on PVE: ${filename}"
        else
            msg "Downloading ${filename} via PVE..."
            local response upid
            response=$(pve_api POST \
                "nodes/${API_NODE}/storage/${API_IMPORT_STORAGE}/download-url" \
                "url=${url}" "content=import" "filename=${filename}") || {
                err "Failed to initiate download for ${name}"
                return 1
            }
            upid=$(echo "$response" | pve_extract_upid)
            if [[ -z "$upid" ]]; then
                err "No task ID returned for download"
                return 1
            fi
            ok "Download task: ${upid}"
            pve_wait_task "$upid" 600 || {
                err "Download failed for ${name}"
                return 1
            }
            ok "Download complete"
        fi
    fi

    # Create VM shell (no disks — added in subsequent steps)
    local ostype="l26"
    [[ "${IMG_FAMILY[$idx]}" == "freebsd" ]] && ostype="other"

    msg "Creating VM ${vmid}..."
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} POST nodes/${API_NODE}/qemu"
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET}   vmid=${vmid} name=${tpl_name} cores=2 memory=2048"
    else
        local response upid
        response=$(pve_api POST "nodes/${API_NODE}/qemu" \
            "vmid=${vmid}" "name=${tpl_name}" \
            "cores=2" "memory=2048" \
            "net0=virtio,firewall=1,bridge=${BRIDGE}" \
            "scsihw=virtio-scsi-pci" \
            "bios=ovmf" "machine=q35" \
            "agent=enabled=1" "ostype=${ostype}" \
            "cpu=cputype=max" \
            "serial0=socket" "vga=serial0") || {
            err "Failed to create VM ${vmid}"
            return 1
        }
        upid=$(echo "$response" | pve_extract_upid)
        if [[ -n "$upid" ]]; then
            pve_wait_task "$upid" 60 || {
                err "VM creation failed for ${name}"
                return 1
            }
        fi
        ok "VM ${vmid} created"
    fi

    # Import disk via import-from
    local import_vol="${API_IMPORT_STORAGE}:import/${filename}"
    local scsi0_spec="${STORAGE}:0,import-from=${import_vol},format=${DISK_FORMAT},discard=on,ssd=1"

    msg "Importing disk → ${STORAGE} (format: ${DISK_FORMAT})"
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT nodes/${API_NODE}/qemu/${vmid}/config"
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET}   scsi0=${scsi0_spec}"
    else
        local response upid
        response=$(pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
            "scsi0=${scsi0_spec}") || {
            err "Disk import failed for ${name}"
            pve_api DELETE "nodes/${API_NODE}/qemu/${vmid}?purge=1" &>/dev/null || true
            return 1
        }
        upid=$(echo "$response" | pve_extract_upid)
        if [[ -n "$upid" ]]; then
            pve_wait_task "$upid" 300 || {
                err "Disk import failed for ${name}"
                pve_api DELETE "nodes/${API_NODE}/qemu/${vmid}?purge=1" &>/dev/null || true
                return 1
            }
        fi
        ok "Disk imported"
    fi

    # EFI disk (OVMF)
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT nodes/${API_NODE}/qemu/${vmid}/config efidisk0=${STORAGE}:0,efitype=4m"
    else
        local response upid
        response=$(pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
            "efidisk0=${STORAGE}:0,efitype=4m") || {
            err "EFI disk creation failed"; return 1
        }
        upid=$(echo "$response" | pve_extract_upid)
        [[ -z "$upid" ]] || pve_wait_task "$upid" 60 || { err "EFI disk creation failed"; return 1; }
    fi

    # Cloud-init drive (on IDE to avoid SCSI bus numbering issues)
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT nodes/${API_NODE}/qemu/${vmid}/config ide2=${STORAGE}:cloudinit"
    else
        local response upid
        response=$(pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
            "ide2=${STORAGE}:cloudinit") || {
            err "Cloud-init drive creation failed"; return 1
        }
        upid=$(echo "$response" | pve_extract_upid)
        [[ -z "$upid" ]] || pve_wait_task "$upid" 60 || { err "Cloud-init drive creation failed"; return 1; }
    fi

    # Boot order
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT nodes/${API_NODE}/qemu/${vmid}/config boot=order=scsi0"
    else
        pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
            "boot=order=scsi0" &>/dev/null || true
    fi

    # Vendor-data snippet for guest-agent (API mode only, Linux distros)
    if [[ "$customize" == "guest-agent" && "${IMG_FAMILY[$idx]}" != "freebsd" ]]; then
        if ensure_agent_vendor_snippet "$STORAGE"; then
            if (( DRY_RUN )); then
                echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT cicustom=vendor=${STORAGE}:snippets/${VENDOR_SNIPPET_NAME}"
            else
                pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/config" \
                    "cicustom=vendor=${STORAGE}:snippets/${VENDOR_SNIPPET_NAME}" &>/dev/null || {
                    warn "Could not set vendor-data snippet for guest-agent"
                }
            fi
        fi
    fi

    # Resize boot disk (optional)
    if [[ -n "$DISK_SIZE" ]]; then
        msg "Resizing disk → ${DISK_SIZE}"
        if (( DRY_RUN )); then
            echo -e "   ${C_YELLOW}[dry-run]${C_RESET} PUT nodes/${API_NODE}/qemu/${vmid}/resize disk=scsi0 size=${DISK_SIZE}"
        else
            pve_api PUT "nodes/${API_NODE}/qemu/${vmid}/resize" \
                "disk=scsi0" "size=${DISK_SIZE}" &>/dev/null || {
                warn "Disk resize failed (template may have smaller disk)"
            }
        fi
    fi

    # Seal as template
    if (( DRY_RUN )); then
        echo -e "   ${C_YELLOW}[dry-run]${C_RESET} POST nodes/${API_NODE}/qemu/${vmid}/template"
    else
        local response upid
        response=$(pve_api POST "nodes/${API_NODE}/qemu/${vmid}/template") || {
            err "Template conversion failed"; return 1
        }
        upid=$(echo "$response" | pve_extract_upid)
        [[ -z "$upid" ]] || pve_wait_task "$upid" 60 || { err "Template conversion failed"; return 1; }
        ok "Template sealed"
    fi

    # FreeBSD: add guest-agent install instructions to VM notes
    if [[ "${IMG_FAMILY[$idx]}" == "freebsd" ]]; then
        set_freebsd_agent_notes "$vmid"
    fi

    # Clean up import image from storage
    if (( ! DRY_RUN )); then
        pve_api DELETE \
            "nodes/${API_NODE}/storage/${API_IMPORT_STORAGE}/content/${API_IMPORT_STORAGE}:import/${filename}" \
            &>/dev/null || true
    fi

    ok "Template ${C_BOLD}${tpl_name}${C_RESET} (VMID ${vmid}) ready"
}

# --- Catalog display ----------------------------------------------------------
list_catalog() {
    if (( IMG_COUNT == 0 )); then
        err "No images discovered."
        exit 1
    fi
    local safe_arch="${ARCH//_/-}"
    printf "\n  ${C_BOLD}%-4s  %-6s  %-28s  %-38s  %s${C_RESET}\n" \
        "#" "VMID" "NAME" "TEMPLATE" "URL"
    printf "  %-4s  %-6s  %-28s  %-38s  %s\n" \
        "---" "------" "----------------------------" "--------------------------------------" "---"
    for (( i=0; i<IMG_COUNT; i++ )); do
        printf "  %-4d  %-6d  %-28s  %-38s  %s\n" \
            $(( i + 1 )) $(( START_ID + i + 1 )) "${IMG_NAME[$i]}" \
            "ci-${IMG_KEY[$i]}-${TIMESTAMP}.${safe_arch}" "${IMG_URL[$i]}"
    done
    echo
}

# --- Interactive selection ----------------------------------------------------
interactive_select() {
    list_catalog
    echo -e "  ${C_BOLD}Select images (space-separated numbers," \
            "${C_CYAN}a${C_RESET}${C_BOLD}=all," \
            "${C_CYAN}q${C_RESET}${C_BOLD}=quit):${C_RESET}"
    read -r -p "  > " selection

    case "$selection" in
        q|Q) echo "Aborted."; exit 0 ;;
        a|A)
            for (( i=0; i<IMG_COUNT; i++ )); do
                SELECTED+=("$i")
            done
            return
            ;;
    esac

    for num in $selection; do
        if [[ "$num" =~ ^[0-9]+$ ]] && (( num >= 1 && num <= IMG_COUNT )); then
            SELECTED+=("$(( num - 1 ))")
        else
            warn "Invalid selection: $num"
        fi
    done

    if (( ${#SELECTED[@]} == 0 )); then
        err "No valid images selected."
        exit 1
    fi
}

# --- Usage / Help -------------------------------------------------------------
usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Import upstream cloud images as Proxmox VE templates.

Images are discovered dynamically from distribution mirrors.  The last
$MAX_VERSIONS releases are offered for each distribution family.

Modes:
  -i, --interactive     Select images interactively (default on TTY)
  -b, --batch           Import all discovered images non-interactively
  -l, --list            List discovered images and exit

Options:
  -s, --storage NAME    Target storage (default: local-lvm)
  -B, --bridge NAME     Network bridge (default: vmbr0)
  -f, --format FMT      Disk format: raw, qcow2 (default: raw)
  -S, --disk-size SIZE  Resize boot disk (e.g. 60G; default: keep original)
  -I, --start-id ID     Starting VM ID (default: 9000)
  -d, --distro NAME     Filter by family: debian ubuntu rocky opensuse oracle freebsd
      --force           Replace existing templates with same VMID
      --no-customize    Skip virt-customize (guest-agent injection, etc.)
      --arch ARCH       Target architecture (default: x86_64)
  -n, --dry-run         Show what would be done without making changes
  -h, --help            Show this help message
  -v, --version         Show version

Execution mode:
  -m, --mode MODE        Execution mode (required):
                           local — run directly on a PVE node (uses qm commands)
                           api   — run remotely via REST API (requires PVE 8.4+)
      --api-host URL       PVE API URL (e.g. https://pve.example.com:8006)
      --api-node NAME      PVE node name (e.g. pve1)
      --api-token TOKEN    API token: user@realm!tokenid=secret
      --api-import-storage NAME
                           Storage for downloading images (default: local)
                           Must support 'import' content type (directory-backed).
                           VM disks are stored on --storage as usual.

Examples:
  $(basename "$0") -l                     List available images (no --mode needed)
  $(basename "$0") -m local -i            Interactive selection
  $(basename "$0") -m local -b            Import all discovered images
  $(basename "$0") -m local -b -d debian  Debian images only
  $(basename "$0") -m local -b -s pool    Use specific storage backend
  $(basename "$0") -m local -b -S 60G     Resize disks to 60G
  $(basename "$0") -m local -n -b         Dry run, all images

  # API mode (remote):
  $(basename "$0") -m api --api-host https://pve:8006 \\
      --api-node pve1 --api-token 'user@pam!token=secret' -b -d debian
EOF
}

# --- Main ---------------------------------------------------------------------
main() {
    declare -a SELECTED=()

    # Parse arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --help|-h)         usage; exit 0 ;;
            --version|-v)      echo "pve-import-cloud-images $VERSION"; exit 0 ;;
            --interactive|-i)  MODE="interactive"; shift ;;
            --batch|-b)        MODE="batch"; shift ;;
            --list|-l)         MODE="list"; shift ;;
            --storage|-s)      STORAGE="$2"; shift 2 ;;
            --bridge|-B)       BRIDGE="$2"; shift 2 ;;
            --format|-f)       DISK_FORMAT="$2"; shift 2 ;;
            --disk-size|-S)    DISK_SIZE="$2"; shift 2 ;;
            --start-id|-I)     START_ID="$2"; shift 2 ;;
            --distro|-d)       DISTRO_FILTER="$2"; shift 2 ;;
            --force)           FORCE=1; shift ;;
            --no-customize)    CUSTOMIZE=0; shift ;;
            --dry-run|-n)      DRY_RUN=1; shift ;;
            --arch)            ARCH="$2"; shift 2 ;;
            --mode|-m)
                case "$2" in
                    local) API_MODE=0 ;;
                    api)   API_MODE=1 ;;
                    *)     err "Unknown mode: $2 (must be 'local' or 'api')"; exit 1 ;;
                esac
                MODE_SET=1; shift 2 ;;
            --api-host)        API_HOST="$2"; shift 2 ;;
            --api-node)        API_NODE="$2"; shift 2 ;;
            --api-token)       API_TOKEN="$2"; shift 2 ;;
            --api-import-storage) API_IMPORT_STORAGE="$2"; shift 2 ;;
            -*)                err "Unknown option: $1"; usage; exit 1 ;;
            *)                 err "Unexpected argument: $1"; usage; exit 1 ;;
        esac
    done

    # Auto-detect mode
    if [[ -z "$MODE" ]]; then
        [[ -t 0 && -t 1 ]] && MODE="interactive" || MODE="batch"
    fi

    # Validate distro filter
    if [[ -n "$DISTRO_FILTER" ]]; then
        case "$DISTRO_FILTER" in
            debian|ubuntu|rocky|opensuse|oracle|freebsd) ;;
            *) err "Unknown distro family: $DISTRO_FILTER"; exit 1 ;;
        esac
    fi

    # Validate architecture
    case "$ARCH" in
        x86_64) ;;  # Currently the only supported PVE target
        # Future: aarch64, riscv64, etc. when PVE adds support
        *) err "Unsupported architecture: $ARCH (currently only x86_64)"; exit 1 ;;
    esac

    # Validate API mode
    if (( API_MODE )); then
        if [[ -z "$API_HOST" || -z "$API_NODE" || -z "$API_TOKEN" ]]; then
            err "API mode requires --api-host, --api-node, and --api-token"
            exit 1
        fi
        # Strip trailing slash from API host
        API_HOST="${API_HOST%/}"
        # Validate token format (user@realm!tokenid=secret)
        if [[ ! "$API_TOKEN" =~ ^[^@]+@[^!]+![^=]+=.+ ]]; then
            err "Invalid API token format. Expected: user@realm!tokenid=secret"
            exit 1
        fi
        # virt-customize not available in API mode; guest-agent uses vendor-data instead
        if (( CUSTOMIZE )); then
            CUSTOMIZE=0
        fi
    fi

    # Discovery needs network tools
    check_fetch_deps

    # Discover images from mirrors (parallel)
    build_catalog

    # List mode (no root / PVE tools needed)
    if [[ "$MODE" == "list" ]]; then
        list_catalog
        exit 0
    fi

    # --mode is required for import operations
    if (( ! MODE_SET )); then
        err "--mode is required. Use '--mode local' or '--mode api'."
        exit 1
    fi

    # Import modes need root and PVE tools (dry-run can skip these)
    if (( ! DRY_RUN )); then
        if (( ! API_MODE )) && [[ $EUID -ne 0 ]]; then
            err "This script must be run as root (or use --mode api for remote mode)."
            exit 1
        fi
        check_import_deps
    fi

    # Build selection
    case "$MODE" in
        interactive) interactive_select ;;
        batch)
            for (( i=0; i<IMG_COUNT; i++ )); do
                SELECTED+=("$i")
            done
            ;;
    esac

    if (( ${#SELECTED[@]} == 0 )); then
        err "No images to import."
        exit 1
    fi

    (( DRY_RUN )) && echo -e "${C_YELLOW}=== DRY RUN MODE ===${C_RESET}\n"

    msg "Importing ${#SELECTED[@]} image(s)..."
    echo

    for idx in "${SELECTED[@]}"; do
        if (( API_MODE )); then
            import_single_api "$idx" || (( ERRORS++ )) || true
        else
            import_single_local "$idx" || (( ERRORS++ )) || true
        fi
        echo
    done

    if (( ERRORS )); then
        err "${ERRORS} image(s) failed."
        exit 1
    else
        msg "Done. All templates imported successfully."
    fi
}

main "$@"
