#!/bin/bash
#
# schupfn - run toolbox containers in VMs via qemu
#
# SPDX-License-Identifier: Apache-2.0

PROGNAME="schupfn"
VERSION="0.2.0"
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/schupfn/images"
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then
    SESSION_DIR="$XDG_RUNTIME_DIR/schupfn/sessions"
else
    SESSION_DIR=""
fi
META_FILE=".schupfn-meta"

# ── Colors ───────────────────────────────────────────────────────────────────

if [ -t 2 ]; then
    C_RED=$'\033[1;31m'
    C_YELLOW=$'\033[1;33m'
    C_RESET=$'\033[0m'
else
    C_RED=""
    C_YELLOW=""
    C_RESET=""
fi

if [ -t 1 ]; then
    C_CYAN=$'\033[0;36m'
    C_BOLD=$'\033[1m'
    C_RESET_OUT=$'\033[0m'
else
    C_CYAN=""
    C_BOLD=""
    C_RESET_OUT=""
fi

# ── Helpers ──────────────────────────────────────────────────────────────────

die() {
    echo "${C_RED}error:${C_RESET} $*" >&2
    exit 1
}

warn() {
    echo "${C_YELLOW}warning:${C_RESET} $*" >&2
}

info() {
    echo "${C_CYAN}::${C_RESET_OUT} $*"
}

todo() {
    stdbuf -oL echo -n " - $*"
}

todo_done() {
    echo -e "\r ✓"
}

check_command() {
    command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed or not in PATH"
}

check_prerequisites() {
    check_command podman
    check_command buildah
    check_command qemu-system-x86_64
    check_command qemu-img
    check_command virt-make-fs
    check_command virt-customize
    check_command ssh
}

# Validate that a container name is safe for use in path construction.
# Rejects names containing path separators or traversal sequences.
validate_container_name() {
    local name="$1"
    case "$name" in
        */*|*..*)
            die "invalid container name '$name': must not contain '/' or '..'"
            ;;
        .*)
            die "invalid container name '$name': must not start with '.'"
            ;;
    esac
}

# Classify a single export path as a directory or file.
# Directories are appended to rw_mount_dirs, ro_mount_dirs, or
# cow_mount_dirs (depending on mode). Files are appended to dotfiles.
# Symlinks that point to a directory also produce a rootfs_symlinks entry.
#
# These arrays must exist in the caller's scope:
#   rw_mount_dirs, ro_mount_dirs, cow_mount_dirs, rootfs_symlinks, dotfiles
#
# $1 = path, $2 = "rw", "ro", or "cow"
classify_export() {
    local path="$1"
    local mode="$2"
    # Expand ~ in case the shell didn't (e.g. quoted argument)
    path="${path/#\~\//$HOME/}"
    if [ ! -e "$path" ]; then
        die "export path does not exist: $path"
    fi

    # Paths with spaces or colons cannot be passed via the kernel command
    # line (parsed by word-splitting in the guest mount script).
    if [[ "$path" == *" "* || "$path" == *$'\t'* ]]; then
        die "export path contains whitespace, which is not supported: $path"
    fi
    if [[ "$path" == *":"* ]]; then
        die "export path contains a colon, which is not supported: $path"
    fi

    if [ -d "$path" ]; then
        local resolved
        resolved="$(realpath "$path")"
        case "$mode" in
            rw)  rw_mount_dirs+=("$resolved") ;;
            ro)  ro_mount_dirs+=("$resolved") ;;
            cow) cow_mount_dirs+=("$resolved") ;;
        esac

        # If the path was a symlink, remember to recreate it in the rootfs
        local abs_dir
        abs_dir="$(cd "$path" && pwd -L)"
        if [ "$abs_dir" != "$resolved" ]; then
            rootfs_symlinks+=("$abs_dir:$resolved")
        fi
    else
        if [ "$mode" = "cow" ]; then
            die "cannot use --export-cow with a file: $path (only directories are supported)"
        fi
        # Regular file (or symlink to a file): use the absolute path of
        # the link itself (not its target) so the file is placed at the
        # original path inside the VM.  tar -h dereferences the symlink
        # at copy time.
        local abs_file
        abs_file="$(cd "$(dirname "$path")" && echo "$PWD/$(basename "$path")")"
        dotfiles+=("$abs_file")
    fi
}

# Check for nested export conflicts between mount dir arrays.
# A mount on a parent directory shadows any child mount, so
# --export-ro /foo --export-rw /foo/bar cannot work.
#
# These arrays must exist in the caller's scope:
#   rw_mount_dirs, ro_mount_dirs, cow_mount_dirs
check_nested_export_conflicts() {
    # Build a combined list of (path, mode) pairs to check
    local -a all_paths=() all_modes=()
    local d
    for d in "${rw_mount_dirs[@]}"; do
        all_paths+=("$d"); all_modes+=("rw")
    done
    for d in "${ro_mount_dirs[@]}"; do
        all_paths+=("$d"); all_modes+=("ro")
    done
    for d in "${cow_mount_dirs[@]}"; do
        all_paths+=("$d"); all_modes+=("cow")
    done

    local i j
    for (( i=0; i<${#all_paths[@]}; i++ )); do
        for (( j=i+1; j<${#all_paths[@]}; j++ )); do
            local a="${all_paths[$i]}" a_mode="${all_modes[$i]}"
            local b="${all_paths[$j]}" b_mode="${all_modes[$j]}"

            # Same mode — nesting is redundant but not harmful
            [[ "$a_mode" = "$b_mode" ]] && continue

            # Exact same path with different modes
            if [[ "$a" = "$b" ]]; then
                die "conflicting exports: '$a' is exported as both" \
                    "$a_mode and $b_mode. Remove one of the duplicate exports."
            fi

            local a_prefix="$a" b_prefix="$b"
            [[ "$a_prefix" != "/" ]] && a_prefix+="/"
            [[ "$b_prefix" != "/" ]] && b_prefix+="/"

            if [[ "$b" = "$a_prefix"* ]]; then
                die "conflicting exports: '$b' ($b_mode) is nested inside" \
                    "'$a' ($a_mode). The mount on the parent shadows the" \
                    "child. Remove one or use the same export mode for both."
            fi
            if [[ "$a" = "$b_prefix"* ]]; then
                die "conflicting exports: '$a' ($a_mode) is nested inside" \
                    "'$b' ($b_mode). The mount on the parent shadows the" \
                    "child. Remove one or use the same export mode for both."
            fi
        done
    done
}

# ── SSH / VM lifecycle ──────────────────────────────────────────────────────

# Find a free port for SSH.
# We pick a random port in the ephemeral range and verify it's not in use.
find_free_port() {
    local port
    for _ in $(seq 1 20); do
        port=$((RANDOM % 16384 + 49152))
        # Check that nothing is listening on this port (covers TCP case)
        if ! ss -tln "sport = :$port" 2>/dev/null | grep -q "$port"; then
            echo "$port"
            return 0
        fi
    done
    die "failed to find a free port for SSH"
}

# Wait until SSH inside the VM is reachable, then return.
# $1 = port, remaining args = SSH key files to try
wait_for_ssh() {
    local port="$1"
    shift
    local -a keys=("$@")
    local timeout=30
    local elapsed=0

    # Build -i flags for all provided keys
    local -a key_flags=()
    for k in "${keys[@]}"; do
        key_flags+=(-i "$k")
    done

    todo "waiting for VM to boot..."

    # Initial delay: qemu's SLIRP stack accepts TCP connections on the
    # hostfwd port immediately (before the guest has networking), but
    # can't forward data until the guest's virtio-net is up.  Early
    # connections get stuck in SLIRP's backlog and never complete the
    # SSH handshake.  A too-short delay causes spurious connections
    # that trigger OpenSSH's per-source penalty system, making
    # subsequent attempts even slower.
    sleep 5
    elapsed=5

    while [ "$elapsed" -lt "$timeout" ]; do
        # Check that the background VM process is still alive
        if [ -n "${QEMU_PID:-}" ] && ! kill -0 "$QEMU_PID" 2>/dev/null; then
            # Show qemu log if available to help diagnose the failure
            if [ -n "${QEMU_LOG:-}" ] && [ -f "$QEMU_LOG" ] && [ -s "$QEMU_LOG" ]; then
                warn "qemu output:"
                sed 's/^/  /' "$QEMU_LOG" >&2
            fi
            die "VM process exited before SSH became available"
        fi

        if ssh -4 -F /dev/null \
               -o BatchMode=yes \
               -o ConnectTimeout=3 \
               -o ServerAliveInterval=2 \
               -o ServerAliveCountMax=1 \
               -o StrictHostKeyChecking=no \
               -o UserKnownHostsFile=/dev/null \
               -o IdentitiesOnly=yes \
               -o ControlPath=none \
               -o PubkeyAcceptedAlgorithms=+ssh-rsa \
               -o LogLevel=ERROR \
               "${key_flags[@]}" \
               -p "$port" \
               127.0.0.1 true >/dev/null 2>&1; then
            todo_done
            return 0
        fi

        sleep 1
        elapsed=$((elapsed + 1))
    done

    # Show SSH debug output to help diagnose the failure
    warn "SSH connection failed. Debug output:"
    ssh -4 -F /dev/null -v -o BatchMode=yes \
        -o ConnectTimeout=5 \
        -o StrictHostKeyChecking=no \
        -o UserKnownHostsFile=/dev/null \
        -o IdentitiesOnly=yes \
        -o ControlPath=none \
        -o PubkeyAcceptedAlgorithms=+ssh-rsa \
        "${key_flags[@]}" \
        -p "$port" \
        127.0.0.1 true 2>&1 | sed 's/^/  /' >&2
    die "timed out waiting for SSH (${timeout}s). Keys: ${keys[*]}"
}

# Collect all PIDs in the process tree rooted at $1 (depth-first).
_pstree_pids() {
    local pid="$1"
    local children
    children=$(ps -o pid= --ppid "$pid" 2>/dev/null) || true
    for child in $children; do
        _pstree_pids "$child"
    done
    echo "$pid"
}

# Clean up the background VM process. Called via trap on EXIT.
#
# If any 'join' sessions are still connected, the function waits for
# them to disconnect before shutting down.
cleanup_vm() {
    if [ -n "${QEMU_PID:-}" ] && kill -0 "$QEMU_PID" 2>/dev/null; then

        # Wait for active join sessions to finish
        if [ -n "${SESSION_NAME:-}" ]; then
            local joins
            joins=$(count_active_joins "$SESSION_NAME" "$QEMU_PID")
            if [ "$joins" -gt 0 ]; then
                warn "VM kept alive — $joins active join session(s) still connected"
                info "waiting for join session(s) to disconnect before shutting down..."
                local wait_count=0
                while true; do
                    joins=$(count_active_joins "$SESSION_NAME" "$QEMU_PID")
                    [ "$joins" -gt 0 ] || break
                    wait_count=$((wait_count + 1))
                    if [ "$wait_count" -ge 300 ]; then
                        warn "timed out waiting for join sessions after 300s, shutting down anyway"
                        break
                    fi
                    sleep 1
                done
                [ "$joins" -eq 0 ] && info "all join sessions disconnected"
            fi
        fi

        todo "shutting down VM (pid $QEMU_PID)..."
        # Collect the full process tree (children before parents)
        local -a pids
        mapfile -t pids < <(_pstree_pids "$QEMU_PID")

        # Graceful SIGTERM to the whole tree
        for pid in "${pids[@]}"; do
            kill "$pid" 2>/dev/null
        done

        # Give them a moment to shut down
        local i=0
        while kill -0 "$QEMU_PID" 2>/dev/null && [ "$i" -lt 5 ]; do
            sleep 1
            i=$((i + 1))
        done

        # Force kill any survivors
        if kill -0 "$QEMU_PID" 2>/dev/null; then
            mapfile -t pids < <(_pstree_pids "$QEMU_PID")
            for pid in "${pids[@]}"; do
                kill -9 "$pid" 2>/dev/null
            done
        fi
        wait "$QEMU_PID" 2>/dev/null

        todo_done
    fi

    # Remove session file after shutdown so the VM remains discoverable
    # by 'join'/'list' if the kill above fails for any reason.
    if [ -n "${SESSION_NAME:-}" ] && [ -n "${QEMU_PID:-}" ]; then
        remove_session "$SESSION_NAME" "$QEMU_PID"
    fi
}

# Boot a VM in the background, wait for SSH, and set up the guest.
#
# This function operates on variables in the caller's scope.
#
# Expected variables (set by caller):
#   name            - container/image name
#   cache_dir       - cache directory for the image
#   image           - path to the base qcow2 image
#   memory          - VM memory (e.g. "4G")
#   cpus            - VM CPU count (may be empty for host default)
#   network         - "1" for full network, "0" for restricted
#   display         - VGA display type (empty for none)
#   verbose         - "1" to tail serial log during boot
#   console         - "1" for serial console mode (runs foreground, exits)
#   rw_mount_dirs   - array of directories to mount read-write
#   ro_mount_dirs   - array of directories to mount read-only
#   cow_mount_dirs  - array of directories to mount copy-on-write
#   rootfs_symlinks - array of "link:target" pairs to create in guest
#   dotfiles        - array of file paths to copy into guest
#
# Variables set in caller's scope on return:
#   QEMU_PID        - (global) PID of the background qemu process
#   SESSION_NAME    - (global) name, for cleanup_vm
#   ssh_port        - SSH port on localhost
#   session_image   - path to the session snapshot (caller must clean up)
#   serial_log      - path to the serial log (caller must clean up)
#   ssh_opts        - array of SSH options for connecting to the VM
#
# Does NOT set EXIT traps — the caller decides on cleanup behavior.
_boot_vm() {
    # Find the host kernel and initramfs
    local kernel initramfs
    kernel=$(find_host_kernel)
    initramfs=$(find_host_initramfs)
    info "using kernel: $kernel"
    if [ -n "$initramfs" ]; then
        info "using initramfs: $initramfs"
    else
        info "no readable initramfs found (booting without one)"
    fi

    # Pick a port for SSH
    ssh_port=$(find_free_port)

    # Use the image's dedicated SSH key for connecting.
    local image_key="$cache_dir/ssh_key"
    if [ ! -f "$image_key" ]; then
        die "image SSH key not found at $image_key — try '$PROGNAME create $name --force' to rebuild the image"
    fi

    local -a key_args=(-i "$image_key")

    # Create a snapshot of the image for this session so the base image
    # stays clean. Each boot gets its own copy-on-write layer.
    local abs_image
    abs_image="$(realpath "$image")"
    session_image="$cache_dir/$name-session-$$.qcow2"
    if ! qemu-img create -f qcow2 -b "$abs_image" -F qcow2 "$session_image" >/dev/null 2>&1; then
        die "failed to create session snapshot"
    fi

    local username
    username=$(whoami)

    # Build the kernel command line.
    #
    # enforcing=0: set SELinux to permissive at boot. The container
    #   image has SELinux labels from the container context that are
    #   wrong for a VM. Permissive mode prevents denials from blocking
    #   services like sshd, dbus, and mount operations.
    # systemd.condition-needs-update=: suppress "System Needs Update"
    #   checks that can cause unnecessary firstboot-like behavior.
    local -a kernel_args=(
        "root=/dev/vda"
        "rw"
        "console=ttyS0,115200"
        "init=/sbin/init"
        "enforcing=0"
        "idle=halt"
        "systemd.condition-needs-update="
        "schupfn.container=$name"
        "schupfn.chdir=$PWD"
        "schupfn.user=$username"
        "schupfn.home=$HOME"
    )

    if [ "$verbose" != "1" ]; then
        kernel_args+=("quiet")
    fi

    # Build qemu command.
    #
    # -no-reboot: exit on guest reboot/crash instead of looping
    local -a qemu_cmd=(
        qemu-system-x86_64
        -enable-kvm
        -cpu host
        -m "$memory"
        -no-reboot
        -kernel "$kernel"
        -drive "file=$session_image,format=qcow2,if=virtio"
    )

    if [ -n "$initramfs" ]; then
        qemu_cmd+=(-initrd "$initramfs")
    fi

    # Display adapter
    if [ -n "$display" ]; then
        case "$display" in
            virtio|qxl|std|none|cirrus|vmware) ;;
            *) die "invalid --display type: '$display' (must be one of: virtio, qxl, std, none, cirrus, vmware)" ;;
        esac
        qemu_cmd+=(-vga "$display")
    else
        qemu_cmd+=(-display none -vga none)
    fi

    # Serial console setup
    serial_log="$cache_dir/serial-$$.log"
    if [ "${console:-0}" = "1" ]; then
        qemu_cmd+=(-nographic)
    else
        qemu_cmd+=(-serial "file:$serial_log" -monitor none)
    fi

    if [ -n "$cpus" ]; then
        qemu_cmd+=(-smp "$cpus")
    fi

    # Networking: user-mode (SLIRP) with SSH port forwarding.
    if [ "$network" = "1" ]; then
        qemu_cmd+=(
            -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${ssh_port}-:22"
            -device "virtio-net-pci,netdev=net0"
        )
    else
        qemu_cmd+=(
            -netdev "user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:${ssh_port}-:22"
            -device "virtio-net-pci,netdev=net0"
        )
    fi

    # Mount directories into the VM via 9p at their original paths.
    local fs_idx=0
    for dir in "${rw_mount_dirs[@]}"; do
        local tag="schupfn_rw_${fs_idx}"
        todo "exporting directory (rw): $dir"
        qemu_cmd+=(-virtfs "local,path=$dir,mount_tag=$tag,security_model=none,id=fs${fs_idx}")
        kernel_args+=("schupfn.9p=${tag}:${dir}:rw")
        fs_idx=$((fs_idx + 1))
        todo_done
    done

    for dir in "${ro_mount_dirs[@]}"; do
        local tag="schupfn_ro_${fs_idx}"
        todo "exporting directory (ro): $dir"
        qemu_cmd+=(-virtfs "local,path=$dir,mount_tag=$tag,security_model=none,id=fs${fs_idx},readonly=on")
        kernel_args+=("schupfn.9p=${tag}:${dir}:ro")
        fs_idx=$((fs_idx + 1))
        todo_done
    done

    for dir in "${cow_mount_dirs[@]}"; do
        local tag="schupfn_cow_${fs_idx}"
        todo "exporting directory (cow): $dir"
        qemu_cmd+=(-virtfs "local,path=$dir,mount_tag=$tag,security_model=none,id=fs${fs_idx},readonly=on")
        kernel_args+=("schupfn.9p=${tag}:${dir}:cow")
        fs_idx=$((fs_idx + 1))
        todo_done
    done

    # Mount host kernel modules so the VM can load them
    local kver
    kver=$(uname -r)
    if [ -d "/lib/modules/$kver" ]; then
        local tag="schupfn_kmod"
        qemu_cmd+=(-virtfs "local,path=/lib/modules/$kver,mount_tag=$tag,security_model=none,id=fskmod,readonly=on")
        kernel_args+=("schupfn.9p=${tag}:/lib/modules/${kver}:ro")
    fi

    # Assemble kernel command line
    local kernel_cmdline=""
    for arg in "${kernel_args[@]}"; do
        kernel_cmdline+="$arg "
    done
    qemu_cmd+=(-append "$kernel_cmdline")

    # ── Console mode: run qemu in the foreground with serial on stdio ──
    if [ "${console:-0}" = "1" ]; then
        info "starting VM '$name' in console mode (Ctrl-A X to quit)..."
        "${qemu_cmd[@]}"
        exit 0
    fi

    # Start qemu in the background
    todo "starting VM '$name' (ssh port $ssh_port)..."

    local qemu_log="$cache_dir/qemu.log"
    QEMU_LOG="$qemu_log"
    : > "$serial_log"  # truncate/create so tail -f doesn't fail
    "${qemu_cmd[@]}" </dev/null >"$qemu_log" 2>&1 &
    QEMU_PID=$!
    SESSION_NAME="$name"

    # In verbose mode, tail the serial console log on stderr so the
    # user can see boot progress.  Kill the tail once SSH is up.
    local tail_pid=""
    if [ "$verbose" = "1" ]; then
        tail -f "$serial_log" >&2 &
        tail_pid=$!
    fi

    todo_done

    # Wait for SSH to become available
    wait_for_ssh "$ssh_port" "$image_key"

    # Stop tailing serial log
    if [ -n "$tail_pid" ]; then
        kill "$tail_pid" 2>/dev/null
        wait "$tail_pid" 2>/dev/null
    fi

    todo "VM is up, registering session"
    # Register this VM session so 'schupfn join' and 'schupfn stop' can find it
    write_session "$name" "$QEMU_PID" "$ssh_port" "$PWD" "$image_key" "$session_image" "$serial_log"
    todo_done

    # Build SSH options for the caller
    ssh_opts=(
        -4
        -F /dev/null
        -o StrictHostKeyChecking=no
        -o UserKnownHostsFile=/dev/null
        -o IdentitiesOnly=yes
        -o PubkeyAcceptedAlgorithms=+ssh-rsa
        -o LogLevel=ERROR
        "${key_args[@]}"
        -p "$ssh_port"
    )

    # Quick sanity test: verify authentication works
    if ! ssh "${ssh_opts[@]}" \
            -o BatchMode=yes \
            -l "$username" \
            127.0.0.1 "echo schupfn-auth-ok" 2>/dev/null | grep -q "schupfn-auth-ok"; then
        warn "SSH authentication test failed. Trying with verbose output..."
        ssh -v "${ssh_opts[@]}" \
            -o BatchMode=yes \
            -l "$username" \
            127.0.0.1 "echo schupfn-auth-ok" 2>&1 >&2
        die "SSH authentication failed for user '$username'"
    fi

    # Create symlinks inside the VM for any symlinked export paths.
    if [ ${#rootfs_symlinks[@]} -gt 0 ]; then
        local symlink_script=""
        for entry in "${rootfs_symlinks[@]}"; do
            local link_path="${entry%%:*}"
            local target="${entry#*:}"
            todo "creating symlink: $link_path -> $target"
            local escaped_dir escaped_link escaped_target
            todo_done
            escaped_dir=$(printf '%q' "$(dirname "$link_path")")
            escaped_link=$(printf '%q' "$link_path")
            escaped_target=$(printf '%q' "$target")
            symlink_script+="mkdir -p $escaped_dir && "
            symlink_script+="[ -e $escaped_link ] || ln -s $escaped_target $escaped_link; "
        done
        ssh "${ssh_opts[@]}" -l root 127.0.0.1 "$symlink_script" 2>/dev/null \
            || warn "failed to create symlinks in VM"
    fi

    # Copy exported files into the VM at their original absolute paths.
    if [ ${#dotfiles[@]} -gt 0 ]; then
        local -a tar_paths=()
        for df in "${dotfiles[@]}"; do
            todo "copying file: $df"
            tar_paths+=("${df#/}")
            todo_done
        done

        tar -chf - -C / "${tar_paths[@]}" | \
            ssh "${ssh_opts[@]}" -l root 127.0.0.1 \
                "tar -xf - -C /" 2>/dev/null \
        || warn "failed to copy exported files"
    fi
}

# ── Session management ──────────────────────────────────────────────────────

# Write a session file for a running VM so other processes can join it.
# Session files live in $SESSION_DIR and are named <name>-<pid>.session.
# No-op when SESSION_DIR is empty (XDG_RUNTIME_DIR unset).
write_session() {
    if [ -z "$SESSION_DIR" ]; then
        warn "XDG_RUNTIME_DIR is not set; session tracking disabled ('join' will not work)"
        return 0
    fi

    local name="$1"
    local pid="$2"
    local ssh_port="$3"
    local workdir="$4"
    local ssh_key="${5:-}"
    local session_image="${6:-}"
    local serial_log="${7:-}"

    mkdir -p -m 0700 "$SESSION_DIR"

    cat > "$SESSION_DIR/${name}-${pid}.session" <<EOF
name=$name
pid=$pid
ssh_port=$ssh_port
workdir=$workdir
ssh_key=$ssh_key
session_image=$session_image
serial_log=$serial_log
started_at=$(date -Iseconds)
EOF
}

# Remove the session file for a VM. No-op when SESSION_DIR is empty.
remove_session() {
    [ -n "$SESSION_DIR" ] || return 0
    local name="$1"
    local pid="$2"
    rm -f "$SESSION_DIR/${name}-${pid}.session"
}

# Read a key from a session file. Returns empty string if missing.
read_session() {
    local session_file="$1"
    local key="$2"

    while IFS='=' read -r k v; do
        if [ "$k" = "$key" ]; then
            echo "$v"
            return 0
        fi
    done < "$session_file"
    return 0
}

# Remove session files whose PIDs are no longer running or whose pid
# field is missing/invalid (treat as corrupt).
cleanup_stale_sessions() {
    [ -d "$SESSION_DIR" ] || return 0

    for session_file in "$SESSION_DIR"/*.session; do
        [ -f "$session_file" ] || continue
        local pid
        pid=$(read_session "$session_file" "pid")
        if [ -z "$pid" ] || ! [[ "$pid" =~ ^[0-9]+$ ]] || ! kill -0 "$pid" 2>/dev/null; then
            local si sl
            si=$(read_session "$session_file" "session_image")
            sl=$(read_session "$session_file" "serial_log")
            [ -n "$si" ] && rm -f "$si"
            [ -n "$sl" ] && rm -f "$sl"
            rm -f "$session_file"
        fi
    done
}

# List active session files, optionally filtered by container name.
# Prints one session file path per line.
# Stale sessions are cleaned up first.
list_active_sessions() {
    local filter_name="${1:-}"

    cleanup_stale_sessions
    [ -d "$SESSION_DIR" ] || return 0

    for session_file in "$SESSION_DIR"/*.session; do
        [ -f "$session_file" ] || continue
        if [ -n "$filter_name" ]; then
            local name
            name=$(read_session "$session_file" "name")
            if [ "$name" != "$filter_name" ]; then
                continue
            fi
        fi
        echo "$session_file"
    done
}

# ── Join tracking ───────────────────────────────────────────────────────────
#
# When a 'join' session connects to a VM, it creates a lock file so
# 'enter' can detect active joins and delay shutdown (--keep-alive).
# Lock files live alongside session files and are named:
#   <name>-<vmpid>.join.<joinpid>
#
# Stale lock files (whose join PIDs are no longer running) are cleaned up
# automatically by count_active_joins().

# Create a join lock file for the given VM session.
acquire_join_lock() {
    [ -n "$SESSION_DIR" ] || return 0
    local name="$1"
    local vm_pid="$2"
    local join_pid="$3"

    mkdir -p -m 0700 "$SESSION_DIR"
    touch "$SESSION_DIR/${name}-${vm_pid}.join.${join_pid}"
}

# Remove the join lock file for the given VM session.
release_join_lock() {
    [ -n "$SESSION_DIR" ] || return 0
    local name="$1"
    local vm_pid="$2"
    local join_pid="$3"

    rm -f "$SESSION_DIR/${name}-${vm_pid}.join.${join_pid}"
}

# Count active join sessions for a VM. Removes stale lock files (whose
# join PID is no longer running) before counting.
# Prints the count to stdout.
count_active_joins() {
    local name="$1"
    local vm_pid="$2"
    local count=0

    [ -d "$SESSION_DIR" ] || { echo 0; return 0; }

    for lock_file in "$SESSION_DIR/${name}-${vm_pid}".join.*; do
        [ -f "$lock_file" ] || continue
        # Extract the join PID from the filename
        local join_pid="${lock_file##*.join.}"
        if [ -z "$join_pid" ] || ! [[ "$join_pid" =~ ^[0-9]+$ ]]; then
            rm -f "$lock_file"
            continue
        fi
        if ! kill -0 "$join_pid" 2>/dev/null; then
            # Join process is dead — stale lock
            rm -f "$lock_file"
            continue
        fi
        count=$((count + 1))
    done

    echo "$count"
}

# ── Configuration file ───────────────────────────────────────────────────────

# Walk from the given directory up to / looking for .schupfn/config.yml.
# If none is found, fall back to XDG_CONFIG_HOME/schupfn/ — first trying
# <container>-config.yml (when a container name is given), then
# default-config.yml.  XDG_CONFIG_HOME defaults to ~/.config.
# Prints the path of the first config found, or nothing if none exists.
#
# Usage: find_config_file <start-dir> [container-name]
find_config_file() {
    local dir="$1"
    local container="${2:-}"
    local physical_dir
    physical_dir="$(realpath "$dir" 2>/dev/null)" || physical_dir="$dir"

    # Walk upward from the logical path first, then (if it differs)
    # from the physical path, so configs next to the real directory
    # are found even when $PWD is a symlink.
    local -a roots=("$dir")
    if [ "$physical_dir" != "$dir" ]; then
        roots+=("$physical_dir")
    fi

    local root
    for root in "${roots[@]}"; do
        local d="$root"
        while true; do
            local candidate="$d/.schupfn/config.yml"
            if [ -f "$candidate" ]; then
                realpath "$candidate"
                return 0
            fi
            if [ "$d" = "/" ]; then
                break
            fi
            d="${d%/*}"
            [ -z "$d" ] && d="/"
        done
    done

    # Fall back to user-level config under XDG_CONFIG_HOME
    local xdg_dir="${XDG_CONFIG_HOME:-$HOME/.config}/schupfn"

    # Try container-specific config first
    if [ -n "$container" ]; then
        local xdg_container="$xdg_dir/${container}-config.yml"
        if [ -f "$xdg_container" ]; then
            echo "$xdg_container"
            return 0
        fi
    fi

    # Then try the default config
    local xdg_default="$xdg_dir/default-config.yml"
    if [ -f "$xdg_default" ]; then
        echo "$xdg_default"
        return 0
    fi

    return 1
}

# Parse a config file with yq and set the corresponding variables.
# Sets the following variables in the caller's scope via nameref:
#   _cfg_container  - default container name, empty if not set
#   _cfg_command    - command to run instead of a shell, empty if not set
#   _cfg_exports    - array of read-only export paths from config
#   _cfg_exports_rw - array of read-write export paths from config
#   _cfg_exports_cow - array of copy-on-write export paths from config
#   _cfg_memory     - VM memory (e.g. "2G"), empty if not set
#   _cfg_cpus       - VM CPU count, empty if not set
#   _cfg_network    - "0" if network: false, empty if not set
#   _cfg_display    - VM display adapter type, empty if not set
#   _cfg_image_size - disk image size (from image.size), empty if not set
#   _cfg_packages   - array of packages to install in the image (from image.install-packages)
#   _cfg_follow_git_worktrees - "1" if follow-git-worktrees: true, empty if not set
load_config() {
    local config_file="$1"

    check_command yq

    # Extract everything in a single yq invocation.  The output is a
    # stream of sentinel-delimited sections that we parse in pure bash.
    #
    #   __SCALARS__
    #   container=<value>
    #   command=<value>
    #   memory=<value>
    #   cpus=<value>
    #   network=<value>
    #   __EXPORT_RO__
    #   <path>          (one per line)
    #   __EXPORT_RW__
    #   <path>          (one per line)
    #   __KEYS__
    #   <dotted.key>    (one per line)
    #
    local blob
    blob=$(yq -r '
        "__SCALARS__",
        "container=" + (.container // ""),
        "command="   + (.command // ""),
        "memory="    + (.vm.memory // ""),
        "cpus="      + ((.vm.cpus // "") | tostring),
        "network="   + (.vm.network | tostring),
        "display="   + (.vm.display // ""),
        "image_size=" + (.image.size // ""),
        "follow_git_worktrees=" + (.["follow-git-worktrees"] | tostring),
        "__EXPORT_RO__",
        (.["export-ro"] // [] | .[]),
        "__EXPORT_RW__",
        (.["export-rw"] // [] | .[]),
        "__EXPORT_COW__",
        (.["export-cow"] // [] | .[]),
        "__PACKAGES__",
        (.image["install-packages"] // [] | .[]),
        "__KEYS__",
        ([.. | path | map(select(tag != "!!int")) | join(".")] | unique | .[])
    ' "$config_file" 2>/dev/null) || die "failed to parse config file: $config_file"

    # Parse the blob line by line — zero additional forks.
    local section="" line
    local raw_cpus="" raw_network="" raw_follow_git_worktrees=""
    while IFS= read -r line; do
        case "$line" in
            __SCALARS__|__EXPORT_RO__|__EXPORT_RW__|__EXPORT_COW__|__PACKAGES__|__KEYS__)
                section="$line"; continue ;;
        esac

        case "$section" in
            __SCALARS__)
                local key="${line%%=*}"
                local val="${line#*=}"
                case "$key" in
                    container) _cfg_container="$val" ;;
                    command)   _cfg_command="$val" ;;
                    memory)    _cfg_memory="$val" ;;
                    cpus)      raw_cpus="$val" ;;
                    network)   raw_network="$val" ;;
                    display)   _cfg_display="$val" ;;
                    image_size) _cfg_image_size="$val" ;;
                    follow_git_worktrees) raw_follow_git_worktrees="$val" ;;
                esac
                ;;
            __EXPORT_RO__)
                if [ -n "$line" ]; then
                    [[ "$line" != /* && "$line" != ~* ]] && line="$HOME/$line"
                    _cfg_exports+=("$line")
                fi
                ;;
            __EXPORT_RW__)
                if [ -n "$line" ]; then
                    [[ "$line" != /* && "$line" != ~* ]] && line="$HOME/$line"
                    _cfg_exports_rw+=("$line")
                fi
                ;;
            __EXPORT_COW__)
                if [ -n "$line" ]; then
                    [[ "$line" != /* && "$line" != ~* ]] && line="$HOME/$line"
                    _cfg_exports_cow+=("$line")
                fi
                ;;
            __PACKAGES__)
                [ -n "$line" ] && _cfg_packages+=("$line")
                ;;
            __KEYS__)
                [ -z "$line" ] && continue
                local _known_keys=" container command export-ro export-rw export-cow follow-git-worktrees image image.size image.install-packages vm vm.memory vm.cpus vm.network vm.display "
                if [[ "$_known_keys" != *" $line "* ]]; then
                    warn "unknown config key '$line' in $config_file"
                fi
                ;;
        esac
    done <<< "$blob"

    if [ -n "$raw_cpus" ]; then
        _cfg_cpus="$raw_cpus"
    fi
    if [ "$raw_network" = "false" ]; then
        _cfg_network="0"
    fi
    if [ "$raw_follow_git_worktrees" = "true" ]; then
        _cfg_follow_git_worktrees="1"
    fi
}

# ── Usage ────────────────────────────────────────────────────────────────────

usage() {
    cat <<EOF
Usage: $PROGNAME <command> [options]

Run toolbox containers in VMs via qemu.

Commands:
  create <name>   Export a toolbox container to a VM disk image
  enter <name>    Boot a VM image and connect via SSH
  start <name>    Start a VM in the background
  stop [<name>]   Shut down a running VM
  join [<name>]   Open a new SSH session to a running VM
  list            Show cached VM images and their freshness status
  clean [<name>]  Remove cached VM images

Run '$PROGNAME <command> --help' for more information on a command.
EOF
}

usage_enter() {
    cat <<EOF
Usage: $PROGNAME enter [<name>] [options]

Boot a previously created VM image and connect to it via SSH. The current
directory is mounted read-write inside the VM.

The image must already exist; run '$PROGNAME create <name>' first to
export the container to a disk image.

If <name> is omitted, the 'container' field from the config file is used.

Options:
  --export-ro <path>  Additional host path to make available in the VM (repeatable)
                      Directories are mounted read-only at their original path.
                      Files are copied into the VM at their original path.
  --export-rw <path>  Like --export-ro, but directories are mounted read-write
  --export-cow <path> Like --export-ro, but the directory is writable inside
                      the VM via overlayfs (writes go to a tmpfs upper layer
                      and are lost on VM shutdown; the host directory is never
                      modified)
  --memory <size>   VM memory, e.g. 4G, 512M (default: 4G)
  --cpus <n>        VM CPU count (default: host CPU count)
  --command <cmd>   Run <cmd> in the VM instead of an interactive shell
  --config <path>   Use <path> as config file instead of searching for one
  --follow-git-worktrees
                    If \$PWD is a git worktree, automatically export the main
                    git directory read-write so git operations work in the VM
  --no-network      Disable the extra network device (SSH network device remains)
  --display <type>  Add a display adapter (e.g. virtio, qxl, std) for running
                    Xorg or Wayland inside the VM
  --console         Boot with serial console on stdio instead of SSH (for debugging)
  --verbose         Show VM boot output
  -h, --help        Show this help

Configuration file:
  schupfn searches from \$PWD upward for .schupfn/config.yml. The first
  file found is loaded. Use --config to specify an explicit path instead.
  CLI arguments override config values; for --export-ro and --export-rw
  the config and CLI lists are merged.

  Example .schupfn/config.yml:
    container: my-toolbox
    command: make test
    export-ro:
      - ~/src/shared-lib
      - ~/.zshrc
    export-rw:
      - ~/src/work-in-progress
    export-cow:
      - ~/src/reference-tree
    follow-git-worktrees: true
    vm:
      memory: 8G
      cpus: 4
      network: false
      display: virtio
EOF
}

usage_create() {
    cat <<EOF
Usage: $PROGNAME create [<name>] [options]

Export the toolbox container <name> to a qcow2 disk image. The image is
cached and reused by '$PROGNAME enter'.

If an image already exists, the command exits with an error unless --force
is given.

If <name> is omitted, the 'container' field from the config file is used.

Required packages (openssh-server, systemd-udev, dbus-broker) are
installed automatically during image creation.

Options:
  --force           Rebuild the image even if one already exists
  --image-size <size>
                    Size of the qcow2 disk image, e.g. 4G, 10G.
                    Default: auto-sized from container (tarball + 50% headroom)
  --install-packages <pkg>
                    Install additional packages in the image (repeatable).
                    Packages are installed via dnf before the image is finalized.
  --config <path>   Use <path> as config file instead of searching for one
  -h, --help        Show this help

Configuration file:
  schupfn searches from \$PWD upward for .schupfn/config.yml. The first
  file found is loaded. Use --config to specify an explicit path instead.
  CLI arguments override config values; for --install-packages the config
  and CLI lists are merged.

  Example .schupfn/config.yml:
    container: my-toolbox
    image:
      size: 10G
      install-packages:
        - zsh
        - vim
EOF
}

usage_start() {
    cat <<EOF
Usage: $PROGNAME start [<name>] [options]

Start a VM in the background from a previously created image. The VM
keeps running until explicitly stopped with '$PROGNAME stop'.

Use '$PROGNAME join' to connect to the running VM.

The image must already exist; run '$PROGNAME create <name>' first to
export the container to a disk image.

If <name> is omitted, the 'container' field from the config file is used.

Options:
  --export-ro <path>  Additional host path to make available in the VM (repeatable)
                      Directories are mounted read-only at their original path.
                      Files are copied into the VM at their original path.
  --export-rw <path>  Like --export-ro, but directories are mounted read-write
  --export-cow <path> Like --export-ro, but the directory is writable inside
                      the VM via overlayfs (writes go to a tmpfs upper layer
                      and are lost on VM shutdown; the host directory is never
                      modified)
  --memory <size>   VM memory, e.g. 4G, 512M (default: 4G)
  --cpus <n>        VM CPU count (default: host CPU count)
  --config <path>   Use <path> as config file instead of searching for one
  --follow-git-worktrees
                    If \$PWD is a git worktree, automatically export the main
                    git directory read-write so git operations work in the VM
  --no-network      Disable the extra network device (SSH network device remains)
  --display <type>  Add a display adapter (e.g. virtio, qxl, std) for running
                    Xorg or Wayland inside the VM
  --verbose         Show VM boot output
  -h, --help        Show this help
EOF
}

usage_stop() {
    cat <<EOF
Usage: $PROGNAME stop [<name>]

Shut down a running VM that was started with '$PROGNAME start'.

If <name> is omitted and exactly one VM is running, that VM is stopped
automatically. If multiple VMs are running, an interactive menu is shown.

If there are active join sessions, the command waits for them to
disconnect before shutting down.

Options:
  -h, --help        Show this help
EOF
}

usage_join() {
    cat <<EOF
Usage: $PROGNAME join [<name>] [options]

Open a new SSH session to an already-running VM.

If <name> is omitted and exactly one VM is running, that VM is joined
automatically. If multiple VMs are running, an interactive menu is shown.

Active join sessions are tracked automatically. When the 'enter' session
exits, the VM waits for all join sessions to disconnect before shutting
down.

Options:
  --command <cmd>   Run <cmd> in the VM instead of an interactive shell
  -h, --help        Show this help
EOF
}

# ── Metadata / Cache ────────────────────────────────────────────────────────

# Write metadata file after a successful export
write_meta() {
    local cache_dir="$1"

    cat > "$cache_dir/$META_FILE" <<EOF
exported_at=$(date -Iseconds)
EOF
}

# Read a key from the metadata file. Returns empty string if missing.
read_meta() {
    local cache_dir="$1"
    local key="$2"
    local meta_path="$cache_dir/$META_FILE"

    if [ ! -f "$meta_path" ]; then
        return 0
    fi

    # shellcheck disable=SC2034
    while IFS='=' read -r k v; do
        if [ "$k" = "$key" ]; then
            echo "$v"
            return 0
        fi
    done < "$meta_path"
    return 0
}

# ── Host kernel/initramfs detection ─────────────────────────────────────────

# Locate the kernel image for the running kernel.
# The file must be readable by the current user.
find_host_kernel() {
    local kver
    kver=$(uname -r)
    local -a candidates=(
        "/lib/modules/$kver/vmlinuz"
        "/boot/vmlinuz-$kver"
        "/boot/vmlinuz"
    )
    for path in "${candidates[@]}"; do
        if [ -r "$path" ]; then
            echo "$path"
            return 0
        fi
    done
    die "cannot find a readable kernel image for $kver." \
        "Looked in: ${candidates[*]}" \
        "On Fedora, /boot files are often root-only;" \
        "/lib/modules/$kver/vmlinuz is typically world-readable."
}

# Locate the initramfs for the running kernel.
# Returns the path if found and readable, or empty string if none is
# available. An initramfs is optional when the kernel has virtio_blk,
# virtio_pci, and the root filesystem driver (ext4) built in — which
# is the case on Fedora and most distribution kernels.
find_host_initramfs() {
    local kver
    kver=$(uname -r)
    local -a candidates=(
        "/boot/initramfs-$kver.img"
        "/boot/initrd.img-$kver"
        "/boot/initrd-$kver.img"
        "/boot/initrd-$kver"
        "/boot/initramfs.img"
    )
    for path in "${candidates[@]}"; do
        if [ -r "$path" ]; then
            echo "$path"
            return 0
        fi
    done
    # Not found — caller decides whether this is fatal
    return 0
}

# ── Export ───────────────────────────────────────────────────────────────────

# Build the guest-side systemd service unit that mounts 9p shares.
# The service reads /proc/cmdline for schupfn.9p=<tag>:<path>:<mode>
# entries and mounts each one.
#
# The 9p kernel modules (9p, 9pnet, 9pnet_virtio) must be loaded before
# any 9p mount can succeed.  We handle this by loading them via modprobe
# first — this works because:
#   - If the modules are built-in: modprobe succeeds silently
#   - If the modules are loadable: we need /lib/modules to be mounted,
#     but /lib/modules is itself a 9p mount (circular dependency).
#     To break the circle, the host kernel modules directory is passed
#     as a kernel built-in 9p mount tag.  We mount that first using
#     insmod on the module files from the disk image (which were
#     installed by virt-customize), then modprobe works for everything.
generate_mount_script() {
    cat <<'SCRIPT'
#!/bin/bash
modprobe 9pnet_virtio 2>/dev/null
modprobe 9p 2>/dev/null
modprobe overlay 2>/dev/null
sleep 0.5

# Parse kernel command line
schupfn_user=""
schupfn_home=""
for param in $(cat /proc/cmdline); do
    case "$param" in
        schupfn.user=*) schupfn_user="${param#schupfn.user=}" ;;
        schupfn.home=*) schupfn_home="${param#schupfn.home=}" ;;
        schupfn.9p=*)
            spec="${param#schupfn.9p=}"
            tag="${spec%%:*}"; rest="${spec#*:}"
            path="${rest%%:*}"; mode="${rest#*:}"
            mkdir -p "$path"
            # Fix ownership of all intermediate dirs created by mkdir -p
            # (e.g. mkdir -p ~/.local/bin creates ~/.local as root:root,
            # which prevents the user from creating ~/.local/share later)
            if [ -n "$schupfn_home" ] && [ -n "$schupfn_user" ]; then
                _uid=$(id -u "$schupfn_user" 2>/dev/null) || true
                _gid=$(id -g "$schupfn_user" 2>/dev/null) || true
                if [ -n "$_uid" ] && [ -n "$_gid" ]; then
                    p="$path"
                    while [ "$p" != "/" ] && [ -n "$p" ]; do
                        case "$p" in "$schupfn_home"/*) chown "$_uid:$_gid" "$p" 2>/dev/null ;; esac
                        p=$(dirname "$p")
                    done
                fi
            fi
            opts="trans=virtio,version=9p2000.L,msize=104857600"

            if [ "$mode" = "cow" ]; then
                # Copy-on-write: mount 9p read-only at a hidden path,
                # then overlay it at the target path with a tmpfs upper
                lower="/run/schupfn/lower/$tag"
                upper="/run/schupfn/upper/$tag"
                work="/run/schupfn/work/$tag"
                mkdir -p "$lower" "$upper" "$work"
                mounted=0
                for i in 1 2 3; do
                    if mount -t 9p -o "$opts,ro" "$tag" "$lower" 2>/dev/null; then
                        mounted=1; break
                    fi
                    sleep 0.5
                done
                if [ "$mounted" = "1" ]; then
                    overlay_err=$(mount -t overlay overlay \
                            -o "lowerdir=$lower,upperdir=$upper,workdir=$work" \
                            "$path" 2>&1)
                    if [ $? -ne 0 ]; then
                        echo "schupfn-mounts: failed to overlay $tag at $path: $overlay_err" >&2
                        # Fall back to plain read-only 9p at the target path
                        umount "$lower" 2>/dev/null
                        mount -t 9p -o "$opts,ro" "$tag" "$path" 2>/dev/null \
                            || echo "schupfn-mounts: fallback ro mount also failed for $tag" >&2
                    fi
                else
                    echo "schupfn-mounts: failed to mount 9p $tag for cow" >&2
                fi
            else
                [ "$mode" = "ro" ] && opts="$opts,ro"
                mounted=0
                for i in 1 2 3; do
                    if mount -t 9p -o "$opts" "$tag" "$path" 2>/dev/null; then
                        mounted=1; break
                    fi
                    sleep 0.5
                done
                if [ "$mounted" = "0" ]; then
                    echo "schupfn-mounts: failed to mount $tag at $path" >&2
                fi
            fi
            ;;
    esac
done

# Fix home directory ownership.  The container export produces files
# owned by root; this fixes them to the actual user.  Uses find with
# -xdev to stay on the rootfs and not traverse into 9p/overlay mounts.
if [ -n "$schupfn_user" ] && [ -n "$schupfn_home" ] && [ -d "$schupfn_home" ]; then
    uid=$(id -u "$schupfn_user" 2>/dev/null)
    gid=$(id -g "$schupfn_user" 2>/dev/null)
    if [ -n "$uid" ] && [ -n "$gid" ]; then
        find "$schupfn_home" -xdev -exec chown "$uid:$gid" {} + 2>/dev/null || true
    fi
fi
SCRIPT
}

generate_mount_service() {
    cat <<'UNIT'
[Unit]
Description=Mount schupfn 9p shares
DefaultDependencies=no
After=systemd-tmpfiles-setup.service systemd-udevd.service
Before=multi-user.target sshd.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/schupfn-mounts.sh

[Install]
WantedBy=multi-user.target
UNIT
}

export_image() {
    local name="$1"
    local cache_dir="$2"
    local user_image_size="${3:-}"
    shift 3 || shift $#
    local -a packages=("$@")

    info "exporting container '$name' to disk image..."

    # Remove old image if it exists
    if [ -d "$cache_dir" ]; then
        rm -rf "$cache_dir"
    fi

    mkdir -p -m 0700 "$cache_dir"

    # Generate a dedicated ed25519 keypair for this image.
    local image_key="$cache_dir/ssh_key"
    ssh-keygen -t ed25519 -f "$image_key" -N "" -q \
        || die "failed to generate SSH key for image"

    local tarball="$cache_dir/$name.tar"
    local image="$cache_dir/$name.qcow2"

    # Prepare the user entry
    local username
    username=$(whoami)
    local uid gid homedir shell
    uid=$(id -u)
    gid=$(id -g)
    homedir="$HOME"
    shell=$(getent passwd "$username" | cut -d: -f7)
    shell="${shell:-/bin/bash}"
    local groupname
    groupname=$(id -gn)

    local host_tz
    host_tz=$(timedatectl show -p Timezone --value 2>/dev/null \
        || cat /etc/timezone 2>/dev/null || echo "UTC")

    local kver
    kver=$(uname -r)

    # ── Phase 1: prepare the container with buildah ─────────────────────
    #
    # Use buildah to create a working container from the toolbox
    # container's image, apply all configuration changes, then export.
    # This runs inside the container's filesystem (no helper VM needed),
    # so it's fast and the operations happen in the right context.
    # The only things that can't be done here are setuid/ownership
    # fixes — those need real root (virt-customize phase).

    todo "preparing container filesystem..."

    # Commit the container's current filesystem (including all packages
    # installed by the user) to a temporary image, then create a buildah
    # working container from it.  We can't use the container's base
    # image directly because it doesn't include user-installed packages.
    local tmp_image="localhost/schupfn-tmp-$name"
    podman commit "$name" "$tmp_image" >/dev/null 2>&1 \
        || die "failed to commit container '$name' to temporary image"

    local wc
    wc=$(buildah from "$tmp_image" 2>/dev/null) \
        || { podman rmi "$tmp_image" >/dev/null 2>&1; die "failed to create buildah working container"; }

    # Helper to run commands in the working container
    _brun() { buildah run --env PATH=/usr/sbin:/usr/bin:/sbin:/bin "$wc" -- "$@"; }

    # Helper to write a file into the container from stdin
    _bwrite() {
        local dest="$1" mode="${2:-0644}"
        local tmp
        tmp=$(mktemp)
        trap 'rm -f "$tmp"' RETURN
        cat > "$tmp"
        buildah copy --chmod "$mode" "$wc" "$tmp" "$dest" >/dev/null
    }

    # Helper: run dnf install inside the container, showing any error
    # messages on failure.
    _bdnf() {
        local msgs
        msgs=$(mktemp)
        trap 'rm -f "$msgs"' RETURN
        if ! _brun dnf -q install -y "$@" >/dev/null 2>"$msgs"; then
            local err
            err=$(cat "$msgs")
            die "failed to install packages: $*: $err"
        fi
    }

    todo_done

    # ── Container-to-VM transition fixes ────────────────────────────────

    local -a required_packages=("openssh-server" "systemd-udev" "dbus-broker")
    todo "installing required packages: ${required_packages[*]}"
    _bdnf "${required_packages[@]}"
    todo_done

    todo "converting from container to VM"
    _brun rm -f /.dockerenv
    _brun rm -f /etc/systemd/system/service.d/00-container.conf
    _brun rm -f /etc/systemd/system.conf.d/00-container.conf
    _brun bash -c 'sed -i "/^container=/d" /etc/environment 2>/dev/null || true'

    # Fix dangling symlinks from toolbox host bind-mounts
    _brun bash -c 'if [ -L /etc/resolv.conf ]; then rm -f /etc/resolv.conf; echo "nameserver 10.0.2.3" > /etc/resolv.conf; fi'
    _brun bash -c "if [ -L /etc/localtime ]; then rm -f /etc/localtime; ln -s '/usr/share/zoneinfo/$host_tz' /etc/localtime; fi"

    # SELinux: set to permissive (container labels are wrong for a VM)
    _brun bash -c 'if [ -f /etc/selinux/config ]; then sed -i "s/^SELINUX=.*/SELINUX=permissive/" /etc/selinux/config; fi'

    # Mask problematic systemd units, need to do this after
    # installing our packages (which typically include systemd)
    _brun ln -sf /dev/null /etc/systemd/system/tmp.mount
    _brun bash -c 'mkdir -p /tmp && chmod 1777 /tmp'

    # ── D-Bus ───────────────────────────────────────────────────────────

    _brun bash -c 'mkdir -p /etc/systemd/system/multi-user.target.wants /etc/systemd/system/sockets.target.wants'
    _brun bash -c 'if [ -f /usr/lib/systemd/system/dbus-broker.service ]; then ln -sf /usr/lib/systemd/system/dbus-broker.service /etc/systemd/system/multi-user.target.wants/; ln -sf /usr/lib/systemd/system/dbus-broker.service /etc/systemd/system/dbus.service; else ln -sf /usr/lib/systemd/system/dbus.service /etc/systemd/system/multi-user.target.wants/ 2>/dev/null; fi || true'
    _brun bash -c 'ln -sf /usr/lib/systemd/system/dbus.socket /etc/systemd/system/sockets.target.wants/ 2>/dev/null || true'

    # ── Kernel modules ──────────────────────────────────────────────────

    local host_moddir="/lib/modules/$kver"
    if [ -d "$host_moddir" ]; then
        local mod_tar
        mod_tar=$(mktemp --suffix=.tar)
        local -a mod_files=()
        while IFS= read -r line; do
            local mod_path="${line#insmod }"
            mod_path="${mod_path%%[[:space:]]*}"
            [ -f "$mod_path" ] && mod_files+=("${mod_path#/}")
        done < <(modprobe --show-depends 9pnet_virtio 2>/dev/null; \
                 modprobe --show-depends 9p 2>/dev/null; \
                 modprobe --show-depends virtio_net 2>/dev/null; \
                 modprobe --show-depends overlay 2>/dev/null)

        if [ ${#mod_files[@]} -gt 0 ]; then
            tar -cf "$mod_tar" -C / "${mod_files[@]}" 2>/dev/null
            buildah copy "$wc" "$mod_tar" /tmp/schupfn-modules.tar >/dev/null
            rm -f "$mod_tar"
            _brun bash -c 'tar -xf /tmp/schupfn-modules.tar -C / && rm -f /tmp/schupfn-modules.tar'
            _brun /usr/sbin/depmod -a "$kver" 2>/dev/null || true
        else
            rm -f "$mod_tar"
        fi
    fi

    _brun bash -c 'mkdir -p /etc/modules-load.d && printf "virtio_net\noverlay\n" > /etc/modules-load.d/schupfn.conf'

    # ── SSH setup ───────────────────────────────────────────────────────

    # Remove stale host keys and regenerate
    _brun bash -c 'rm -f /etc/ssh/ssh_host_*key* && ssh-keygen -q -A > /dev/null'

    # Drop-in sshd config for the VM environment
    _brun mkdir -p /etc/ssh/sshd_config.d
    _bwrite /etc/ssh/sshd_config.d/50-schupfn.conf 0644 <<'SSHD_CONF'
# Disable PAM — we manage accounts directly and PAM modules
# can reject auth for accounts it considers locked
UsePAM no
# Disable password auth — key-only access
PasswordAuthentication no
PermitRootLogin prohibit-password
# Avoid GSSAPI delays
GSSAPIAuthentication no
# Allow older RSA keys
PubkeyAcceptedAlgorithms +ssh-rsa
# Pass locale environment
AcceptEnv LANG LC_*
# Exempt the QEMU SLIRP gateway from per-source penalties —
# early connections during boot can trigger escalating delays
PerSourcePenaltyExemptList 10.0.2.2
SSHD_CONF

    # Enable socket-activated sshd — no need for ordering dependencies
    # since sshd only spawns when a connection arrives, by which time
    # the network and mounts are already up.
    _brun bash -c 'mkdir -p /etc/systemd/system/sockets.target.wants && ln -sf /usr/lib/systemd/system/sshd.socket /etc/systemd/system/sockets.target.wants/'

    todo_done

    # ── Extra packages ─────────────────────────────────────────────────

    if [ ${#packages[@]} -gt 0 ]; then
        todo "installing extra packages: ${packages[*]}"
        _bdnf "${packages[@]}"
        todo_done
    fi

    # ── User setup ──────────────────────────────────────────────────────
    todo "finishing setup"

    # Verify the user's login shell exists inside the image; fall back
    # to /bin/bash if it doesn't (e.g. host uses zsh but the container
    # doesn't have it installed).
    if ! _brun test -x "$shell" 2>/dev/null; then
        todo_done
        warn "shell '$shell' not found in image, defaulting to /bin/bash"
        shell="/bin/bash"
        todo "continuing with setup"
    fi

    _brun bash -c 'echo "root:schupfn" | chpasswd'
    _brun bash -c "sed -i '/^${groupname}:/d' /etc/group; echo '${groupname}:x:${gid}:' >> /etc/group"
    # Replace or add the user's passwd entry.  Toolbox containers have
    # the user with an empty password field; we need 'x' so sshd
    # defers to /etc/shadow.  Also update the shell to $shell which
    # may have been changed to /bin/bash if the original wasn't found.
    _brun bash -c "sed -i '/^${username}:/d' /etc/passwd; echo '${username}:x:${uid}:${gid}::${homedir}:${shell}' >> /etc/passwd"
    _brun mkdir -p "$homedir/.ssh"

    # Inject SSH authorized keys
    [ -f "${image_key}.pub" ] || die "SSH public key not found at ${image_key}.pub"
    local ak_tmp
    ak_tmp=$(mktemp)
    trap 'rm -f "$ak_tmp"' RETURN
    cat "${image_key}.pub" > "$ak_tmp"
    for pub in "$HOME"/.ssh/id_*.pub; do
        [ -f "$pub" ] && cat "$pub" >> "$ak_tmp"
    done
    buildah copy --chmod 0600 "$wc" "$ak_tmp" "${homedir}/.ssh/authorized_keys" >/dev/null

    # Also inject for root
    _brun mkdir -p /root/.ssh
    _brun chmod 700 /root/.ssh
    buildah copy --chmod 0600 "$wc" "$ak_tmp" /root/.ssh/authorized_keys >/dev/null

    _brun chmod 700 "${homedir}/.ssh"

    # The user's /etc/shadow entry must not have a '!' or '!!' password
    # hash — Fedora's sshd rejects all auth (including pubkey) for
    # accounts with a locked password prefix.  Replace the password
    # field with '*' (disabled but not locked).
    _brun bash -c "if grep -q '^${username}:' /etc/shadow; then sed -i 's/^${username}:[^:]*:/${username}:*:/' /etc/shadow; else echo '${username}:*:::::::' >> /etc/shadow; fi"

    # Passwordless sudo: add user to wheel and enable NOPASSWD for wheel.
    _brun bash -c "usermod -aG wheel ${username} 2>/dev/null || (grep -q '^wheel:' /etc/group && sed -i 's/^wheel:x:\([0-9]*\):.*/&,${username}/' /etc/group)"
    _brun bash -c "sed -i 's/^# *%wheel.*NOPASSWD.*ALL$/%wheel ALL=(ALL) NOPASSWD: ALL/' /etc/sudoers 2>/dev/null || true"

    # ── 9p mount script + service ───────────────────────────────────────

    generate_mount_script | _bwrite /usr/local/bin/schupfn-mounts.sh 0755
    generate_mount_service | _bwrite /etc/systemd/system/schupfn-mounts.service 0644
    _brun bash -c 'ln -sf /etc/systemd/system/schupfn-mounts.service /etc/systemd/system/multi-user.target.wants/'

    # ── Network: let systemd-networkd handle DHCP via QEMU SLIRP ──────

    _brun bash -c 'mkdir -p /etc/systemd/network'
    _bwrite /etc/systemd/network/80-schupfn.network 0644 <<'NETCFG'
[Match]
Driver=virtio_net

[Network]
DHCP=yes

[DHCPv4]
UseDNS=no
NETCFG
    _brun bash -c 'mkdir -p /etc/systemd/system/multi-user.target.wants'
    _brun bash -c 'ln -sf /usr/lib/systemd/system/systemd-networkd.service /etc/systemd/system/multi-user.target.wants/'

    # ── Hostname ────────────────────────────────────────────────────────

    # Set hostname — use buildah config instead of writing the file
    # directly (avoids "Device or resource busy" on /etc/hostname)
    buildah config --hostname "$name" "$wc" >/dev/null 2>&1 || true

    todo_done

    # ── Export ──────────────────────────────────────────────────────────

    todo "exporting prepared container..."
    local prepared_image="localhost/schupfn-prepared-$name"
    if ! buildah commit --rm --squash "$wc" "$prepared_image" >/dev/null 2>&1; then
        buildah rm "$wc" >/dev/null 2>&1
        podman rmi "$tmp_image" >/dev/null 2>&1
        die "failed to commit buildah container"
    fi

    # Remove the temp image (no longer needed after commit)
    podman rmi "$tmp_image" >/dev/null 2>&1

    # Create a temporary container from the committed image and export it
    local tmp_ctr
    tmp_ctr=$(podman create "$prepared_image" /bin/true 2>/dev/null) \
        || die "failed to create temporary export container"

    if ! podman export "$tmp_ctr" > "$tarball"; then
        podman rm "$tmp_ctr" >/dev/null 2>&1
        podman rmi "$prepared_image" >/dev/null 2>&1
        rm -f "$tarball"
        die "failed to export container"
    fi

    podman rm "$tmp_ctr" >/dev/null 2>&1
    podman rmi "$prepared_image" >/dev/null 2>&1

    # ── Phase 2: create qcow2 image ────────────────────────────────────

    local image_size
    if [ -n "$user_image_size" ]; then
        if ! [[ "$user_image_size" =~ ^[0-9]+[KMGT]$ ]]; then
            die "invalid --image-size format: '$user_image_size' (expected e.g. 4G, 512M, 10G)"
        fi
        image_size="$user_image_size"
    else
        # Auto-size: tarball size + 50% headroom, minimum 2G
        local tar_size_bytes
        tar_size_bytes=$(stat --format='%s' "$tarball" 2>/dev/null) \
            || tar_size_bytes=$(stat -f '%z' "$tarball" 2>/dev/null) \
            || tar_size_bytes=0
        local image_size_bytes=$(( tar_size_bytes * 3 / 2 ))
        local min_size=$(( 2 * 1024 * 1024 * 1024 ))  # 2G
        if [ "$image_size_bytes" -lt "$min_size" ]; then
            image_size_bytes=$min_size
        fi
        local image_size_gb=$(( (image_size_bytes + 1073741823) / 1073741824 ))
        image_size="${image_size_gb}G"
    fi

    todo_done
    todo "creating ${image_size} qcow2 image..."
    if ! virt-make-fs --format=qcow2 --type=ext4 --size="$image_size" \
            "$tarball" "$image" > /dev/null; then
        rm -f "$tarball" "$image"
        die "failed to create disk image"
    fi

    rm -f "$tarball"

    todo_done

    # ── Phase 3: fix permissions (needs real root) ──────────────────────
    #
    # Rootless podman export loses setuid bits and produces wrong
    # file ownership.  virt-customize's SELinux relabelling pass can
    # undo ownership changes, so we use two steps:
    # 1. virt-customize for rpm --setperms/--setugids (needs a running
    #    system with rpm database access)
    # 2. guestfish for chown (direct filesystem access, no post-processing)

    todo "fixing permissions..."
    if ! virt-customize -q -a "$image" --no-network --no-selinux-relabel \
            --run-command 'chown -R root:root /etc' \
            --run-command 'rpm --setperms -a 2>/dev/null || true' \
            --run-command 'rpm --setugids -a 2>/dev/null || true'; then
        die "failed to fix image permissions"
    fi

    todo_done
    todo "writing metadata..."

    write_meta "$cache_dir"
    todo_done
}

# ── Subcommand: create ──────────────────────────────────────────────────────

cmd_create() {
    local name=""
    local force=0
    local cli_config=""
    local cli_image_size=""
    local -a cli_packages=()

    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                usage_create
                exit 0
                ;;
            --force)
                force=1
                shift
                ;;
            --image-size)
                [ $# -ge 2 ] || die "--image-size requires an argument (e.g. 4G, 10G)"
                if ! [[ "$2" =~ ^[0-9]+[KMGT]?$ ]]; then
                    die "invalid --image-size format '$2': expected a number followed by K, M, G, or T (e.g. 4G, 10G, 512M)"
                fi
                cli_image_size="$2"
                shift 2
                ;;
            --install-packages)
                [ $# -ge 2 ] || die "--install-packages requires an argument"
                cli_packages+=("$2")
                shift 2
                ;;
            --config)
                [ $# -ge 2 ] || die "--config requires an argument"
                cli_config="$2"
                shift 2
                ;;
            -*)
                die "unknown option: $1"
                ;;
            *)
                if [ -z "$name" ]; then
                    name="$1"
                else
                    die "unexpected argument: $1"
                fi
                shift
                ;;
        esac
    done

    # Load configuration file (if any). --config overrides the default
    # search; otherwise walk from $PWD upward for .schupfn/config.yml,
    # falling back to $XDG_CONFIG_HOME/schupfn/<name>-config.yml (if the
    # container name is known from the CLI), then default-config.yml.
    # Config values provide defaults; CLI arguments always take precedence.
    local -a _cfg_exports=()
    local -a _cfg_exports_rw=()
    local -a _cfg_exports_cow=()
    local -a _cfg_packages=()
    local _cfg_container="" _cfg_command="" _cfg_memory="" _cfg_cpus="" _cfg_network="" _cfg_display="" _cfg_image_size="" _cfg_follow_git_worktrees=""
    local config_file
    if [ -n "$cli_config" ]; then
        if [ ! -f "$cli_config" ]; then
            die "config file not found: $cli_config"
        fi
        config_file="$cli_config"
        todo "loading config: $config_file"
        load_config "$config_file"
        todo_done
    elif config_file=$(find_config_file "$PWD" "$name"); then
        todo "loading config: $config_file"
        load_config "$config_file"
        todo_done
    fi

    # Container name: CLI wins, then config
    if [ -z "$name" ]; then
        name="${_cfg_container}"
    fi
    if [ -z "$name" ]; then
        die "container name required. See '$PROGNAME create --help'."
    fi

    validate_container_name "$name"

    local image_size="${cli_image_size:-${_cfg_image_size}}"

    # Merge: config packages come first, CLI packages are appended
    local -a packages=("${_cfg_packages[@]}" "${cli_packages[@]}")

    check_prerequisites

    # Verify the container exists
    if ! podman container inspect "$name" >/dev/null 2>&1; then
        die "container '$name' does not exist. Create it with 'toolbox create $name'."
    fi

    local cache_dir="$CACHE_DIR/$name"
    local image="$cache_dir/$name.qcow2"

    # If the image already exists, require --force to rebuild
    if [ -f "$image" ] && [ "$force" != "1" ]; then
        die "image for '$name' already exists. Use --force to rebuild."
    fi

    export_image "$name" "$cache_dir" "$image_size" "${packages[@]}"

    info "image ready: $image"
}

# ── Subcommand: enter ───────────────────────────────────────────────────────

cmd_enter() {
    local name=""
    local memory=""
    local cpus=""
    local network=""
    local verbose=0
    local console=0
    local command=""
    local cli_config=""
    local -a exports=()
    local -a exports_rw=()
    local -a exports_cow=()
    local cli_memory="" cli_cpus="" cli_network="" cli_display=""
    local follow_git_worktrees=0

    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                usage_enter
                exit 0
                ;;
            --export-ro)
                [ $# -ge 2 ] || die "--export-ro requires an argument"
                exports+=("$2")
                shift 2
                ;;
            --export-rw)
                [ $# -ge 2 ] || die "--export-rw requires an argument"
                exports_rw+=("$2")
                shift 2
                ;;
            --export-cow)
                [ $# -ge 2 ] || die "--export-cow requires an argument"
                exports_cow+=("$2")
                shift 2
                ;;
            --dotfile)
                die "--dotfile has been removed; use --export-ro instead"
                ;;
            --memory)
                [ $# -ge 2 ] || die "--memory requires an argument"
                cli_memory="$2"
                shift 2
                ;;
            --cpus)
                [ $# -ge 2 ] || die "--cpus requires an argument"
                cli_cpus="$2"
                shift 2
                ;;
            --command)
                [ $# -ge 2 ] || die "--command requires an argument"
                command="$2"
                shift 2
                ;;
            --config)
                [ $# -ge 2 ] || die "--config requires an argument"
                cli_config="$2"
                shift 2
                ;;
            --no-network)
                cli_network="0"
                shift
                ;;
            --display)
                [ $# -ge 2 ] || die "--display requires an argument (e.g. virtio, qxl, std)"
                cli_display="$2"
                shift 2
                ;;
            --verbose)
                verbose=1
                shift
                ;;
            --follow-git-worktrees)
                follow_git_worktrees=1
                shift
                ;;
            --console)
                console=1
                shift
                ;;
            -*)
                die "unknown option: $1"
                ;;
            *)
                if [ -z "$name" ]; then
                    name="$1"
                else
                    die "unexpected argument: $1"
                fi
                shift
                ;;
        esac
    done

    # Load configuration file (if any). --config overrides the default
    # search; otherwise walk from $PWD upward for .schupfn/config.yml,
    # falling back to $XDG_CONFIG_HOME/schupfn/<name>-config.yml (if the
    # container name is known from the CLI), then default-config.yml.
    # Config values provide defaults; CLI arguments always take precedence.
    local -a _cfg_exports=()
    local -a _cfg_exports_rw=()
    local -a _cfg_exports_cow=()
    local -a _cfg_packages=()
    local _cfg_container="" _cfg_command="" _cfg_memory="" _cfg_cpus="" _cfg_network="" _cfg_display="" _cfg_image_size="" _cfg_follow_git_worktrees=""
    local config_file
    if [ -n "$cli_config" ]; then
        if [ ! -f "$cli_config" ]; then
            die "config file not found: $cli_config"
        fi
        config_file="$cli_config"
        todo "using config: $config_file"
        load_config "$config_file"
        todo_done
    elif config_file=$(find_config_file "$PWD" "$name"); then
        todo "using config: $config_file"
        load_config "$config_file"
        todo_done
    fi

    # Container name: CLI wins, then config
    if [ -z "$name" ]; then
        name="${_cfg_container}"
    fi
    if [ -z "$name" ]; then
        die "container name required. See '$PROGNAME enter --help'."
    fi

    validate_container_name "$name"

    # Merge: config exports come first, CLI exports are appended
    exports=("${_cfg_exports[@]}" "${exports[@]}")
    exports_rw=("${_cfg_exports_rw[@]}" "${exports_rw[@]}")
    exports_cow=("${_cfg_exports_cow[@]}" "${exports_cow[@]}")

    # Scalars: CLI wins, then config, then built-in default
    memory="${cli_memory:-${_cfg_memory:-4G}}"
    cpus="${cli_cpus:-${_cfg_cpus}}"
    network="${cli_network:-${_cfg_network:-1}}"
    local display="${cli_display:-${_cfg_display}}"
    if [ "$follow_git_worktrees" != "1" ] && [ "${_cfg_follow_git_worktrees}" = "1" ]; then
        follow_git_worktrees=1
    fi
    if [ -z "$command" ]; then
        command="${_cfg_command}"
    fi

    if [ "$console" = "1" ] && [ -n "$display" ]; then
        die "--console and --display cannot be used together"
    fi

    check_prerequisites

    local cache_dir="$CACHE_DIR/$name"
    local image="$cache_dir/$name.qcow2"
    if [ ! -f "$image" ]; then
        die "no image found for '$name'. Run '$PROGNAME create $name' first."
    fi

    # Resolve $PWD to its physical path so the 9p mount uses a real
    # directory, not a symlink. Keep the logical path so the user lands
    # in the expected directory.
    local physical_pwd
    if ! physical_pwd="$(realpath "$PWD" 2>/dev/null)" || [ -z "$physical_pwd" ]; then
        die "cannot resolve current directory '$PWD' —" \
            "it may have been deleted or permissions are insufficient."
    fi

    # Paths with spaces or colons cannot be passed via the kernel command
    # line (parsed by word-splitting in the guest mount script).
    if [[ "$physical_pwd" == *" "* || "$physical_pwd" == *$'\t'* || "$physical_pwd" == *":"* ]]; then
        die "current directory path contains whitespace or colons, which is not supported: $physical_pwd"
    fi
    if [[ "$HOME" == *" "* || "$HOME" == *$'\t'* || "$HOME" == *":"* ]]; then
        die "\$HOME contains whitespace or colons, which is not supported: $HOME"
    fi

    # Classify each export path as a directory or file.
    local -a rw_mount_dirs=("$physical_pwd")   # $PWD is always read-write
    local -a ro_mount_dirs=()
    local -a cow_mount_dirs=()
    local -a rootfs_symlinks=()
    local -a dotfiles=()

    # If $PWD is (or passes through) a symlink, remember the mapping
    if [ "$PWD" != "$physical_pwd" ]; then
        rootfs_symlinks+=("$PWD:$physical_pwd")
    fi

    for path in "${exports[@]}"; do
        classify_export "$path" "ro"
    done
    for path in "${exports_rw[@]}"; do
        classify_export "$path" "rw"
    done
    for path in "${exports_cow[@]}"; do
        classify_export "$path" "cow"
    done

    # If --follow-git-worktrees is set and $PWD is a git worktree,
    # automatically export the git common directory (the main .git dir)
    # so that git operations work inside the VM.
    if [ "$follow_git_worktrees" = "1" ]; then
        local git_common_dir
        git_common_dir=$(git -C "$physical_pwd" rev-parse --git-common-dir 2>/dev/null) || true
        if [ -n "$git_common_dir" ] && [ "$git_common_dir" != ".git" ]; then
            # It's a worktree — git_common_dir is a path (absolute or
            # relative) to the main .git directory.  Resolve it and add
            # as rw export if not already covered by an existing mount.
            local resolved_git_dir
            resolved_git_dir=$(realpath "$git_common_dir" 2>/dev/null) || true
            if [ -n "$resolved_git_dir" ] && [ -d "$resolved_git_dir" ]; then
                # Check if it's already covered by an existing mount
                local already_exported=0
                for d in "${rw_mount_dirs[@]}" "${ro_mount_dirs[@]}" "${cow_mount_dirs[@]}"; do
                    if [[ "$resolved_git_dir" = "$d" ]] || [[ "$resolved_git_dir" = "$d/"* ]]; then
                        already_exported=1
                        break
                    fi
                done
                if [ "$already_exported" = "0" ]; then
                    info "exporting git common dir (worktree): $resolved_git_dir"
                    rw_mount_dirs+=("$resolved_git_dir")
                fi
            fi
        fi
    fi

    check_nested_export_conflicts

    # Variables set by _boot_vm in our scope
    local ssh_port="" session_image="" serial_log=""
    local -a ssh_opts=()

    # Ensure we clean up the VM on exit (Ctrl-C, errors, normal exit).
    # Set before _boot_vm so partial failures are cleaned up too.
    trap 'rm -f "$session_image" "$serial_log"; cleanup_vm' EXIT

    # Boot the VM (may exit directly in console mode)
    _boot_vm

    # SSH into the VM
    local username
    username=$(whoami)
    local quoted_pwd
    printf -v quoted_pwd '%q' "$PWD"
    local remote_cmd
    if [ -n "$command" ]; then
        remote_cmd="cd $quoted_pwd && $command"
    else
        remote_cmd="cd $quoted_pwd && exec \$SHELL -l"
    fi

    info "Entering VM based on '$name' now."

    ssh -t \
        "${ssh_opts[@]}" \
        -l "$username" \
        127.0.0.1 \
        "$remote_cmd"
    local rc=$?

    if [ -n "$command" ]; then
        exit "$rc"
    elif [ $rc -ne 0 ]; then
        warn "SSH session exited with status $rc"
    fi
}

# ── Subcommand: start ───────────────────────────────────────────────────────

cmd_start() {
    local name=""
    local memory=""
    local cpus=""
    local network=""
    local verbose=0
    local console=0
    local cli_config=""
    local -a exports=()
    local -a exports_rw=()
    local -a exports_cow=()
    local cli_memory="" cli_cpus="" cli_network="" cli_display=""
    local follow_git_worktrees=0

    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                usage_start
                exit 0
                ;;
            --export-ro)
                [ $# -ge 2 ] || die "--export-ro requires an argument"
                exports+=("$2")
                shift 2
                ;;
            --export-rw)
                [ $# -ge 2 ] || die "--export-rw requires an argument"
                exports_rw+=("$2")
                shift 2
                ;;
            --export-cow)
                [ $# -ge 2 ] || die "--export-cow requires an argument"
                exports_cow+=("$2")
                shift 2
                ;;
            --dotfile)
                die "--dotfile has been removed; use --export-ro instead"
                ;;
            --memory)
                [ $# -ge 2 ] || die "--memory requires an argument"
                cli_memory="$2"
                shift 2
                ;;
            --cpus)
                [ $# -ge 2 ] || die "--cpus requires an argument"
                cli_cpus="$2"
                shift 2
                ;;
            --config)
                [ $# -ge 2 ] || die "--config requires an argument"
                cli_config="$2"
                shift 2
                ;;
            --no-network)
                cli_network="0"
                shift
                ;;
            --display)
                [ $# -ge 2 ] || die "--display requires an argument (e.g. virtio, qxl, std)"
                cli_display="$2"
                shift 2
                ;;
            --verbose)
                verbose=1
                shift
                ;;
            --follow-git-worktrees)
                follow_git_worktrees=1
                shift
                ;;
            -*)
                die "unknown option: $1"
                ;;
            *)
                if [ -z "$name" ]; then
                    name="$1"
                else
                    die "unexpected argument: $1"
                fi
                shift
                ;;
        esac
    done

    # Load configuration file
    local -a _cfg_exports=()
    local -a _cfg_exports_rw=()
    local -a _cfg_exports_cow=()
    local -a _cfg_packages=()
    local _cfg_container="" _cfg_command="" _cfg_memory="" _cfg_cpus="" _cfg_network="" _cfg_display="" _cfg_image_size="" _cfg_follow_git_worktrees=""
    local config_file
    if [ -n "$cli_config" ]; then
        if [ ! -f "$cli_config" ]; then
            die "config file not found: $cli_config"
        fi
        config_file="$cli_config"
        todo "using config: $config_file"
        load_config "$config_file"
        todo_done
    elif config_file=$(find_config_file "$PWD" "$name"); then
        todo "using config: $config_file"
        load_config "$config_file"
        todo_done
    fi

    # Container name: CLI wins, then config
    if [ -z "$name" ]; then
        name="${_cfg_container}"
    fi
    if [ -z "$name" ]; then
        die "container name required. See '$PROGNAME start --help'."
    fi

    validate_container_name "$name"

    # Merge: config exports come first, CLI exports are appended
    exports=("${_cfg_exports[@]}" "${exports[@]}")
    exports_rw=("${_cfg_exports_rw[@]}" "${exports_rw[@]}")
    exports_cow=("${_cfg_exports_cow[@]}" "${exports_cow[@]}")

    # Scalars: CLI wins, then config, then built-in default
    memory="${cli_memory:-${_cfg_memory:-4G}}"
    cpus="${cli_cpus:-${_cfg_cpus}}"
    network="${cli_network:-${_cfg_network:-1}}"
    local display="${cli_display:-${_cfg_display}}"
    if [ "$follow_git_worktrees" != "1" ] && [ "${_cfg_follow_git_worktrees}" = "1" ]; then
        follow_git_worktrees=1
    fi

    check_prerequisites

    local cache_dir="$CACHE_DIR/$name"
    local image="$cache_dir/$name.qcow2"
    if [ ! -f "$image" ]; then
        die "no image found for '$name'. Run '$PROGNAME create $name' first."
    fi

    # Resolve $PWD to its physical path
    local physical_pwd
    if ! physical_pwd="$(realpath "$PWD" 2>/dev/null)" || [ -z "$physical_pwd" ]; then
        die "cannot resolve current directory '$PWD' —" \
            "it may have been deleted or permissions are insufficient."
    fi

    if [[ "$physical_pwd" == *" "* || "$physical_pwd" == *$'\t'* || "$physical_pwd" == *":"* ]]; then
        die "current directory path contains whitespace or colons, which is not supported: $physical_pwd"
    fi
    if [[ "$HOME" == *" "* || "$HOME" == *$'\t'* || "$HOME" == *":"* ]]; then
        die "\$HOME contains whitespace or colons, which is not supported: $HOME"
    fi

    # Classify each export path
    local -a rw_mount_dirs=("$physical_pwd")
    local -a ro_mount_dirs=()
    local -a cow_mount_dirs=()
    local -a rootfs_symlinks=()
    local -a dotfiles=()

    if [ "$PWD" != "$physical_pwd" ]; then
        rootfs_symlinks+=("$PWD:$physical_pwd")
    fi

    for path in "${exports[@]}"; do
        classify_export "$path" "ro"
    done
    for path in "${exports_rw[@]}"; do
        classify_export "$path" "rw"
    done
    for path in "${exports_cow[@]}"; do
        classify_export "$path" "cow"
    done

    if [ "$follow_git_worktrees" = "1" ]; then
        local git_common_dir
        git_common_dir=$(git -C "$physical_pwd" rev-parse --git-common-dir 2>/dev/null) || true
        if [ -n "$git_common_dir" ] && [ "$git_common_dir" != ".git" ]; then
            local resolved_git_dir
            resolved_git_dir=$(realpath "$git_common_dir" 2>/dev/null) || true
            if [ -n "$resolved_git_dir" ] && [ -d "$resolved_git_dir" ]; then
                local already_exported=0
                for d in "${rw_mount_dirs[@]}" "${ro_mount_dirs[@]}" "${cow_mount_dirs[@]}"; do
                    if [[ "$resolved_git_dir" = "$d" ]] || [[ "$resolved_git_dir" = "$d/"* ]]; then
                        already_exported=1
                        break
                    fi
                done
                if [ "$already_exported" = "0" ]; then
                    info "exporting git common dir (worktree): $resolved_git_dir"
                    rw_mount_dirs+=("$resolved_git_dir")
                fi
            fi
        fi
    fi

    check_nested_export_conflicts

    # Variables set by _boot_vm in our scope
    local ssh_port="" session_image="" serial_log=""
    local -a ssh_opts=()

    # Set a temporary EXIT trap so boot failures are cleaned up
    trap 'rm -f "$session_image" "$serial_log"; cleanup_vm' EXIT

    # Boot the VM
    _boot_vm

    # VM is running — clear the EXIT trap so the VM survives our exit
    trap - EXIT

    info "VM '$name' is running (pid $QEMU_PID, ssh port $ssh_port)"
    info "use '$PROGNAME join $name' to connect, '$PROGNAME stop $name' to shut down"
}

# ── Subcommand: stop ────────────────────────────────────────────────────────

cmd_stop() {
    local name=""

    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                usage_stop
                exit 0
                ;;
            -*)
                die "unknown option: $1"
                ;;
            *)
                if [ -z "$name" ]; then
                    name="$1"
                else
                    die "unexpected argument: $1"
                fi
                shift
                ;;
        esac
    done

    # Find matching sessions
    local -a sessions=()
    mapfile -t sessions < <(list_active_sessions "$name")
    if [ ${#sessions[@]} -eq 1 ] && [ -z "${sessions[0]}" ]; then
        sessions=()
    fi

    if [ ${#sessions[@]} -eq 0 ]; then
        if [ -n "$name" ]; then
            die "no running VM found for '$name'"
        else
            die "no running VMs found"
        fi
    fi

    local session_file
    if [ ${#sessions[@]} -eq 1 ]; then
        session_file="${sessions[0]}"
    else
        # Multiple sessions — show them and ask the user to pick one
        echo "Multiple running VMs found:"
        echo ""
        local idx=1
        for sf in "${sessions[@]}"; do
            local sf_name sf_pid sf_port sf_workdir sf_started
            sf_name=$(read_session "$sf" "name")
            sf_pid=$(read_session "$sf" "pid")
            sf_port=$(read_session "$sf" "ssh_port")
            sf_workdir=$(read_session "$sf" "workdir")
            sf_started=$(read_session "$sf" "started_at")
            printf "  %d) %s (pid %s, port %s, dir %s, started %s)\n" \
                "$idx" "$sf_name" "$sf_pid" "$sf_port" "$sf_workdir" "$sf_started"
            idx=$((idx + 1))
        done
        echo ""
        printf "Select VM [1-%d]: " "${#sessions[@]}"
        local choice
        read -r choice
        if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#sessions[@]}" ]; then
            die "invalid selection"
        fi
        session_file="${sessions[$((choice - 1))]}"
    fi

    # Read session details
    if [ -z "$name" ]; then
        name=$(read_session "$session_file" "name")
    fi
    local vm_pid
    vm_pid=$(read_session "$session_file" "pid")
    local session_image serial_log
    session_image=$(read_session "$session_file" "session_image")
    serial_log=$(read_session "$session_file" "serial_log")

    if ! kill -0 "$vm_pid" 2>/dev/null; then
        info "VM '$name' (pid $vm_pid) is no longer running, cleaning up"
        rm -f "$session_image" "$serial_log"
        remove_session "$name" "$vm_pid"
        return 0
    fi

    # Wait for active join sessions to finish
    local joins
    joins=$(count_active_joins "$name" "$vm_pid")
    if [ "$joins" -gt 0 ]; then
        warn "VM kept alive — $joins active join session(s) still connected"
        info "waiting for join session(s) to disconnect before shutting down..."
        local wait_count=0
        while true; do
            joins=$(count_active_joins "$name" "$vm_pid")
            [ "$joins" -gt 0 ] || break
            wait_count=$((wait_count + 1))
            if [ "$wait_count" -ge 300 ]; then
                warn "timed out waiting for join sessions after 300s, shutting down anyway"
                break
            fi
            sleep 1
        done
        [ "$joins" -eq 0 ] && info "all join sessions disconnected"
    fi

    todo "shutting down VM '$name' (pid $vm_pid)..."

    # Collect the full process tree (children before parents)
    local -a pids
    mapfile -t pids < <(_pstree_pids "$vm_pid")

    # Graceful SIGTERM to the whole tree
    for pid in "${pids[@]}"; do
        kill "$pid" 2>/dev/null
    done

    # Give them a moment to shut down
    local i=0
    while kill -0 "$vm_pid" 2>/dev/null && [ "$i" -lt 5 ]; do
        sleep 1
        i=$((i + 1))
    done

    # Force kill any survivors
    if kill -0 "$vm_pid" 2>/dev/null; then
        mapfile -t pids < <(_pstree_pids "$vm_pid")
        for pid in "${pids[@]}"; do
            kill -9 "$pid" 2>/dev/null
        done
    fi
    todo_done

    # Clean up session snapshot, serial log, and session file
    rm -f "$session_image" "$serial_log"
    remove_session "$name" "$vm_pid"
}

# ── Subcommand: join ────────────────────────────────────────────────────────

cmd_join() {
    local name=""
    local command=""

    while [ $# -gt 0 ]; do
        case "$1" in
            -h|--help)
                usage_join
                exit 0
                ;;
            --command)
                [ $# -ge 2 ] || die "--command requires an argument"
                command="$2"
                shift 2
                ;;
            -*)
                die "unknown option: $1"
                ;;
            *)
                if [ -z "$name" ]; then
                    name="$1"
                else
                    die "unexpected argument: $1"
                fi
                shift
                ;;
        esac
    done

    # Find matching sessions
    local -a sessions=()
    mapfile -t sessions < <(list_active_sessions "$name")
    # mapfile with empty input may leave a single empty element; filter it.
    if [ ${#sessions[@]} -eq 1 ] && [ -z "${sessions[0]}" ]; then
        sessions=()
    fi

    if [ ${#sessions[@]} -eq 0 ]; then
        if [ -n "$name" ]; then
            die "no running VM found for '$name'"
        else
            die "no running VMs found"
        fi
    fi

    local session_file
    if [ ${#sessions[@]} -eq 1 ]; then
        session_file="${sessions[0]}"
    else
        # Multiple sessions — show them and ask the user to pick one
        echo "Multiple running VMs found:"
        echo ""
        local idx=1
        for sf in "${sessions[@]}"; do
            local sf_name sf_pid sf_port sf_workdir sf_started
            sf_name=$(read_session "$sf" "name")
            sf_pid=$(read_session "$sf" "pid")
            sf_port=$(read_session "$sf" "ssh_port")
            sf_workdir=$(read_session "$sf" "workdir")
            sf_started=$(read_session "$sf" "started_at")
            printf "  %d) %s (pid %s, port %s, dir %s, started %s)\n" \
                "$idx" "$sf_name" "$sf_pid" "$sf_port" "$sf_workdir" "$sf_started"
            idx=$((idx + 1))
        done
        echo ""
        printf "Select VM [1-%d]: " "${#sessions[@]}"
        local choice
        read -r choice
        if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#sessions[@]}" ]; then
            die "invalid selection"
        fi
        session_file="${sessions[$((choice - 1))]}"
    fi

    # Read session details
    local ssh_port workdir ssh_key_file
    ssh_port=$(read_session "$session_file" "ssh_port")
    workdir=$(read_session "$session_file" "workdir")
    ssh_key_file=$(read_session "$session_file" "ssh_key")

    if ! [[ "$ssh_port" =~ ^[0-9]+$ ]]; then
        die "corrupted session file: invalid ssh_port '$ssh_port'"
    fi

    if [ -z "$name" ]; then
        name=$(read_session "$session_file" "name")
    fi

    local vm_pid
    vm_pid=$(read_session "$session_file" "pid")

    info "joining VM '$name' (ssh port $ssh_port)..."

    # Register this join session so 'enter' can detect it
    acquire_join_lock "$name" "$vm_pid" "$$"
    trap 'release_join_lock "$name" "$vm_pid" "$$"' EXIT

    # Build SSH options
    local -a ssh_opts=(
        -4
        -F /dev/null
        -o StrictHostKeyChecking=no
        -o UserKnownHostsFile=/dev/null
        -o IdentitiesOnly=yes
        -o PubkeyAcceptedAlgorithms=+ssh-rsa
        -o LogLevel=ERROR
        -p "$ssh_port"
    )
    if [ -n "$ssh_key_file" ]; then
        ssh_opts+=(-i "$ssh_key_file")
    fi

    local quoted_workdir
    printf -v quoted_workdir '%q' "$workdir"
    local remote_cmd
    if [ -n "$command" ]; then
        remote_cmd="cd $quoted_workdir && $command"
    else
        remote_cmd="cd $quoted_workdir && exec \$SHELL -l"
    fi

    ssh -t \
        "${ssh_opts[@]}" \
        -l "$(whoami)" \
        127.0.0.1 \
        "$remote_cmd"
    local rc=$?

    release_join_lock "$name" "$vm_pid" "$$"

    if [ -n "$command" ]; then
        exit "$rc"
    elif [ $rc -ne 0 ]; then
        warn "SSH session exited with status $rc"
    fi
}

# ── Subcommand: list ────────────────────────────────────────────────────────

cmd_list() {
    if [ ! -d "$CACHE_DIR" ]; then
        echo "No cached VM images."
        return
    fi

    local found=0

    # Clean up stale session files once, then build a per-name VM count
    # in a single pass instead of calling list_active_sessions per image.
    cleanup_stale_sessions

    declare -A _vm_counts=()
    if [ -d "$SESSION_DIR" ]; then
        for session_file in "$SESSION_DIR"/*.session; do
            [ -f "$session_file" ] || continue
            local sname
            sname=$(read_session "$session_file" "name")
            if [ -n "$sname" ]; then
                _vm_counts["$sname"]=$(( ${_vm_counts["$sname"]:-0} + 1 ))
            fi
        done
    fi

    printf "%-20s %-10s %-10s %-10s %s\n" "NAME" "STATUS" "VMs" "SIZE" "EXPORTED"
    printf "%-20s %-10s %-10s %-10s %s\n" "----" "------" "---" "----" "--------"

    for cache_entry in "$CACHE_DIR"/*/; do
        [ -d "$cache_entry" ] || continue

        local name
        name=$(basename "$cache_entry")
        found=1

        # Get size of the qcow2 image
        local size
        local qcow2_file="$cache_entry/$name.qcow2"
        if [ -f "$qcow2_file" ]; then
            size=$(du -sh "$qcow2_file" 2>/dev/null | cut -f1)
        else
            size="?"
        fi

        # Get export date
        local exported_at
        exported_at=$(read_meta "$cache_entry" "exported_at")
        if [ -z "$exported_at" ]; then
            exported_at="unknown"
        fi

        # Check if the source container still exists
        local status="-"
        if ! podman container inspect "$name" >/dev/null 2>&1; then
            status="orphaned"
        fi

        # Look up running VM count from the pre-built map
        local vm_count="${_vm_counts["$name"]:-0}"
        local vm_display
        if [ "$vm_count" -gt 0 ]; then
            vm_display="$vm_count running"
        else
            vm_display="-"
        fi

        printf "%-20s %-10s %-10s %-10s %s\n" "$name" "$status" "$vm_display" "$size" "$exported_at"
    done

    if [ "$found" = "0" ]; then
        echo "No cached VM images."
    fi
}

# ── Subcommand: clean ──────────────────────────────────────────────────────

cmd_clean() {
    local name="${1:-}"

    if [ -n "$name" ]; then
        validate_container_name "$name"
        local cache_entry="$CACHE_DIR/$name"
        if [ ! -d "$cache_entry" ]; then
            die "no cached image for '$name'"
        fi

        todo "removing cached image for '$name'..."
        rm -rf "$cache_entry"
        todo_done
        return
    fi

    # Clean all
    if [ ! -d "$CACHE_DIR" ]; then
        echo "Nothing to clean."
        return
    fi

    local count
    count=$(find "$CACHE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)

    if [ "$count" = "0" ]; then
        echo "Nothing to clean."
        return
    fi

    printf "%-20s %s\n" "NAME" "SIZE"
    printf "%-20s %s\n" "----" "----"
    for cache_entry in "$CACHE_DIR"/*/; do
        [ -d "$cache_entry" ] || continue
        local entry_name entry_size
        entry_name=$(basename "$cache_entry")
        local qcow2_file="$cache_entry/$entry_name.qcow2"
        if [ -f "$qcow2_file" ]; then
            entry_size=$(du -sh "$qcow2_file" 2>/dev/null | cut -f1)
        else
            entry_size="?"
        fi
        printf "%-20s %s\n" "$entry_name" "$entry_size"
    done
    echo ""
    printf "Remove all %d cached images? [y/N] " "$count"
    read -r answer
    case "$answer" in
        y|Y|yes|YES)
            rm -rf "$CACHE_DIR"
            info "cleaned $count entries"
            ;;
        *)
            info "aborted"
            ;;
    esac
}

# ── Main ────────────────────────────────────────────────────────────────────

main() {
    if [ $# -eq 0 ]; then
        usage
        exit 1
    fi

    local command="$1"
    shift

    case "$command" in
        create)
            cmd_create "$@"
            ;;
        enter)
            cmd_enter "$@"
            ;;
        start)
            cmd_start "$@"
            ;;
        stop)
            cmd_stop "$@"
            ;;
        join)
            cmd_join "$@"
            ;;
        list|ls)
            cmd_list "$@"
            ;;
        clean)
            cmd_clean "$@"
            ;;
        -h|--help|help)
            usage
            exit 0
            ;;
        --version)
            echo "$PROGNAME $VERSION"
            exit 0
            ;;
        *)
            die "unknown command: $command. See '$PROGNAME --help'."
            ;;
    esac
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
