#!/bin/bash
# ============================================================
#  NiiX Arch Installer  //  niixa.org
#  Modes   : Dual-boot (Arch + Windows)  |  Single-boot (bare drive)
#  Stack   : LUKS2 - Btrfs - Limine - CachyOS - GNOME (customised)
#  Desktop : dash-to-panel + ArcMenu (clean desktop, no icons)
#  Extras  : Auto GPU drivers - Gaming (Steam/Lutris/Heroic) - Wi-Fi hotspot
#
#  Requirements:
#    - UEFI system (not legacy BIOS)
#    - Secure Boot OFF  (Limine does not support it)
#    - Dual-boot : free/unallocated space OR a deletable partition
#    - Single-boot : a drive you are willing to completely wipe
#    - Internet connection on the Arch live ISO
#
#  Hardcoded preferences:
#    Keyboard   : us / ch
#    Locale     : en_GB.UTF-8
#    Mirrors    : Switzerland
#    Desktop    : GNOME
#    Bootloader : Limine (UEFI)
#    Encryption : LUKS2
#    Filesystem : Btrfs  (@  @home  @snapshots  @var_log)
#    Kernel     : linux-cachyos (BORE scheduler, installed first-boot)
#    Swap       : ZRAM (zstd, auto-sized)
#
#  HOW TO RUN from the Arch ISO:
#    bash <(curl -fsSL https://niixa.org/install)
#  Before touching anything, the installer checks itself against the sha256
#  published separately on GitHub (techniixdotcom/niixarch-install) and
#  refuses to run on a mismatch. Strongest option - verify BEFORE running,
#  which a tampered download could not skip:
#    curl -fsSLo install https://niixa.org/install && curl -fsSL https://raw.githubusercontent.com/techniixdotcom/niixarch-install/main/install.sha256 | grep ' install$' | sha256sum -c && bash install
# ============================================================

set -uo pipefail

# -- Logging --------------------------------------------------
LOGFILE="/root/niixarch-install-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOGFILE") 2>&1

# -- Colours - matched to niixa.org palette -------------
#   GREEN  = neon green  #0aff9e  (256-colour: 118)
#   PINK   = neon pink   #ff0a6c  (256-colour: 205)
GREEN='\033[38;5;118m'
PINK='\033[38;5;205m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
DIM='\033[2;37m'
BOLD='\033[1m'
NC='\033[0m'

# -- UI kit palette (real ESC, for the printf-based helpers below) --------------
# One look everywhere: white body text, green + pink accents. Red/amber are kept
# only for warnings and errors so failures stay visually distinct (safety).
C_GREEN=$'\033[38;5;118m'; C_PINK=$'\033[38;5;205m'; C_WHITE=$'\033[38;5;255m'
C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RED=$'\033[38;5;203m'; C_YEL=$'\033[38;5;215m'; C_NC=$'\033[0m'

# -- Hardcoded preferences ------------------------------------
KEYMAP="us"        # console keymap  (loadkeys / vconsole.conf) - set by gather_input
KEYMAP_X11="us"    # X11/Wayland layout (localectl / XkbLayout) - set by gather_input
KEYMAP_X11_VARIANT=""          # XkbVariant (e.g. dvorak) - set by gather_input
KEYBOARD_NAME="English (US)"   # human-readable, for the summary screen
LOCALE="en_GB.UTF-8"
MIRROR_COUNTRY="Switzerland"
MAPPER="cryptroot"
BTRFS_OPTS="noatime,compress=zstd,space_cache=v2"
NIIX_BUILD="2026-10-01-r30"  # bump on every change - printed on the banner to verify freshness

# -- Install mode ---------------------------------------------
INSTALL_MODE=""    # "dualboot" | "singleboot"
SINGLE_DRIVE=""    # target drive for single-boot (entire drive wiped)

# -- Detected at runtime --------------------------------------
EFI_PART=""
EFI_DRIVE=""
EFI_PART_NUM=""
TARGET_DRIVE=""
LUKS_PART=""
LUKS_UUID=""

# -- User input -----------------------------------------------
HOSTNAME=""; USERNAME=""; USER_PASS=""; LUKS_PASS=""; TIMEZONE=""
FREE_START=""; FREE_END=""
USE_MITIGATIONS=false

# -- Hardware (detected at runtime) ---------------------------
CPU_VENDOR=""; CPU_MODEL=""; GPU_VENDOR=""; GPU_MODEL=""; GPU_GEN=""; NVIDIA_LEGACY=""
HAS_NVIDIA=0; HAS_AMD=0; HAS_INTEL=0; GPU_COUNT=0; IS_HYBRID=false; OFFLOAD_MODE=""
DGPU_PCI=""; DRI_PRIME_TAG="1"
MEM_TOTAL_GB=0; HAS_NVME=false; HAS_SSD=false

# -- Output helpers ------------------------------------------
# -- UI kit : titles, status lines, live % bar, spinner (one consistent look) ---
# Animations draw straight to the terminal so their \r redraws never pollute the
# tee'd log; full command output is archived to the log while the screen stays
# tidy. Falls back to stdout when there is no controlling terminal.
if { exec 3>/dev/tty; } 2>/dev/null; then :; else exec 3>&1; fi
ui_hr()    { printf '  %s────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_NC"; }
ui_title() { printf '\n  %s▸%s  %s%s%s\n' "$C_PINK" "$C_NC" "$C_BOLD$C_WHITE" "$*" "$C_NC"; ui_hr; }
ui_ok()    { printf '  %s✔%s  %s%s%s\n' "$C_GREEN" "$C_NC" "$C_WHITE" "$*" "$C_NC"; }
ui_info()  { printf '  %s•%s  %s%s%s\n' "$C_PINK"  "$C_NC" "$C_WHITE" "$*" "$C_NC"; }
ui_warn()  { printf '  %s!%s  %s%s%s\n' "$C_YEL"   "$C_NC" "$C_WHITE" "$*" "$C_NC"; }

# ui_bar CURRENT TOTAL [label] : redraw a themed percentage bar in place.
ui_bar() {
    local cur=$1 tot=$2 label=${3:-} width=28 i fill pct done_="" todo_=""
    [ "$tot" -gt 0 ] 2>/dev/null || tot=1
    [ "$cur" -gt "$tot" ] 2>/dev/null && cur=$tot
    pct=$(( cur * 100 / tot )); fill=$(( cur * width / tot ))
    for ((i=0;i<fill;i++)); do done_+='█'; done
    for ((i=fill;i<width;i++)); do todo_+='░'; done
    printf '\r  %s[%s%s%s%s%s]%s %s%3d%%%s  %s%s%s' \
        "$C_PINK" "$C_GREEN" "$done_" "$C_DIM" "$todo_" "$C_NC" "$C_PINK" \
        "$C_WHITE" "$pct" "$C_NC" "$C_DIM" "$label" "$C_NC" >&3
    [ "$cur" -ge "$tot" ] && printf '\n' >&3
}

# ui_run "Message" cmd args... : animate while cmd runs, archive its output to
# the log, print a ✔/! summary, and return cmd's real exit code.
ui_run() {
    local msg=$1; shift
    local frames='|/-\' i=0 start el rc pid
    "$@" >>"${LOGFILE:-/dev/null}" 2>&1 &
    pid=$!; start=$SECONDS
    while kill -0 "$pid" 2>/dev/null; do
        i=$(( (i+1) % 4 )); el=$(( SECONDS - start ))
        printf '\r  %s%s%s  %s%s%s %s(%ds)%s   ' \
            "$C_PINK" "${frames:i:1}" "$C_NC" "$C_WHITE" "$msg" "$C_NC" "$C_DIM" "$el" "$C_NC" >&3
        sleep 0.1
    done
    wait "$pid"; rc=$?; el=$(( SECONDS - start ))
    printf '\r\033[K' >&3
    if [ "$rc" -eq 0 ]; then ui_ok "$msg  ${C_DIM}(${el}s)${C_NC}"
    else ui_warn "$msg  ${C_DIM}(failed rc=$rc, ${el}s - see log)${C_NC}"; fi
    return "$rc"
}

log()  { ui_ok "$@"; }
warn() { ui_warn "$@"; }
err()  {
    printf '\n  %s✗%s  %sERROR%s  %s%s%s\n' "$C_RED" "$C_NC" "$C_RED" "$C_NC" "$C_WHITE" "$*" "$C_NC"
    dump_state "ERROR: $*" 2>/dev/null || true
    ui_info "Log saved to: ${LOGFILE}"
    # Offer to copy logs to USB BEFORE unmounting - this is exactly the
    # moment logs matter most, and /mnt/var/log/niixarch-install.log (if it
    # exists yet) is only readable while /mnt is still mounted.
    copy_logs_to_usb 2>/dev/null || true
    # Leave a clean slate: no half-open LUKS container or dangling /mnt mounts.
    swapoff -a 2>/dev/null || true
    umount -R /mnt 2>/dev/null || true
    cryptsetup close "$MAPPER" 2>/dev/null || true
    exit 1
}

# Signal handler: on Ctrl+C / kill during install, unwind mounts + encryption so the
# machine is never left in a half-installed state. Reset before the final reboot
# prompt so Ctrl+C there still drops the user to a shell with /mnt intact.
cleanup_on_fail() {
    trap - INT TERM
    echo
    warn "Interrupted - cleaning up (unmounting target, closing encryption)..."
    copy_logs_to_usb 2>/dev/null || true
    swapoff -a 2>/dev/null || true
    umount -R /mnt 2>/dev/null || true
    cryptsetup close "$MAPPER" 2>/dev/null || true
    exit 130
}
info() { ui_info "$@"; }
ask()  { printf '\n  %s?%s  %s%s%s ' "$C_PINK" "$C_NC" "$C_BOLD$C_WHITE" "$*" "$C_NC"; }

# Styled section header - called as: hr "Section Title" (or hr for a plain rule)
hr() {
    if [[ -n "${1:-}" ]]; then ui_title "$1"; else printf '\n'; ui_hr; printf '\n'; fi
}

# Debug helper - writes ONLY to log file, not to screen
dbg() {
    echo "[DBG $(date +%H:%M:%S)] $*" >> "$LOGFILE"
}

# Dump system state to log for troubleshooting
dump_state() {
    local label="$1"
    {
        echo ""
        echo "================================================"
        echo "  DEBUG DUMP: $label"
        echo "  $(date)"
        echo "================================================"
        echo ""
        echo "--- lsblk -f ---"
        lsblk -f 2>&1 || true
        echo ""
        echo "--- blkid ---"
        blkid 2>&1 || true
        echo ""
        echo "--- efibootmgr ---"
        efibootmgr -v 2>&1 || true
        echo ""
        echo "--- df -h (mounted) ---"
        df -h 2>&1 || true
        echo ""
        echo "--- mount ---"
        mount 2>&1 || true
        echo ""
        echo "--- Script variables ---"
        echo "EFI_PART=$EFI_PART"
        echo "EFI_DRIVE=$EFI_DRIVE"
        echo "EFI_PART_NUM=$EFI_PART_NUM"
        echo "TARGET_DRIVE=$TARGET_DRIVE"
        echo "LUKS_PART=$LUKS_PART"
        echo "LUKS_UUID=$LUKS_UUID"
        echo "FREE_START=${FREE_START:-}"
        echo "FREE_END=${FREE_END:-}"
        echo "MAPPER=$MAPPER"
        echo ""
        echo "================================================"
        echo ""
    } >> "$LOGFILE" 2>&1
}

# -- Copy logs to USB drive ----------------------------------
# Moved up from near the end of the script so err() can call this too - it
# was previously only wired into the successful-completion path at the very
# bottom, so on ANY install failure (the moment you most want your logs
# saved somewhere durable) it never ran at all.
copy_logs_to_usb() {
    hr "Save Logs to USB"
    info "Looking for a USB drive to copy logs to..."

    # Find which drive is running the live ISO - exclude it
    ISO_DEV=""
    # Try multiple archiso mount points
    for mnt in /run/archiso/bootmnt /run/archiso/airootfs; do
        src=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null | head -1)
        if [[ -n "$src" ]]; then
            parent=$(lsblk -no PKNAME "$src" 2>/dev/null | head -1)
            # Handle nested parents (e.g. ventoy -> sda1 -> sda)
            while [[ -n "$parent" ]]; do
                grandparent=$(lsblk -no PKNAME "/dev/$parent" 2>/dev/null | head -1)
                [[ -n "$grandparent" ]] && parent="$grandparent" || break
            done
            [[ -n "$parent" ]] && ISO_DEV="/dev/$parent" && break
        fi
    done
    dbg "ISO drive: ${ISO_DEV:-unknown}"

    # Find USB/removable drives that are NOT the ISO drive
    USB_PARTS=()

    # First, find all USB or removable DISKS
    USB_DISKS=()
    while IFS= read -r line; do
        dpath=$(echo "$line" | awk '{print $1}')
        drm=$(echo "$line" | awk '{print $3}')
        dtran=$(echo "$line" | awk '{print $4}')
        # Include if removable OR USB transport
        if [[ "$drm" == "1" || "$dtran" == "usb" ]]; then
            [[ "$dpath" == "$ISO_DEV" ]] && continue
            USB_DISKS+=("$dpath")
        fi
    done < <(lsblk -dn -o PATH,TYPE,RM,TRAN 2>/dev/null | awk '$2 == "disk"')

    dbg "USB disks (excluding ISO): ${USB_DISKS[*]+"${USB_DISKS[*]}"}"

    # Now find mountable partitions on those USB disks
    if [[ ${#USB_DISKS[@]} -gt 0 ]]; then
        for udisk in "${USB_DISKS[@]}"; do
        while IFS= read -r pdev; do
            [[ -b "$pdev" ]] || continue
            fstype=$(lsblk -no FSTYPE "$pdev" 2>/dev/null | head -1)
            [[ -z "$fstype" ]] && continue

            size=$(lsblk -no SIZE "$pdev" 2>/dev/null | head -1)
            label=$(lsblk -no LABEL "$pdev" 2>/dev/null | head -1)
            label=${label:-no label}

            USB_PARTS+=("${pdev}|${size}|${fstype}|${label}")
        done < <(lsblk -ln -o PATH,TYPE "$udisk" 2>/dev/null | awk '$2 == "part" {print $1}')
        done
    fi

    if [[ ${#USB_PARTS[@]} -eq 0 ]]; then
        warn "No other USB drive found. Logs saved to:"
        echo -e "    ${YELLOW}${LOGFILE}${NC}  (live ISO - lost on reboot)"
        echo -e "    ${YELLOW}/var/log/niixarch-install.log${NC}  (installed system)"
        return
    fi

    # Pick the USB partition
    local USB_TARGET=""
    if [[ ${#USB_PARTS[@]} -eq 1 ]]; then
        USB_TARGET="${USB_PARTS[0]}"
        IFS='|' read -r udev usize ufs ulabel <<< "$USB_TARGET"
        log "Found USB: $udev ($usize, $ufs, $ulabel)"
    else
        echo
        echo -e "${BOLD}  Multiple USB partitions found:${NC}"
        echo
        for i in "${!USB_PARTS[@]}"; do
            IFS='|' read -r udev usize ufs ulabel <<< "${USB_PARTS[$i]}"
            printf "  ${CYAN}[%s]${NC}  %-16s  %6s  %s  (%s)\n" \
                "$i" "$udev" "$usize" "$ulabel" "$ufs"
        done
        echo
        ask "Copy logs to which USB? Enter the number (or 's' to skip):"
        read -rp "  > " USB_CHOICE
        [[ "$USB_CHOICE" == "s" ]] && { info "Skipped."; return; }
        [[ "$USB_CHOICE" =~ ^[0-9]+$ ]] || { warn "Invalid choice. Skipped."; return; }
        USB_TARGET="${USB_PARTS[$USB_CHOICE]:-}"
        [[ -n "$USB_TARGET" ]] || { warn "Invalid choice. Skipped."; return; }
    fi

    IFS='|' read -r USB_DEV _ _ USB_LABEL <<< "$USB_TARGET"

    # Mount the USB
    local USB_MNT="/mnt_usb_log"
    mkdir -p "$USB_MNT"

    if mount "$USB_DEV" "$USB_MNT" 2>/dev/null; then
        log "Mounted $USB_DEV at $USB_MNT"

        # Copy both log files (the installed-system copy may not exist yet if
        # we're being called from err() before configure_system ran)
        cp "$LOGFILE" "$USB_MNT/" 2>/dev/null \
            && log "Copied $(basename "$LOGFILE") to USB"
        cp /mnt/var/log/niixarch-install.log "$USB_MNT/" 2>/dev/null \
            && log "Copied niixarch-install.log to USB"
        cp /root/niixarch-luks-header-backup.img "$USB_MNT/" 2>/dev/null \
            && log "Copied LUKS header backup to USB"

        sync
        umount "$USB_MNT" 2>/dev/null
        rmdir "$USB_MNT" 2>/dev/null || true

        log "Logs saved to USB ($USB_LABEL)"
    else
        warn "Could not mount $USB_DEV - logs not copied to USB"
    fi
}

# Returns 0 if a drive is removable (USB) or virtual (loop/ram/dm), 1 if internal physical
is_removable() {
    local dev
    dev=$(basename "$1")
    # Exclude loop devices, RAM disks, and device-mapper - these are never install targets
    [[ "$dev" == loop*  ]] && return 0
    [[ "$dev" == ram*   ]] && return 0
    [[ "$dev" == dm-*   ]] && return 0
    local rm_flag
    rm_flag=$(cat "/sys/block/${dev}/removable" 2>/dev/null || echo "0")
    [[ "$rm_flag" == "1" ]] && return 0
    # Also check transport - USB drives may report RM=0
    local tran
    tran=$(lsblk -dn -o TRAN "/dev/$dev" 2>/dev/null | tr -d '[:space:]')
    [[ "$tran" == "usb" ]] && return 0
    return 1
}

banner() {
    clear 2>/dev/null || true
    local g="$C_GREEN" p="$C_PINK" n="$C_NC" w="$C_WHITE" d="$C_DIM" b="$C_BOLD" r="$C_RED"
    printf '\n'
    printf '  %s███╗   ██╗██╗██╗██╗  ██╗ █████╗ %s██████╗ ███████╗%s\n' "$g" "$p" "$n"
    printf '  %s████╗  ██║██║██║╚██╗██╔╝██╔══██╗%s██╔═══██╗██╔════╝%s\n' "$g" "$p" "$n"
    printf '  %s██╔██╗ ██║██║██║ ╚███╔╝ ███████║%s██║   ██║███████╗%s\n' "$g" "$p" "$n"
    printf '  %s██║╚██╗██║██║██║ ██╔██╗ ██╔══██║%s██║   ██║╚════██║%s\n' "$g" "$p" "$n"
    printf '  %s██║ ╚████║██║██║██╔╝ ██╗██║  ██║%s╚██████╔╝███████║%s\n' "$g" "$p" "$n"
    printf '  %s╚═╝  ╚═══╝╚═╝╚═╝╚═╝  ╚═╝╚═╝  ╚═╝%s ╚═════╝ ╚══════╝%s\n' "$g" "$p" "$n"
    printf '\n'
    printf '  %s%sArch Installer%s   %sluks2 · cachyos · limine · btrfs · gnome%s\n' "$b" "$w" "$n" "$d" "$n"
    printf '  %sbuild %s%s   %s(missing/old? you are running a stale script)%s\n' "$g" "${NIIX_BUILD:-?}" "$n" "$d" "$n"
    ui_hr
    printf '\n'
    printf '  %s▸%s  %s%sRequired before running%s\n' "$p" "$n" "$b" "$w" "$n"
    printf '  %s✗%s  %sSecure Boot must be OFF in BIOS%s\n' "$r" "$n" "$w" "$n"
    printf '  %s✗%s  %sLimine does not support Secure Boot%s\n' "$r" "$n" "$w" "$n"
    printf '\n'
}

# -- Hardware detection (runs from live ISO) ------------------
detect_hardware() {
    hr "Hardware Detection"
    info "Detecting hardware..."

    # CPU
    CPU_MODEL=$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2 | xargs)
    if grep -qi "AuthenticAMD" /proc/cpuinfo; then
        CPU_VENDOR="amd"
    elif grep -qi "GenuineIntel" /proc/cpuinfo; then
        CPU_VENDOR="intel"
    else
        CPU_VENDOR="unknown"
    fi
    log "CPU: $CPU_MODEL ($CPU_VENDOR)"

    # GPU - detect via lspci descriptions. Count every GPU so hybrid (iGPU + dGPU)
    # laptops are handled correctly, not just the "primary" card.
    GPU_DESCS=$(lspci 2>/dev/null | grep -iE "VGA|3D|Display" | sed 's/.*: //')
    GPU_COUNT=$(lspci 2>/dev/null | grep -ciE "VGA compatible controller|3D controller|Display controller")
    HAS_NVIDIA=$(echo "$GPU_DESCS" | grep -ciE "NVIDIA" || true)
    HAS_AMD=$(echo "$GPU_DESCS" | grep -ciE "\bAMD\b|\bATI\b|\bRadeon\b|Advanced Micro" || true)
    HAS_INTEL=$(echo "$GPU_DESCS" | grep -ciE "Intel" || true)
    local has_nvidia="$HAS_NVIDIA" has_amd="$HAS_AMD" has_intel="$HAS_INTEL"

    if [[ "$has_nvidia" -gt 0 ]]; then
        GPU_VENDOR="nvidia"
        GPU_MODEL=$(echo "$GPU_DESCS" | grep -iE "NVIDIA" | head -1)
        # Classify the architecture (codename first, marketing name as fallback).
        # This decides the driver: NVIDIA's mainline driver (R590+, Dec 2025) supports
        # only Turing and newer; Maxwell/Pascal/Kepler need a legacy AUR branch.
        if echo "$GPU_MODEL" | grep -qiE "TU[0-9]{3}|GA[0-9]{3}|AD[0-9]{3}|GB[0-9]{3}|GH[0-9]{3}|RTX [0-9]|GTX 16[0-9]{2}"; then
            GPU_GEN="turing_plus"; NVIDIA_LEGACY=""
        elif echo "$GPU_MODEL" | grep -qiE "GM[0-9]{3}|GP[0-9]{3}|GTX 9[0-9]{2}|GTX 10[0-9]{2}|TITAN X"; then
            GPU_GEN="maxwell_pascal"; NVIDIA_LEGACY="580xx"
        elif echo "$GPU_MODEL" | grep -qiE "GK[0-9]{3}|GTX 6[0-9]{2}|GTX 7[0-9]{2}"; then
            GPU_GEN="kepler"; NVIDIA_LEGACY="470xx"
        elif echo "$GPU_MODEL" | grep -qiE "GF[0-9]{3}|GTX [45][0-9]{2}"; then
            GPU_GEN="fermi"; NVIDIA_LEGACY=""
        else
            GPU_GEN="turing_plus"; NVIDIA_LEGACY=""   # unknown -> assume modern
        fi
    elif [[ "$has_amd" -gt 0 ]]; then
        GPU_VENDOR="amd"
        GPU_MODEL=$(echo "$GPU_DESCS" | grep -iE "\bAMD\b|\bATI\b|\bRadeon\b|Advanced Micro" | head -1)
        if echo "$GPU_MODEL" | grep -qiE "RX 9[0-9]{3}"; then GPU_GEN="rdna4"
        elif echo "$GPU_MODEL" | grep -qiE "RX 7[0-9]{3}"; then GPU_GEN="rdna3"
        elif echo "$GPU_MODEL" | grep -qiE "RX 6[0-9]{3}"; then GPU_GEN="rdna2"
        else GPU_GEN="older"; fi
    elif [[ "$has_intel" -gt 0 ]]; then
        GPU_VENDOR="intel"
        GPU_MODEL=$(echo "$GPU_DESCS" | grep -iE "Intel" | head -1)
    fi

    # Discrete ("most powerful") GPU + how games reach it. iGPUs sit on PCI bus 00
    # (Intel 00:02.0, AMD APU 00:01/00:08); a dGPU has its own bus (01:00, 03:00...).
    # Record the dGPU's PCI address so the Mesa offload path can target it
    # deterministically with DRI_PRIME=pci-DOMAIN_BUS_DEV_FN, which is far more
    # reliable than the ordinal DRI_PRIME=1 on multi-GPU boxes.
    DGPU_PCI=""
    local _gline _gaddr _gbus
    while IFS= read -r _gline; do
        [ -n "$_gline" ] || continue
        _gaddr=$(awk '{print $1}' <<< "$_gline")
        _gbus=$(cut -d: -f2 <<< "$_gaddr")
        if echo "$_gline" | grep -qiE "NVIDIA"; then
            DGPU_PCI="$_gaddr"; break                 # NVIDIA is always the discrete GPU
        elif [ "$_gbus" != "00" ] && [ -z "$DGPU_PCI" ]; then
            DGPU_PCI="$_gaddr"                         # a GPU off bus 00 == discrete
        fi
    done < <(lspci -D 2>/dev/null | grep -iE "VGA compatible controller|3D controller|Display controller")

    # Bias toward "hybrid" when unsure: treating a single-GPU box as hybrid is
    # harmless (the offload keys are ignored), whereas forcing a hybrid box to
    # single-GPU breaks the iGPU display.
    [[ "${GPU_COUNT:-0}" -ge 2 ]] && IS_HYBRID=true || IS_HYBRID=false
    if [[ "$IS_HYBRID" == true ]]; then
        if [[ "$has_nvidia" -gt 0 ]]; then OFFLOAD_MODE="nvidia"; else OFFLOAD_MODE="dri-prime"; fi
    fi
    # Deterministic DRI_PRIME target for the Mesa (AMD/Intel dGPU) offload path.
    DRI_PRIME_TAG="1"
    [ -n "$DGPU_PCI" ] && DRI_PRIME_TAG="pci-${DGPU_PCI//[:.]/_}"
    log "GPU: $GPU_MODEL ($GPU_VENDOR${GPU_GEN:+ $GPU_GEN})"
    [[ "$IS_HYBRID" == true ]] && log "Hybrid graphics: $GPU_COUNT GPUs - games will offload to the dGPU ($OFFLOAD_MODE)"

    [[ "$GPU_VENDOR" == "unknown" || -z "$GPU_VENDOR" ]] \
        && warn "Could not detect GPU - drivers will need manual install"

    # RAM
    local mem_kb
    mem_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
    MEM_TOTAL_GB=$(( mem_kb / 1024 / 1024 ))
    log "RAM: ~${MEM_TOTAL_GB}GB"

    # Storage type
    lsblk -d -o name,rota 2>/dev/null | grep -q "^nvme" && HAS_NVME=true
    lsblk -d -o name,rota 2>/dev/null | grep -q " 0$" && HAS_SSD=true
    log "Storage: NVMe=$HAS_NVME SSD=$HAS_SSD"
}

# -- WiFi connection - used by pre-flight when there's no link yet -----
# Works on both the official Arch ISO (iwd/iwctl) and the NiiX ISO
# (NetworkManager/nmcli). Interactive: scans, asks for SSID + password,
# connects, and verifies real internet before returning.
connect_wifi() {
    command -v rfkill &>/dev/null && rfkill unblock wifi 2>/dev/null || true

    local wifi_if d
    wifi_if=$(iw dev 2>/dev/null | awk '/Interface/{print $2; exit}')
    if [ -z "$wifi_if" ]; then
        for d in /sys/class/net/wl*; do [ -e "$d" ] && { wifi_if=$(basename "$d"); break; }; done
    fi
    if [ -z "$wifi_if" ]; then
        warn "No WiFi adapter detected."
        return 1
    fi

    local backend=""
    if command -v nmcli &>/dev/null; then
        systemctl is-active --quiet NetworkManager 2>/dev/null || systemctl start NetworkManager 2>/dev/null || true
        systemctl is-active --quiet NetworkManager 2>/dev/null && backend="nm"
    fi
    if [ -z "$backend" ] && command -v iwctl &>/dev/null; then
        systemctl start iwd 2>/dev/null || true
        backend="iwd"
    fi
    if [ -z "$backend" ]; then
        warn "No WiFi tool available (need nmcli or iwctl)."
        return 1
    fi

    info "WiFi adapter: $wifi_if  -  backend: $backend"

    local ssid wpass ok i again
    while true; do
        echo
        info "Scanning for networks..."
        if [ "$backend" = "nm" ]; then
            nmcli device wifi rescan ifname "$wifi_if" 2>/dev/null || nmcli device wifi rescan 2>/dev/null || true
            sleep 3
            nmcli -t -f SSID,SIGNAL,SECURITY device wifi list 2>/dev/null \
                | awk -F: 'length($1){printf "     %-32s  %3s%%  %s\n",$1,$2,($3==""?"open":$3)}' \
                | sort -u | head -25
        else
            iwctl station "$wifi_if" scan 2>/dev/null || true
            sleep 3
            iwctl station "$wifi_if" get-networks 2>/dev/null \
                | sed -e 's/\x1b\[[0-9;]*m//g' | awk 'NR>4 && NF{print "     "$0}' | head -25
        fi

        echo
        read -rp "  WiFi network name (SSID) - or 'skip' to cancel: " ssid
        if [ -z "$ssid" ]; then continue; fi
        if [ "$ssid" = "skip" ]; then warn "WiFi setup skipped."; return 1; fi
        read -rsp "  WiFi password (leave blank if open): " wpass; echo

        info "Connecting to '$ssid'..."
        if [ "$backend" = "nm" ]; then
            if [ -n "$wpass" ]; then
                nmcli device wifi connect "$ssid" password "$wpass" ifname "$wifi_if" 2>&1 | sed 's/^/     /'
            else
                nmcli device wifi connect "$ssid" ifname "$wifi_if" 2>&1 | sed 's/^/     /'
            fi
        else
            if [ -n "$wpass" ]; then
                iwctl --passphrase "$wpass" station "$wifi_if" connect "$ssid" 2>&1 | sed 's/^/     /'
            else
                iwctl station "$wifi_if" connect "$ssid" 2>&1 | sed 's/^/     /'
            fi
        fi

        ok=false
        for i in 1 2 3 4 5; do
            ping -c1 -W3 archlinux.org &>/dev/null && { ok=true; break; }
            sleep 2
        done
        if $ok; then
            log "Connected to '$ssid' - internet is up"
            return 0
        fi

        warn "Still no internet on '$ssid' (wrong password, weak signal, or captive portal?)."
        read -rp "  Try a different network / retry? [Y/n]: " again
        case "${again,,}" in n|no) warn "WiFi setup cancelled."; return 1 ;; esac
    done
}

# -- Ensure internet, bringing up WiFi if there's no active link ------
ensure_internet() {
    info "Checking internet connection..."
    local i
    for i in 1 2 3; do
        if ping -c1 -W3 archlinux.org &>/dev/null; then
            log "Internet connection detected"
            return 0
        fi
        sleep 2
    done

    warn "No wired/active connection found - let's set up WiFi."
    connect_wifi && return 0
    err "No internet connection. Connect Ethernet or WiFi, then re-run the installer."
}

# -- Step 0: Pre-flight checks --------------------------------
check_prereqs() {
    hr "Pre-flight Checks"
    info "Running pre-flight checks..."

    [ "$EUID" -eq 0 ]        || err "Please run as root"

    ensure_internet

    [ -d /sys/firmware/efi ] || err "Not booted in UEFI mode - enable UEFI in BIOS"

    # Secure Boot must be OFF - Limine doesn't support it. Fail now with a
    # clear message rather than a confusing boot failure after a full install.
    if command -v mokutil &>/dev/null; then
        if mokutil --sb-state 2>/dev/null | grep -qi "SecureBoot enabled"; then
            err "Secure Boot is ON - disable it in BIOS/UEFI settings, then re-run this installer (Limine does not support Secure Boot)"
        fi
    elif [[ -d /sys/firmware/efi/efivars ]]; then
        local sb_var
        sb_var=$(find /sys/firmware/efi/efivars -maxdepth 1 -name 'SecureBoot-*' 2>/dev/null | head -1)
        if [[ -n "$sb_var" ]]; then
            local sb_byte
            sb_byte=$(od -An -tu1 "$sb_var" 2>/dev/null | awk '{print $NF}')
            [[ "$sb_byte" == "1" ]] && err "Secure Boot is ON - disable it in BIOS/UEFI settings, then re-run this installer (Limine does not support Secure Boot)"
        fi
    fi

    for cmd in cryptsetup parted mkfs.btrfs python3 efibootmgr lsblk blkid; do
        command -v "$cmd" &>/dev/null || err "$cmd not found on live ISO"
    done

    loadkeys "$KEYMAP"   # load default (us) until user selects layout in gather_input
    log "Keyboard: $KEYMAP (default - will be updated after layout selection)"
    log "All checks passed"
}

# -- Step 1: Auto-detect EFI partition (internal drives only) -
detect_efi() {
    hr "EFI Partition Detection"
    info "Detecting EFI System Partition..."

    # Primary: match EFI System Partition GUID, skip USB drives
    while IFS= read -r dev; do
        [[ -b "$dev" ]] || continue
        parent=$(lsblk -no PKNAME "$dev" 2>/dev/null)
        if [[ -n "$parent" ]] && is_removable "/dev/$parent"; then
            continue
        fi
        EFI_PART="$dev"
        break
    done < <(blkid -t PART_ENTRY_TYPE="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" \
                   -o device 2>/dev/null)

    # Fallback: find FAT32 partition with esp flag on internal drives
    if [[ -z "$EFI_PART" ]]; then
        while IFS= read -r drive; do
            [[ -b "$drive" ]] || continue
            is_removable "$drive" && continue
            partnum=$(parted -m "$drive" print 2>/dev/null \
                | awk -F: '$7 ~ /esp/ || $7 ~ /boot/ {print $1; exit}')
            if [[ -n "$partnum" ]]; then
                if [[ "$drive" == *nvme* || "$drive" == *mmcblk* ]]; then
                    EFI_PART="${drive}p${partnum}"
                else
                    EFI_PART="${drive}${partnum}"
                fi
                break
            fi
        done < <(lsblk -dn -o PATH 2>/dev/null)
    fi

    [[ -n "$EFI_PART" && -b "$EFI_PART" ]] \
        || err "Could not find EFI System Partition on any internal drive."

    EFI_DRIVE_NAME=$(lsblk -no PKNAME "$EFI_PART" 2>/dev/null)
    EFI_DRIVE="/dev/$EFI_DRIVE_NAME"
    EFI_PART_NUM=$(lsblk -no PARTN "$EFI_PART" 2>/dev/null \
        || echo "$EFI_PART" | grep -o '[0-9]*$')

    EFI_SIZE=$(lsblk -no SIZE "$EFI_PART" 2>/dev/null || echo "?")
    log "EFI partition : $EFI_PART  ($EFI_SIZE)"
    log "EFI drive     : $EFI_DRIVE  (partition $EFI_PART_NUM)"

    # Warn if EFI partition is small - kernels + initramfs need ~150 MiB
    EFI_SIZE_BYTES=$(lsblk -bno SIZE "$EFI_PART" 2>/dev/null | head -1 | tr -d '[:space:]')
    EFI_SIZE_BYTES=${EFI_SIZE_BYTES:-0}
    if [[ "$EFI_SIZE_BYTES" -lt 209715200 ]] 2>/dev/null; then   # < 200 MiB
        warn "EFI partition is only $EFI_SIZE - this may be too small"
        warn "for Linux kernels + initramfs. Consider resizing it first."
        ask "Continue anyway? (y/N):"
        read -rp "  > " efi_ok
        [[ "$efi_ok" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
    fi
}

# -- Step 2: Find free space or offer partition deletion ----
detect_free_spaces() {
    hr "Free Space / Partition Selection"
    info "Scanning internal drives for free space (>10 GiB)..."
    echo

    FREE_SPACE_LIST=$(python3 <<'PYEOF'
import subprocess

# Get all disks - internal only (RM=0 and not USB transport)
try:
    raw = subprocess.check_output(
        ['lsblk', '-dn', '-o', 'PATH,TYPE,RM,TRAN'],
        stderr=subprocess.DEVNULL, text=True
    )
except Exception:
    raw = ""

drives = []
for line in raw.strip().split('\n'):
    cols = line.split()
    if len(cols) < 3:
        continue
    path, dtype, rm = cols[0], cols[1], cols[2]
    tran = cols[3] if len(cols) > 3 else ''
    if dtype != 'disk' or rm != '0':
        continue
    if tran.lower() == 'usb':
        continue
    drives.append(path)

entries = []
for drive in drives:
    try:
        model = subprocess.check_output(
            ['lsblk', '-dn', '-o', 'MODEL', drive],
            stderr=subprocess.DEVNULL, text=True
        ).strip() or "Unknown"

        # 5 second timeout - prevents hanging on odd drives
        out = subprocess.check_output(
            ['timeout', '5', 'parted', '-m', drive, 'unit', 'MiB', 'print', 'free'],
            stderr=subprocess.DEVNULL, text=True
        )

        for line in out.strip().split('\n'):
            parts = line.rstrip(';').split(':')
            if len(parts) >= 4 and 'free' in parts[-1].lower():
                try:
                    start = float(parts[1].replace('MiB', ''))
                    end   = float(parts[2].replace('MiB', ''))
                    size  = end - start
                    if size > 10240:  # > 10 GiB
                        entries.append(
                            f"{drive}|{start:.0f}|{end:.0f}|{size/1024:.1f}|{model}"
                        )
                except Exception:
                    pass
    except Exception:
        pass

for i, e in enumerate(entries):
    print(f"{i}:{e}")
PYEOF
)

    if [[ -n "$FREE_SPACE_LIST" ]]; then
        echo -e "  ${WHITE}${BOLD}Available free space regions:${NC}"
        echo
        while IFS= read -r line; do
            idx="${line%%:*}"
            rest="${line#*:}"
            IFS='|' read -r drive start _ size_gib model <<< "$rest"
            printf "  ${CYAN}[%s]${NC}  %-16s  %6.1f GiB  (%s)\n" \
                "$idx" "$drive" "$size_gib" "$model"
        done <<< "$FREE_SPACE_LIST"

        echo
        ask "Which region should Arch use? Enter the number:"
        read -rp "  > " CHOICE
        [[ "$CHOICE" =~ ^[0-9]+$ ]] || err "Invalid choice: $CHOICE"

        CHOSEN=$(echo "$FREE_SPACE_LIST" | grep "^${CHOICE}:")
        [[ -n "$CHOSEN" ]] || err "Invalid choice: $CHOICE"

        rest="${CHOSEN#*:}"
        IFS='|' read -r TARGET_DRIVE FREE_START FREE_END FREE_GiB FREE_MODEL <<< "$rest"

        log "Selected: $TARGET_DRIVE  (~${FREE_GiB} GiB)  [$FREE_MODEL]"
        return
    fi

    # -- No free space found - offer partition deletion ------
    warn "No free space (>10 GiB) found on any internal drive."
    info "You can delete an existing partition to make room."
    echo
    echo -e "  ${RED}${BOLD}  Protected partitions (will NOT be listed):${NC}"
    echo -e "    - EFI System Partition"
    echo -e "    - Windows / NTFS partitions"
    echo -e "    - Microsoft Reserved (MSR)"
    echo -e "    - Recovery partitions"
    echo

    # Build list of deletable partitions on internal drives
    PART_LIST=$(python3 - "$EFI_PART" "$LOGFILE" <<'PYEOF'
import subprocess, sys

efi_part = sys.argv[1]
logfile = sys.argv[2]

def log(msg):
    with open(logfile, 'a') as f:
        f.write(f"[PARTLIST] {msg}\n")

# Get internal, non-USB disks
try:
    raw = subprocess.check_output(
        ['lsblk', '-dn', '-o', 'PATH,TYPE,RM,TRAN'],
        stderr=subprocess.DEVNULL, text=True
    )
except Exception as e:
    log(f"ERROR getting drives: {e}")
    raw = ""

drives = []
for line in raw.strip().split('\n'):
    cols = line.split()
    if len(cols) < 3:
        continue
    path, dtype, rm = cols[0], cols[1], cols[2]
    tran = cols[3] if len(cols) > 3 else ''
    if dtype != 'disk' or rm != '0':
        log(f"Skip drive {path}: type={dtype} rm={rm}")
        continue
    if tran.lower() == 'usb':
        log(f"Skip drive {path}: USB transport")
        continue
    drives.append(path)
    log(f"Internal drive: {path} tran={tran}")

# Protected partition type GUIDs (lowercase)
protected_guids = {
    'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',  # EFI System
    'e3c9e316-0b5c-4db8-817d-f92df00215ae',  # Microsoft Reserved (MSR)
    'de94bba4-06d1-4d40-a16a-bfd50179d6ac',  # Windows Recovery
}

entries = []
for drive in drives:
    try:
        # Use plain text output with explicit separator - more reliable than JSON
        out = subprocess.check_output(
            ['lsblk', '-ln', '-o', 'PATH,SIZE,FSTYPE,PARTTYPE', drive],
            stderr=subprocess.DEVNULL, text=True
        )
        log(f"lsblk output for {drive}:\n{out}")

        for line in out.strip().split('\n'):
            cols = line.split()
            if len(cols) < 2:
                continue

            path = cols[0]
            size = cols[1]

            # Remaining columns: FSTYPE might be empty, PARTTYPE might be empty
            # We need to figure out which columns are which
            # If there are 4 cols: PATH SIZE FSTYPE PARTTYPE
            # If there are 3 cols: PATH SIZE FSTYPE (no parttype) or PATH SIZE PARTTYPE (no fstype)
            # If there are 2 cols: PATH SIZE (no fstype, no parttype)
            fstype = ''
            partguid = ''
            if len(cols) == 4:
                fstype = cols[2]
                partguid = cols[3].lower()
            elif len(cols) == 3:
                # Could be fstype or parttype - GUIDs have dashes
                if '-' in cols[2] and len(cols[2]) > 30:
                    partguid = cols[2].lower()
                else:
                    fstype = cols[2]

            # Skip the drive itself
            if path == drive:
                continue

            # Skip /dev/mapper devices
            if '/mapper/' in path:
                log(f"Skip {path}: mapper device")
                continue

            # Skip EFI partition
            if path == efi_part:
                log(f"Skip {path}: EFI partition")
                continue

            # Skip protected GUIDs
            if partguid in protected_guids:
                log(f"Skip {path}: protected GUID {partguid}")
                continue

            # Skip NTFS, vfat
            if fstype.lower() in ('ntfs', 'vfat', 'fat32', 'fat16'):
                log(f"Skip {path}: protected fs {fstype}")
                continue

            # Skip tiny partitions (<1 GiB)
            try:
                s = size.upper()
                if 'T' in s:
                    gib = float(s.replace('T','')) * 1024
                elif 'G' in s:
                    gib = float(s.replace('G',''))
                elif 'M' in s:
                    gib = float(s.replace('M','')) / 1024
                else:
                    gib = 0
                if gib < 1:
                    log(f"Skip {path}: too small ({size})")
                    continue
            except Exception:
                log(f"Skip {path}: can't parse size '{size}'")
                continue

            label = fstype.lower() if fstype else 'unknown'
            entries.append(f"{path}|{size}|{label}|{drive}")
            log(f"ELIGIBLE: {path} {size} {label}")

    except Exception as e:
        log(f"ERROR processing {drive}: {e}")

for i, e in enumerate(entries):
    print(f"{i}:{e}")
PYEOF
)

    if [[ -z "$PART_LIST" ]]; then
        err "No deletable partitions found.\nShrink a Windows volume in Disk Management first, then re-run."
    fi

    echo -e "  ${WHITE}${BOLD}Deletable partitions:${NC}"
    echo
    while IFS= read -r line; do
        idx="${line%%:*}"
        rest="${line#*:}"
        IFS='|' read -r path size label drive <<< "$rest"
        printf "  ${CYAN}[%s]${NC}  %-16s  %8s  (%s)\n" \
            "$idx" "$path" "$size" "$label"
    done <<< "$PART_LIST"

    echo
    echo -e "  ${RED}${BOLD}WARNING: The selected partition will be PERMANENTLY DELETED.${NC}"
    ask "Which partition should be deleted for Arch? Enter the number:"
    read -rp "  > " DEL_CHOICE
    [[ "$DEL_CHOICE" =~ ^[0-9]+$ ]] || err "Invalid choice: $DEL_CHOICE"

    DEL_CHOSEN=$(echo "$PART_LIST" | grep "^${DEL_CHOICE}:")
    [[ -n "$DEL_CHOSEN" ]] || err "Invalid choice: $DEL_CHOICE"

    rest="${DEL_CHOSEN#*:}"
    IFS='|' read -r DEL_PART DEL_SIZE DEL_LABEL DEL_DRIVE <<< "$rest"

    echo
    echo -e "  ${RED}${BOLD}You are about to DELETE:  $DEL_PART  ($DEL_SIZE, $DEL_LABEL)${NC}"
    echo -e "  ${RED}${BOLD}This CANNOT be undone.${NC}"
    echo
    ask "Type  DELETE  (uppercase) to confirm:"
    read -rp "  > " DEL_CONFIRM
    [[ "$DEL_CONFIRM" == "DELETE" ]] || { echo "Aborted. Nothing was changed."; exit 0; }

    # Get partition number
    DEL_PART_NUM=$(lsblk -no PARTN "$DEL_PART" 2>/dev/null)
    if [[ -z "$DEL_PART_NUM" ]]; then
        DEL_PART_NUM=$(echo "$DEL_PART" | grep -o '[0-9]*$')
    fi

    [[ -n "$DEL_PART_NUM" ]] || err "Could not determine partition number for $DEL_PART"

    # Close any LUKS containers on this partition
    if blkid -o value -s TYPE "$DEL_PART" 2>/dev/null | grep -q "crypto_LUKS"; then
        # Find the mapper name if open
        MAPPER_NAME=$(lsblk -lno NAME,TYPE "$DEL_PART" 2>/dev/null \
            | awk '$2 == "crypt" {print $1}')
        if [[ -n "$MAPPER_NAME" ]]; then
            info "Closing LUKS container /dev/mapper/$MAPPER_NAME..."
            cryptsetup close "$MAPPER_NAME" 2>/dev/null || true
        fi
    fi

    # Unmount if mounted
    umount "$DEL_PART" 2>/dev/null || true

    info "Deleting partition $DEL_PART (partition $DEL_PART_NUM on $DEL_DRIVE)..."
    parted -s "$DEL_DRIVE" rm "$DEL_PART_NUM"
    partprobe "$DEL_DRIVE"
    sleep 2

    log "Partition deleted"

    # Re-scan the drive for free space and use the largest region
    info "Scanning $DEL_DRIVE for freed space..."
    FREE_REGION=$(parted -m "$DEL_DRIVE" unit MiB print free 2>/dev/null \
        | awk -F: '$NF ~ /free/ {
            start = $2; end = $3;
            gsub(/MiB/, "", start); gsub(/MiB/, "", end);
            size = end - start;
            if (size > max) { max = size; best_start = start; best_end = end }
        } END { if (max > 0) printf "%s|%s|%s", best_start, best_end, max/1024 }')

    [[ -n "$FREE_REGION" ]] \
        || err "Could not find free space on $DEL_DRIVE after deletion"

    IFS='|' read -r FREE_START FREE_END FREE_GiB <<< "$FREE_REGION"

    # Set variables for the rest of the script
    TARGET_DRIVE="$DEL_DRIVE"

    log "Using freed space: $TARGET_DRIVE  (~${FREE_GiB%.*} GiB)"
}

# -- Step 3: Gather user input --------------------------------
# Prints pre-formatted entries in columns, filled top-to-bottom so an
# alphabetical list reads naturally down each column.
_columns() {
    local width=$1; shift
    printf '%s\n' "$@" | awk -v W="$width" '
        { item[NR] = $0; if (length($0) > m) m = length($0) }
        END {
            if (NR == 0) exit
            cw = m + 3; cols = int((W - 2) / cw); if (cols < 1) cols = 1
            rows = int((NR + cols - 1) / cols)
            for (r = 1; r <= rows; r++) {
                line = "  "
                for (c = 0; c < cols; c++) {
                    i = r + c * rows
                    if (i <= NR) line = line sprintf("%-" cw "s", item[i])
                }
                sub(/ +$/, "", line); print line
            }
        }'
}

# _pick TITLE ENTER_LABEL ITEM...
# Numbered picker that always fits the screen: long lists are paged (n/p),
# and typing 2+ letters searches. Numbers never change while paging or
# searching, so what you type is what you saw.
# Result in PICK: 1..N = that item, 0 = Enter, -1 = typed text containing "/"
# (left in PICK_TEXT for callers that accept a full name).
_pick() {
    local title=$1 enter_label=$2; shift 2
    local -a items=("$@") view=()
    local n=$# width height d i maxlen cols rows per pages page=0 filter="" input
    width=$(tput cols 2>/dev/null) || width=0
    [[ "$width" =~ ^[0-9]+$ ]] && (( width >= 40 )) || width=80
    height=$(tput lines 2>/dev/null) || height=0
    [[ "$height" =~ ^[0-9]+$ ]] && (( height >= 12 )) || height=24
    d=${#n}
    PICK=0; PICK_TEXT=""
    while true; do
        view=(); maxlen=0
        for (( i = 1; i <= n; i++ )); do
            if [[ -z "$filter" || "${items[i-1],,}" == *"${filter,,}"* ]]; then
                view+=("$(printf "[%${d}d] %s" "$i" "${items[i-1]}")")
                (( ${#view[-1]} > maxlen )) && maxlen=${#view[-1]}
            fi
        done
        cols=$(( (width - 2) / (maxlen + 3) )); (( cols >= 1 )) || cols=1
        rows=$(( height - 8 )); (( rows >= 4 )) || rows=4
        per=$(( cols * rows ))
        pages=$(( (${#view[@]} + per - 1) / per ))
        (( page >= pages )) && page=$(( pages - 1 ))
        (( page < 0 )) && page=0

        echo
        echo -e "  ${WHITE}${BOLD}${title}${NC}${filter:+  (search: $filter)}$( (( pages > 1 )) && echo "  - page $(( page + 1 ))/$pages")"
        echo
        _columns "$width" "${view[@]:page*per:per}"
        echo
        local hint="number"
        (( pages > 1 )) && hint+=", n/p = next/previous page"
        hint+=", letters = search"
        [[ -n "$filter" ]] && hint+=", Enter = clear search" || hint+=", Enter = $enter_label"
        read -rp "  Choose ($hint): " input
        input=${input#"${input%%[![:space:]]*}"}; input=${input%"${input##*[![:space:]]}"}

        if [[ -z "$input" ]]; then
            if [[ -n "$filter" ]]; then filter=""; page=0; continue; fi
            PICK=0; return
        elif [[ "$input" =~ ^[0-9]+$ ]]; then
            if (( input >= 1 && input <= n )); then PICK=$input; return; fi
            warn "Please type a number from 1 to $n."
        elif [[ "$input" == n || "$input" == N ]]; then
            (( page + 1 < pages )) && page=$(( page + 1 ))
        elif [[ "$input" == p || "$input" == P ]]; then
            (( page > 0 )) && page=$(( page - 1 ))
        elif [[ "$input" == */* ]]; then
            PICK=-1; PICK_TEXT=$input; return
        elif (( ${#input} < 2 )); then
            warn "Type at least 2 letters to search."
        else
            local hit=0
            for (( i = 0; i < n; i++ )); do
                [[ "${items[i],,}" == *"${input,,}"* ]] && { hit=1; break; }
            done
            if (( hit )); then filter=$input; page=0; else warn "Nothing matches '$input'."; fi
        fi
    done
}

# Keyboard: console keymap (TTY + the disk-unlock prompt at boot) and desktop
# layout are chosen together, because the LUKS passphrase is typed on the
# console keymap both now and at every boot - they must never differ.
# Console names are Arch kbd names; several candidates are tried in order and
# checked against what this ISO really ships, with a safe US fallback.
choose_keyboard() {
    local -a KB_NAME=() KB_CONS=() KB_XKB=() KB_VAR=()
    local name cons xkb var def=0 choice idx km cand available
    while IFS='|' read -r name cons xkb var; do
        [[ -n "$name" ]] || continue
        KB_NAME+=("$name"); KB_CONS+=("$cons"); KB_XKB+=("$xkb"); KB_VAR+=("$var")
        [[ "$name" == "English (US)" ]] && def=${#KB_NAME[@]}
    done <<'KBEOF'
Belgian|be-latin1|be|
Brazilian (ABNT2)|br-abnt2|br|
Bulgarian (+ English)|us|us,bg|
Canadian French|cf|ca|
Canadian Multilingual|cf|ca|multix
Croatian|croat|hr|
Czech|cz-qwertz cz|cz|
Danish|dk-latin1|dk|
Dutch|nl|nl|
English (UK)|uk|gb|
English (US)|us|us|
English (US, Colemak)|colemak|us|colemak
English (US, Dvorak)|dvorak|us|dvorak
English (US, International)|us-acentos|us|intl
Estonian|et|ee|
Finnish|fi|fi|
French|fr-latin1|fr|
French (Bepo)|fr-bepo-latin9|fr|bepo
French (Swiss)|fr_CH-latin1|ch|fr
German|de-latin1|de|
German (Austria)|de-latin1|at|
German (Neo 2)|neo|de|neo
German (Swiss)|de_CH-latin1|ch|
Greek (+ English)|us|us,gr|
Hungarian|hu|hu|
Icelandic|is-latin1|is|
Irish|uk|ie|
Italian|it|it|
Japanese (106)|jp106|jp|
Latin American|la-latin1|latam|
Latvian|lv|lv|
Lithuanian|lt|lt|
Norwegian|no-latin1|no|
Polish|pl2|pl|
Portuguese|pt-latin1|pt|
Romanian|ro|ro|
Russian (+ English)|us|us,ru|
Serbian (Latin)|sr-latin|rs|latin
Slovak|sk-qwertz|sk|
Slovenian|slovene|si|
Spanish|es|es|
Swedish|sv-latin1|se|
Turkish (F)|trf|tr|f
Turkish (Q)|trq|tr|
Ukrainian (+ English)|us|us,ua|
KBEOF

    echo -e "  ${DIM}Layouts marked (+ English) keep US English for the console and the disk${NC}"
    echo -e "  ${DIM}unlock prompt; on the desktop switch between both with Super+Space.${NC}"
    while true; do
        _pick "Keyboard Layout" "English (US)" "${KB_NAME[@]}"
        (( PICK == 0 )) && choice=$def && break
        (( PICK > 0 )) && choice=$PICK && break
        warn "Please choose a number from the list."
    done
    idx=$(( choice - 1 ))

    available=$(localectl list-keymaps --no-pager 2>/dev/null)
    km=""
    for cand in ${KB_CONS[$idx]}; do
        if [[ -z "$available" ]] || grep -qxF "$cand" <<<"$available"; then
            km=$cand
            break
        fi
    done
    # loadkeys is the final word: it is exactly what the boot prompt will use.
    if [[ -z "$km" ]] || ! loadkeys "$km" >/dev/null 2>&1; then
        warn "The console keymap for '${KB_NAME[$idx]}' is not available on this ISO."
        warn "The console and disk-unlock prompt will use US English; the desktop still gets ${KB_NAME[$idx]}."
        km=us
        loadkeys us >/dev/null 2>&1 || true
    fi

    KEYBOARD_NAME=${KB_NAME[$idx]}
    KEYMAP=$km
    KEYMAP_X11=${KB_XKB[$idx]}
    KEYMAP_X11_VARIANT=${KB_VAR[$idx]}
    log "Keyboard: $KEYBOARD_NAME (console: $KEYMAP, desktop: $KEYMAP_X11${KEYMAP_X11_VARIANT:+ / $KEYMAP_X11_VARIANT})"
}

# Timezone: pick a region, then a city - both numbered and alphabetical. A
# full name like Europe/Paris can be typed at either prompt. The list comes
# from the ISO itself, so it is always complete and valid.
choose_timezone() {
    local -a all=() regions=() zones=() labels=()
    local default="Europe/Zurich" region z
    mapfile -t all < <(timedatectl list-timezones --no-pager 2>/dev/null)
    if (( ${#all[@]} == 0 )) && [[ -r /usr/share/zoneinfo/tzdata.zi ]]; then
        mapfile -t all < <(awk '$1 == "Z" || $1 == "L" { print ($1 == "Z" ? $2 : $3) }' \
            /usr/share/zoneinfo/tzdata.zi | sort -u)
    fi
    if (( ${#all[@]} == 0 )); then
        # Last resort: typed entry, validated against the zoneinfo files.
        while true; do
            ask "Timezone (press Enter for $default):"
            read -rp "  > " TIMEZONE
            TIMEZONE=${TIMEZONE:-$default}
            [[ "$TIMEZONE" != *..* && -f "/usr/share/zoneinfo/$TIMEZONE" ]] && break
            warn "Unknown timezone '$TIMEZONE' - try again (e.g. Europe/Zurich)."
        done
        log "Timezone: $TIMEZONE"
        return
    fi

    # Only the ten real regions; legacy alias folders (US/, Canada/, Brazil/ ...)
    # just duplicate America/*, and can still be typed as a full name.
    mapfile -t regions < <(printf '%s\n' "${all[@]}" | awk -F/ '
        NF > 1 && $1 ~ /^(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)$/ { print $1 }' | sort -u)
    regions+=("UTC")

    while true; do
        TIMEZONE=""
        _pick "Timezone - region" "$default" "${regions[@]}"
        if (( PICK == 0 )); then
            TIMEZONE=$default
        elif (( PICK == -1 )); then
            if printf '%s\n' "${all[@]}" | grep -qxF -- "$PICK_TEXT"; then
                TIMEZONE=$PICK_TEXT
            else
                warn "Unknown timezone '$PICK_TEXT'."; continue
            fi
        else
            region=${regions[PICK-1]}
            if [[ "$region" == "UTC" ]]; then
                TIMEZONE=UTC
            else
                mapfile -t zones < <(printf '%s\n' "${all[@]}" | grep "^${region}/" | sort)
                labels=()
                for z in "${zones[@]}"; do
                    z=${z#"$region"/}
                    labels+=("${z//_/ }")
                done
                _pick "Timezone - $region" "back to regions" "${labels[@]}"
                (( PICK > 0 )) || continue
                TIMEZONE=${zones[PICK-1]}
            fi
        fi
        # It must exist as a zone file: /etc/localtime is symlinked to it.
        [[ -f "/usr/share/zoneinfo/$TIMEZONE" ]] && break
        warn "'$TIMEZONE' has no zone file on this ISO - please pick another."
    done
    log "Timezone: $TIMEZONE"
}

gather_input() {
    hr "Installation Config"
    echo -e "  ${WHITE}${BOLD}Configure your NiiX Arch install:${NC}"
    echo

    # -- Keyboard layout --------------------------------------
    # Chosen first and applied to the live ISO immediately, so the passwords
    # below are typed on the same layout the boot-time unlock prompt uses.
    choose_keyboard

    while true; do
        ask "Hostname (e.g. archbox, press Enter for 'niixarch'):"
        read -rp "  > " HOSTNAME
        HOSTNAME=${HOSTNAME:-niixarch}
        if [[ "$HOSTNAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]]; then
            break
        fi
        warn "Invalid hostname: '$HOSTNAME' - use only letters, digits, and hyphens (not at the start or end), max 63 characters. Try again."
    done

    while true; do
        ask "Username:"
        read -rp "  > " USERNAME
        if [[ -z "$USERNAME" ]]; then
            warn "Username cannot be empty. Try again."
            continue
        fi
        if [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
            # Names taken by the base system or created later by packages
            # (gdm, polkitd, ...). Caught here, before the disk is touched,
            # instead of failing at useradd after partitioning.
            case "$USERNAME" in
                root|bin|daemon|mail|ftp|http|nobody|dbus|polkitd|rtkit|gdm|colord|\
                avahi|geoclue|usbmux|flatpak|git|dnsmasq|saned|tss|uuidd|alpm|ntp|\
                cups|lp|games|kvm|render|video|audio|input|wheel|users|storage|\
                optical|adm|log|utmp|tty|disk|floppy|scanner|rfkill|sgx|\
                systemd-*|nm-*)
                    warn "'$USERNAME' is reserved by the system - please choose another."
                    continue ;;
            esac
            if getent passwd "$USERNAME" >/dev/null 2>&1 || getent group "$USERNAME" >/dev/null 2>&1; then
                warn "'$USERNAME' is already a system account - please choose another."
                continue
            fi
            break
        fi
        warn "Invalid username: '$USERNAME' - must start with a lowercase letter or underscore, and contain only lowercase letters, digits, hyphens, or underscores. Try again."
    done

    echo
    while true; do
        ask "User & root password:"
        read -rsp "  > " USER_PASS; echo
        if [[ -z "$USER_PASS" ]]; then
            warn "Password cannot be empty - please try again."
            continue
        fi
        ask "Confirm password:"
        read -rsp "  > " USER_PASS2; echo
        if [[ "$USER_PASS" != "$USER_PASS2" ]]; then
            warn "Passwords do not match - please try again."
        else
            break
        fi
    done

    echo
    warn "LUKS passphrase - typed every time you boot into Arch:"
    while true; do
        ask "LUKS passphrase:"
        read -rsp "  > " LUKS_PASS; echo
        if [[ ${#LUKS_PASS} -lt 8 ]]; then
            warn "LUKS passphrase must be at least 8 characters - it is the only thing protecting the disk."
            continue
        fi
        ask "Confirm LUKS passphrase:"
        read -rsp "  > " LUKS_PASS2; echo
        if [[ "$LUKS_PASS" != "$LUKS_PASS2" ]]; then
            warn "LUKS passphrases do not match - please try again."
        else
            break
        fi
    done

    choose_timezone

    echo
    echo -e "  ${WHITE}${BOLD}CPU Vulnerability Mitigations${NC}"
    echo -e "  ${DIM}Disabling gives ~5-10% more FPS but reduces side-channel protection.${NC}"
    echo -e "  ${GREEN}[1]${NC}  ${WHITE}OFF${NC}  - Max performance  (mitigations=off)"
    echo -e "  ${GREEN}[2]${NC}  ${WHITE}ON${NC}   - Keep protections  (safer for banking/crypto)"
    while true; do
        read -rp "  Choose [1/2]: " MIT_CHOICE
        case "$MIT_CHOICE" in
            1) USE_MITIGATIONS=false; log "Mitigations: OFF"; break ;;
            2) USE_MITIGATIONS=true;  log "Mitigations: ON";  break ;;
            *) warn "Please type 1 or 2 - there is no silent default for a security setting." ;;
        esac
    done
}

# -- Step 4: Confirm ------------------------------------------
confirm() {
    hr "Summary & Confirmation"
    echo -e "  ${WHITE}${BOLD}Review carefully before continuing:${NC}"
    echo
    echo -e "  ${PINK}-- Hardware --------------------------------------${NC}"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "CPU:"  "$CPU_MODEL ($CPU_VENDOR)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "GPU:"  "$GPU_MODEL ($GPU_VENDOR)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "RAM:"  "~${MEM_TOTAL_GB}GB"
    echo
    echo -e "  ${PINK}-- Install mode ----------------------------------${NC}"
    if [[ "$INSTALL_MODE" == "dualboot" ]]; then
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Mode:"            "Dual-boot  (Windows preserved)"
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "EFI (/boot):"     "$EFI_PART  (reused, NOT reformatted)"
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "New partition:"   "$TARGET_DRIVE  (free space)"
    else
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Mode:"            "Single-boot  (standalone Arch)"
        printf "  ${RED}x${NC}  %-22s ${RED}%s${NC}\n"   "WIPE target:"     "$SINGLE_DRIVE  - ENTIRE DRIVE WIPED"
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "EFI partition:"   "512 MiB  (new, created on $SINGLE_DRIVE)"
        printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Root partition:"  "Remaining space on $SINGLE_DRIVE"
    fi
    echo
    echo -e "  ${PINK}-- Configuration ---------------------------------${NC}"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Bootloader:"    "Limine (UEFI)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Keyboard:"      "$KEYBOARD_NAME  (console: $KEYMAP)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Kernel:"        "linux-cachyos (BORE - installed first-boot)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Encryption:"    "LUKS2"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Filesystem:"    "Btrfs  -  @  @home  @snapshots  @var_log"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Swap:"          "ZRAM (zstd, auto-sized)"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Hostname:"      "$HOSTNAME"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Username:"      "$USERNAME"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Desktop:"       "GNOME"
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Mitigations:"   "$([ "$USE_MITIGATIONS" = true ] && echo ON || echo OFF)"
    echo
    echo -e "  ${PINK}-- Security trade-offs you are accepting -------${NC}"
    printf "  ${YELLOW}!${NC}  %-22s ${WHITE}%s${NC}\n" "Auto-login:" "ON - after the LUKS passphrase, the desktop opens as"
    printf "  ${YELLOW} ${NC}  %-22s ${WHITE}%s${NC}\n" "" "'$USERNAME' (sudo-capable) with no second password"
    if [[ "$USE_MITIGATIONS" == false ]]; then
        printf "  ${YELLOW}!${NC}  %-22s ${WHITE}%s${NC}\n" "CPU mitigations:" "OFF - side-channel protections disabled for FPS"
    fi
    printf "  ${CYAN}-${NC}  %-22s ${WHITE}%s${NC}\n" "Hotspot routing:" "only while the hotspot runs (NetworkManager-managed)"
    echo
    if [[ "$INSTALL_MODE" == "dualboot" ]]; then
        warn "Only the selected free space will be touched - Windows is safe."
    else
        warn "ALL data on $SINGLE_DRIVE will be permanently destroyed."
        warn "This includes any OS, files, or partitions on that drive."
    fi
    echo
    echo -e "  ${RED}${BOLD}Type  YES  (uppercase) to begin - this cannot be undone:${NC}"
    read -rp "  > " CONFIRM_INPUT
    [[ "$CONFIRM_INPUT" == "YES" ]] || { echo "  Aborted. Nothing was changed."; exit 0; }
}

# -- Step 5: Create partition in selected free space ----------
create_partition() {
    hr "Partition Creation"
    info "Creating partition on $TARGET_DRIVE..."

    PARTS_BEFORE=$(lsblk -lno NAME "$TARGET_DRIVE" 2>/dev/null)

    PART_END=$(( ${FREE_END%.*} - 1 ))
    parted -s "$TARGET_DRIVE" mkpart primary "${FREE_START}MiB" "${PART_END}MiB" \
        || err "Failed to create partition on $TARGET_DRIVE"

    partprobe "$TARGET_DRIVE" 2>/dev/null || true

    # Wait for the kernel AND udev to publish the new node (up to ~10s)
    # instead of a fixed sleep that is too short on some controllers.
    NEW_PART_NAME=""
    local _try
    for _try in $(seq 1 20); do
        udevadm settle --timeout=5 2>/dev/null || true
        PARTS_AFTER=$(lsblk -lno NAME "$TARGET_DRIVE" 2>/dev/null)
        NEW_PART_NAME=$(diff <(echo "$PARTS_BEFORE") <(echo "$PARTS_AFTER") \
            | grep '^>' | awk '{print $2}' | head -1)
        [[ -n "$NEW_PART_NAME" && -b "/dev/$NEW_PART_NAME" ]] && break
        sleep 0.5
    done

    [[ -n "$NEW_PART_NAME" ]] \
        || err "Could not identify new partition. Run: lsblk $TARGET_DRIVE"

    LUKS_PART="/dev/$NEW_PART_NAME"
    [[ -b "$LUKS_PART" ]] || err "New partition $LUKS_PART is not a block device"

    # Set Linux filesystem type GUID on GPT
    NEW_PART_NUM=$(lsblk -no PARTN "$LUKS_PART" 2>/dev/null \
        || echo "$LUKS_PART" | grep -o '[0-9]*$')
    parted -s "$TARGET_DRIVE" type "$NEW_PART_NUM" \
        "0FC63DAF-8483-4772-8E79-3D69D8477DE4" 2>/dev/null \
        || true   # older parted may not support 'type'; harmless

    log "New partition: $LUKS_PART"
}

# -- Step 6: LUKS2 encryption ---------------------------------
setup_luks() {
    hr "LUKS2 Encryption Setup"
    log "Formatting $LUKS_PART with LUKS2..."

    printf '%s' "$LUKS_PASS" | cryptsetup luksFormat \
        --batch-mode \
        --type luks2 \
        "$LUKS_PART" \
        --key-file=- \
        || err "cryptsetup luksFormat failed on $LUKS_PART"

    # Back up the header immediately - a corrupted LUKS header on an
    # otherwise-healthy disk is unrecoverable without one, and right after
    # formatting (before there's any real data at risk) is exactly when to
    # take it. Saved alongside the install log so it rides along on the same
    # USB copy offered at the end of the install (or on failure via err()).
    LUKS_HEADER_BACKUP="/root/niixarch-luks-header-backup.img"
    if cryptsetup luksHeaderBackup "$LUKS_PART" --header-backup-file "$LUKS_HEADER_BACKUP" 2>/dev/null; then
        chmod 600 "$LUKS_HEADER_BACKUP"
        log "LUKS header backed up to $LUKS_HEADER_BACKUP (copied to USB alongside the install log)"
        info "Keep that backup private: it opens the disk with THIS passphrase even after you"
        info "change it later. If the passphrase is ever exposed, change it AND delete the backup."
    else
        warn "Could not back up the LUKS header - you can run 'cryptsetup luksHeaderBackup' manually later"
    fi

    LUKS_UUID=$(blkid -s UUID -o value "$LUKS_PART")
    [[ -n "$LUKS_UUID" ]] || err "Could not read UUID from $LUKS_PART"
    log "LUKS2 UUID: $LUKS_UUID"

    log "Opening container as /dev/mapper/$MAPPER..."
    printf '%s' "$LUKS_PASS" | cryptsetup open \
        "$LUKS_PART" "$MAPPER" \
        --key-file=- \
        || err "cryptsetup open failed on $LUKS_PART"

    [[ -b "/dev/mapper/$MAPPER" ]] || err "Failed to open LUKS container"
    log "Container open at /dev/mapper/$MAPPER"
}

# -- Step 7: Btrfs + subvolumes -------------------------------
setup_btrfs() {
    hr "Btrfs Filesystem"
    log "Formatting /dev/mapper/$MAPPER as Btrfs..."
    mkfs.btrfs -f /dev/mapper/$MAPPER || err "mkfs.btrfs failed on /dev/mapper/$MAPPER"

    log "Creating subvolumes..."
    mount /dev/mapper/$MAPPER /mnt || err "Failed to mount /dev/mapper/$MAPPER"
    btrfs subvolume create /mnt/@          || err "Failed to create subvolume @"
    btrfs subvolume create /mnt/@home      || err "Failed to create subvolume @home"
    btrfs subvolume create /mnt/@snapshots || err "Failed to create subvolume @snapshots"
    btrfs subvolume create /mnt/@var_log   || err "Failed to create subvolume @var_log"
    umount /mnt || err "Failed to unmount /mnt after subvolume creation"

    log "Mounting subvolumes..."
    mount -o "${BTRFS_OPTS},subvol=@"          /dev/mapper/$MAPPER /mnt || err "Failed to mount @"
    mkdir -p /mnt/{boot,home,.snapshots,var/log}
    mount "$EFI_PART" /mnt/boot || err "Failed to mount EFI partition $EFI_PART"
    mount -o "${BTRFS_OPTS},subvol=@home"      /dev/mapper/$MAPPER /mnt/home       || err "Failed to mount @home"
    mount -o "${BTRFS_OPTS},subvol=@snapshots" /dev/mapper/$MAPPER /mnt/.snapshots || err "Failed to mount @snapshots"
    mount -o "${BTRFS_OPTS},subvol=@var_log"   /dev/mapper/$MAPPER /mnt/var/log    || err "Failed to mount @var_log"

    log "Btrfs mounted"
}

# -- Step 7b: Remove ALL traces of previous Arch/Limine ------
clean_efi() {
    hr "EFI Partition Cleanup"
    info "Removing all traces of previous Arch/Limine installations..."
    info "(Windows and EFI/Microsoft files will NOT be touched)"

    # -- 1. Clean files from EFI partition --------------------
    # Kernels and initramfs (stock + cachyos) at the ESP root. A previous NiiX
    # install is recognised by its Limine branding and cleaned silently. Any
    # other Arch-family install that keeps its kernels at the ESP root (e.g.
    # another Limine-based distro in a triple-boot) would lose them - and this
    # install writes the same filenames anyway - so that case needs consent.
    local stale_kernels=() f
    for f in /mnt/boot/vmlinuz-linux* /mnt/boot/initramfs-linux*.img; do
        [ -e "$f" ] && stale_kernels+=("$f")
    done
    if [ "${#stale_kernels[@]}" -gt 0 ]; then
        if grep -qs 'NiiX Arch' /mnt/boot/limine.conf /mnt/boot/limine.cfg \
                /mnt/boot/limine/limine.conf /mnt/boot/limine/limine.cfg \
                /mnt/boot/EFI/limine/limine.conf /mnt/boot/EFI/limine/limine.cfg \
                /mnt/boot/EFI/BOOT/limine.conf /mnt/boot/EFI/BOOT/limine.cfg; then
            info "Removing kernels left by a previous NiiX install..."
        else
            warn "Found Linux kernel files on this EFI partition that do NOT belong to a previous NiiX install:"
            for f in "${stale_kernels[@]}"; do echo "    $f"; done
            warn "If another Linux install boots from these, it will stop booting (NiiX uses the same filenames)."
            ask "Delete them and continue? [y/N]:"
            read -rp "  > " DELETE_FOREIGN_KERNELS
            [[ "$DELETE_FOREIGN_KERNELS" =~ ^[Yy]$ ]] \
                || err "Aborted at your request - the other install's kernel files were left untouched."
        fi
        rm -f "${stale_kernels[@]}"
    fi

    # Limine configs + EFI binary - also unambiguously ours, only Limine uses
    # these exact paths.
    rm -rf /mnt/boot/limine
    rm -f  /mnt/boot/limine.cfg
    rm -f  /mnt/boot/limine.conf
    rm -rf /mnt/boot/EFI/limine

    # GRUB, systemd-boot, and generic UKIs are NOT unambiguously ours - on a
    # shared ESP (triple-boot, another distro previously installed here)
    # these could belong to someone else's working bootloader. Warn and
    # require explicit confirmation before touching them instead of silently
    # deleting - that's data loss nobody consented to.
    FOREIGN_BOOT_PATHS=()
    for p in /mnt/boot/EFI/GRUB /mnt/boot/grub /mnt/boot/EFI/systemd /mnt/boot/loader /mnt/boot/EFI/Linux; do
        [ -e "$p" ] && FOREIGN_BOOT_PATHS+=("$p")
    done
    if [ "${#FOREIGN_BOOT_PATHS[@]}" -gt 0 ]; then
        warn "Found other bootloader files on this EFI partition (could be another Linux install sharing this drive):"
        for p in "${FOREIGN_BOOT_PATHS[@]}"; do echo "    $p"; done
        ask "Delete these too? Only say yes if you're sure they're stale NiiX/Arch leftovers, not another OS. [y/N]:"
        read -rp "  > " DELETE_FOREIGN_BOOT
        if [[ "$DELETE_FOREIGN_BOOT" =~ ^[Yy]$ ]]; then
            for p in "${FOREIGN_BOOT_PATHS[@]}"; do rm -rf "$p"; done
            log "Removed other bootloader files"
        else
            log "Left other bootloader files untouched"
        fi
    fi

    log "EFI partition cleaned"

    # -- 2. Remove old Limine/Arch UEFI boot entries ---------
    info "Cleaning old UEFI NVRAM boot entries..."
    while IFS= read -r bootnum; do
        efibootmgr -b "$bootnum" -B 2>/dev/null \
            && log "Removed UEFI entry Boot${bootnum}" \
            || true
    done < <(efibootmgr 2>/dev/null \
        | grep -iE "limine|arch" \
        | grep -oP 'Boot\K[0-9A-Fa-f]{4}')

    log "UEFI NVRAM cleaned"
}

# -- Step 8: Swiss mirrors & base install ---------------------
install_base() {
    hr "Base System Install"
    info "Fetching fastest $MIRROR_COUNTRY mirrors..."
    pacman -Sy --noconfirm reflector 2>/dev/null \
        && reflector \
            --country "$MIRROR_COUNTRY" \
            --latest 10 \
            --protocol https \
            --sort rate \
            --save /etc/pacman.d/mirrorlist \
        && log "Mirrors updated" \
        || warn "reflector failed - using default mirrors"

    # Speed: pacstrap downloads through the ISO's pacman.conf, which ships with
    # ParallelDownloads commented out (i.e. one file at a time). Raising it here
    # is the single biggest win on the 10-20 minute base install, and
    # DisableDownloadTimeout stops a slow-but-alive mirror from aborting.
    if grep -q '^#*ParallelDownloads' /etc/pacman.conf; then
        sed -i 's/^#*ParallelDownloads.*/ParallelDownloads = 15/' /etc/pacman.conf
    else
        sed -i '/^\[options\]/a ParallelDownloads = 15' /etc/pacman.conf
    fi
    grep -q '^DisableDownloadTimeout' /etc/pacman.conf \
        || sed -i '/^\[options\]/a DisableDownloadTimeout' /etc/pacman.conf
    log "ISO pacman tuned: 15 parallel downloads, no download timeout"

    # Write vconsole.conf BEFORE pacstrap so mkinitcpio's keymap hook doesn't error
    mkdir -p /mnt/etc
    echo "KEYMAP=${KEYMAP}" > /mnt/etc/vconsole.conf

    log "Installing base system (this takes 10-20 minutes)..."
    local n=1
    until pacstrap /mnt \
        base base-devel linux linux-firmware \
        networkmanager wpa_supplicant iw efibootmgr \
        sudo nano git wget curl python \
        ntfs-3g dosfstools \
        btrfs-progs cryptsetup \
        limine \
        pipewire pipewire-alsa pipewire-pulse wireplumber \
        bluez bluez-utils \
        iio-sensor-proxy \
        dconf \
        unzip
    do
        if [ "$n" -ge 3 ]; then
            err "pacstrap failed after $n attempts - base system install could not complete"
        fi
        warn "pacstrap failed (mirror/DNS blip?) - refreshing databases and retrying ($n/3)..."
        pacman -Syy --noconfirm 2>/dev/null || true
        sleep 3
        n=$((n + 1))
    done

    log "Base system installed"
}

# -- CachyOS repo + kernel ------------------------------------
# (CachyOS kernel installed during first-boot - needs live system for PGP keys)

# -- Enable multilib + parallel downloads (must run before any package install) --
enable_multilib() {
    hr "Enabling multilib"
    info "Enabling multilib repository and parallel downloads..."

    if ! grep -q "^\[multilib\]" /mnt/etc/pacman.conf; then
        sed -i '/^#\[multilib\]/,/^#Include/ s/^#//' /mnt/etc/pacman.conf
        log "multilib enabled"
    else
        log "multilib already enabled"
    fi

    if grep -q '^#*ParallelDownloads' /mnt/etc/pacman.conf; then
        sed -i 's/^#*ParallelDownloads.*/ParallelDownloads = 15/' /mnt/etc/pacman.conf
    else
        sed -i '/^\[options\]/a ParallelDownloads = 15' /mnt/etc/pacman.conf
    fi
    grep -q '^DisableDownloadTimeout' /mnt/etc/pacman.conf \
        || sed -i '/^\[options\]/a DisableDownloadTimeout' /mnt/etc/pacman.conf
    log "Parallel downloads set to 15 (no download timeout)"

    # Speed: makepkg defaults to ONE compile job and then re-compresses every
    # built package with single-threaded zstd. The AUR builds at first boot are
    # the slowest part of the whole install, and both of those are pure waste on
    # a multi-core gaming machine.
    #   MAKEFLAGS  - compile with every core
    #   PKGEXT     - do not compress packages that are installed immediately
    #   COMPRESSZST- multi-threaded, for anything that still is compressed
    if [[ -f /mnt/etc/makepkg.conf ]]; then
        sed -i -e 's/^#\?MAKEFLAGS=.*/MAKEFLAGS="-j\$(nproc)"/' \
               -e "s/^#\?PKGEXT=.*/PKGEXT='.pkg.tar'/" \
               -e 's/^#\?COMPRESSZST=.*/COMPRESSZST=(zstd -c -T0 --fast -)/' \
               /mnt/etc/makepkg.conf
        grep -q '^MAKEFLAGS=' /mnt/etc/makepkg.conf \
            || echo 'MAKEFLAGS="-j$(nproc)"' >> /mnt/etc/makepkg.conf
        grep -q '^PKGEXT=' /mnt/etc/makepkg.conf \
            || echo "PKGEXT='.pkg.tar'" >> /mnt/etc/makepkg.conf
        log "makepkg tuned: all cores, no package re-compression (faster AUR builds)"
    fi

    # Force a real (dereferenced) resolv.conf into the chroot AND always append
    # public fallback resolvers (Cloudflare + Quad9). arch-chroot's bind-mount
    # silently skips if the target is a symlink, and copying only the ISO's
    # resolver leaves DNS entirely dependent on the ISO's DHCP/stub resolver -
    # if that dies mid-install (minutes later) every mirror fails with "Could
    # not resolve host", which looks like a mirror outage but isn't. Appending
    # fallbacks lets glibc fall through to a reachable resolver on timeout.
    harden_dns
    if ! arch-chroot /mnt getent hosts archlinux.org &>/dev/null; then
        warn "DNS not resolving in chroot yet - waiting for it to recover..."
        wait_for_dns || warn "DNS still flaky - continuing on public fallback resolvers"
    fi

    # Full sync+upgrade (never a bare -Sy: a partial-upgrade DB refresh without an
    # upgrade is the classic Arch breakage where new libs mismatch old packages).
    # Retry on mirror blips instead of hard-failing on the first transient error -
    # a single flaky mirror shouldn't tear down a 20-minute install.
    local n=1
    until arch-chroot /mnt pacman -Syu --noconfirm; do
        if [ "$n" -ge 3 ]; then
            err "pacman -Syu failed after $n attempts while enabling multilib"
        fi
        warn "Mirror/DNS error - re-checking DNS and refreshing databases, retry ($n/3)..."
        wait_for_dns || true
        arch-chroot /mnt pacman -Syyu --noconfirm 2>/dev/null || true
        n=$((n + 1))
    done
    log "Package databases synced and system upgraded"
}

# -- GPU drivers (auto-detected) ------------------------------
install_gpu_drivers() {
    hr "GPU Base Userspace"

    # DB was already synced + upgraded by enable_multilib(); no extra -Sy here (a bare
    # -Sy before installing would risk a partial upgrade).

    # Minimal Mesa/Vulkan so the FIRST boot reaches the desktop on the iGPU and has
    # 32-bit GL for Steam. The actual vendor drivers and the full Optimus/PRIME setup
    # (NVIDIA legacy 580xx/470xx, nvidia-prime, switcheroo-control, RTD3 workaround,
    # initramfs) are installed at first boot by CachyOS hardware detection (chwd),
    # which is the tested path - we no longer hand-roll driver logic.
    local base_pkgs=(mesa lib32-mesa vulkan-icd-loader lib32-vulkan-icd-loader \
                     vulkan-mesa-layers lib32-vulkan-mesa-layers)
    if [[ "$HAS_INTEL" -gt 0 ]]; then
        base_pkgs+=(vulkan-intel lib32-vulkan-intel intel-media-driver)
        info "Intel graphics detected -> mesa + vulkan-intel"
    fi
    if [[ "$HAS_AMD" -gt 0 ]]; then
        base_pkgs+=(vulkan-radeon lib32-vulkan-radeon libva-mesa-driver lib32-libva-mesa-driver)
        info "AMD graphics detected -> mesa + vulkan-radeon"
    fi
    pac_install_retry "${base_pkgs[@]}" \
        || warn "Some Mesa/Vulkan packages failed to install even after retries."

    # Shader cache - harmless, vendor-agnostic. (No MOZ_DISABLE_RDD_SANDBOX:
    # current Firefox does VAAPI inside the sandboxed RDD process, so disabling
    # that sandbox system-wide only weakens security.)
    cat >> /mnt/etc/environment <<'COMMONENV'
__GL_SHADER_DISK_CACHE=1
__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1
COMMONENV

    # Force EVERYTHING onto the discrete GPU globally, instead of the usual
    # per-game prime-run/DRI_PRIME launch options. This is a gaming distro and
    # is assumed to stay on AC power, so we skip hybrid-graphics power saving
    # entirely rather than requiring per-game launch-option edits. Only write
    # the vars for vendors actually present - __GLX_VENDOR_LIBRARY_NAME=nvidia
    # on a system with no NVIDIA GLX vendor breaks GLX apps outright, it's not
    # a harmless no-op like the other vars.
    if [[ "$HAS_NVIDIA" -gt 0 ]]; then
        cat >> /mnt/etc/environment <<'NVOFFLOAD'
__NV_PRIME_RENDER_OFFLOAD=1
__NV_PRIME_RENDER_OFFLOAD_PROVIDER=NVIDIA-G0
__GLX_VENDOR_LIBRARY_NAME=nvidia
NVOFFLOAD
        info "NVIDIA detected -> forcing discrete GPU render offload globally"
    fi
    if [[ "$HAS_AMD" -gt 0 ]]; then
        cat >> /mnt/etc/environment <<'AMDOFFLOAD'
DRI_PRIME=1
AMDOFFLOAD
        info "AMD detected -> forcing DRI_PRIME=1 globally"
    fi

    log "GPU base userspace installed (chwd configures the real drivers at first boot)"
}

# -- DNS resilience -------------------------------------------
# Copy the live resolver into the chroot, then ALWAYS append public fallbacks so
# a mid-install death of the ISO's DHCP/stub resolver can't take DNS down (glibc
# falls through to the next nameserver on timeout). Idempotent - safe to re-run.
harden_dns() {
    local rc=/mnt/etc/resolv.conf
    rm -f "$rc" 2>/dev/null || true
    cat /etc/resolv.conf > "$rc" 2>/dev/null || true
    grep -q '^nameserver 1\.1\.1\.1' "$rc" 2>/dev/null || echo 'nameserver 1.1.1.1' >> "$rc"
    grep -q '^nameserver 9\.9\.9\.9' "$rc" 2>/dev/null || echo 'nameserver 9.9.9.9' >> "$rc"
    grep -q '^options '           "$rc" 2>/dev/null || echo 'options timeout:2 attempts:3 rotate' >> "$rc"
}

# Block until the chroot resolves a hostname again (~60s max), re-hardening DNS
# on each attempt so a transient resolver outage self-heals instead of aborting
# the install. Returns 0 as soon as resolution works.
wait_for_dns() {
    local i
    for i in $(seq 1 12); do
        arch-chroot /mnt getent hosts archlinux.org &>/dev/null && return 0
        harden_dns
        sleep 5
    done
    arch-chroot /mnt getent hosts archlinux.org &>/dev/null
}

# Record an optional package that could not be installed now, so first-boot can
# retry it (with a Flatpak fallback where one exists) instead of the whole
# install aborting over one transient blip on a non-essential app.
defer_pkg() {
    mkdir -p /mnt/var/lib/niixarch
    grep -qxF "$1" /mnt/var/lib/niixarch/deferred 2>/dev/null \
        || echo "$1" >> /mnt/var/lib/niixarch/deferred
}

# -- All pacman packages (gaming, security, tools) ------------
# Retry through mirror/DNS blips (waiting for DNS + refreshing databases between
# attempts) instead of failing on the first transient error - this is the exact
# failure class that has taken down this install before (a resolver hiccup looks
# identical to "package unavailable" in pacman's output, but waiting for DNS and
# retrying usually clears it). Returns 0 only if every package in $@ installs.
pac_install_retry() {
    local n=1
    until arch-chroot /mnt pacman -S --noconfirm --needed "$@"; do
        if [ "$n" -ge 3 ]; then
            return 1
        fi
        warn "Install error - re-checking DNS + refreshing databases, retry ($n/3)..."
        wait_for_dns || true
        arch-chroot /mnt pacman -Syyu --noconfirm 2>/dev/null || true
        n=$((n + 1))
    done
    return 0
}

# Install a batch into the target. If the atomic transaction fails for ANY reason
# (a mirror blip, a single unavailable package, a provider prompt), retry each
# package individually so one failure can't silently wipe out the whole group.
chroot_install() {
    if pac_install_retry "$@"; then return 0; fi
    warn "Batch install failed after retries - retrying each package individually..."
    local p okc=0 badc=0
    for p in "$@"; do
        if pac_install_retry "$p"; then okc=$((okc+1))
        else warn "  could not install: $p"; badc=$((badc+1)); fi
    done
    log "Per-package retry: ${okc} installed, ${badc} failed"
    return 0
}

# Best-effort extras that are subject to multilib churn (Arch has been dropping
# and re-adding lib32 packages; some now live only in the AUR). Try each on its
# own so a name that isn't currently in the repos is quietly skipped instead of
# collapsing the whole main transaction into the slow per-package fallback.
# These are nice-to-have 32-bit codecs, never required for a working system.
install_optional() {
    local p
    for p in "$@"; do
        if arch-chroot /mnt pacman -S --noconfirm --needed "$p" &>/dev/null; then
            info "optional 32-bit codec installed: $p"
        else
            info "optional 32-bit codec not in repos right now - skipped: $p"
        fi
    done
}

install_packages() {
    hr "Package Install"
    info "Installing packages (gaming, security, tools)..."

    chroot_install \
        linux-headers \
        gamemode lib32-gamemode mangohud lib32-mangohud \
        gamescope \
        wine winetricks \
        vkd3d lib32-vkd3d \
        ananicy-cpp \
        lib32-alsa-plugins lib32-libpulse \
        ufw \
        zram-generator irqbalance \
        power-profiles-daemon \
        flatpak \
        dnsmasq \
        snapper snap-pac \
        vlc \
        filezilla libreoffice-fresh signal-desktop \
        xorg-server \
        xorg-xwayland \
        libva-utils dmidecode \
        gnome-shell-extension-appindicator \
        reflector

    # Optional 32-bit codecs, best-effort (see install_optional). Kept out of the
    # batch above so their availability can't affect the core install.
    install_optional lib32-openal lib32-gst-plugins-base lib32-gst-plugins-good

    # Steam - requires multilib. Non-fatal: a transient mirror/DNS blip on one
    # app must not abort a whole install. If it fails now, first-boot retries it.
    info "Installing Steam..."
    if pac_install_retry steam; then
        log "Steam installed"
    else
        warn "Steam not installed now (transient?) - deferred to first boot"
        defer_pkg steam
    fi

    # Lutris - non-fatal; first-boot retries from the repo, then Flatpak.
    info "Installing Lutris..."
    if pac_install_retry lutris; then
        log "Lutris installed"
    else
        warn "Lutris not installed now (transient?) - deferred to first boot"
        defer_pkg lutris
    fi

    # fastfetch - cosmetic; never worth aborting the install for.
    info "Installing fastfetch..."
    if pac_install_retry fastfetch; then
        log "fastfetch installed"
    else
        warn "fastfetch not installed now (transient?) - deferred to first boot"
        defer_pkg fastfetch
    fi

    # CPU microcode. A stale <vendor>-ucode.img left on a reused ESP makes a
    # plain install fail with "exists in filesystem" (a file conflict retries
    # can't fix), silently skipping microcode. --overwrite lets the package
    # claim the file it legitimately owns; retry fallback covers mirror blips.
    case "$CPU_VENDOR" in
        amd)
            arch-chroot /mnt pacman -S --noconfirm --needed --overwrite '/boot/amd-ucode.img' amd-ucode \
                || pac_install_retry amd-ucode || warn "amd-ucode not installed - CPU microcode updates unavailable" ;;
        intel)
            arch-chroot /mnt pacman -S --noconfirm --needed --overwrite '/boot/intel-ucode.img' intel-ucode \
                || pac_install_retry intel-ucode || warn "intel-ucode not installed - CPU microcode updates unavailable" ;;
    esac

    # CoreCtrl for AMD GPU control
    [[ "$GPU_VENDOR" == "amd" ]] && \
        pac_install_retry corectrl || true

    log "All packages installed"
}

# -- System configuration + tweaks ----------------------------
configure_system() {
    hr "System Configuration"
    log "Generating fstab..."
    genfstab -U /mnt >> /mnt/etc/fstab || err "genfstab failed"
    grep -qE '^[^#].*[[:space:]]/[[:space:]]+btrfs' /mnt/etc/fstab \
        || err "fstab has no btrfs root entry - check the log"

    # /tmp: no fstab entry on purpose. systemd's tmp.mount already makes /tmp a
    # tmpfs (nosuid,nodev, sized at 50% of RAM). A fixed size cap here would
    # override that and break large builds/extractions on high-RAM machines.

    # (mkinitcpio.conf written and rebuilt by rebuild_initramfs after all packages)

    # -- Sysctl tuning ----------------------------------------
    # Swap here is ZRAM (compressed RAM), not disk - so classic low-swappiness
    # tuning is inverted: it makes the kernel drop useful page cache instead of
    # using nearly-free compressed memory. swappiness=100 matches CachyOS's
    # shipped default for this exact stack. page-cluster=0 reads one page per
    # fault instead of eight - right for RAM-backed swap, a legacy of spinning
    # disks otherwise.
    local swappiness=100

    cat > /mnt/etc/sysctl.d/99-niixarch.conf <<SYSEOF
vm.swappiness=$swappiness
vm.page-cluster=0
vm.vfs_cache_pressure=50
vm.compaction_proactiveness=0
vm.watermark_boost_factor=0
vm.max_map_count=2147483642
kernel.nmi_watchdog=0
kernel.split_lock_mitigate=0
fs.inotify.max_user_watches=524288
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
SYSEOF
    # No global ip_forward: NetworkManager switches IPv4 forwarding on itself
    # while a shared (hotspot) connection is up. Global IPv6 forwarding would
    # also make the kernel ignore router advertisements on every interface.
    log "Sysctl tuned (ZRAM-aware swap: swappiness=$swappiness + page-cluster=0; max_map_count + split-lock for gaming)"

    # -- Transparent hugepages --------------------------------
    mkdir -p /mnt/etc/tmpfiles.d
    echo "w /sys/kernel/mm/transparent_hugepage/enabled - - - - madvise" \
        > /mnt/etc/tmpfiles.d/hugepages.conf

    # -- NVMe I/O scheduler -----------------------------------
    if [[ "$HAS_NVME" == true ]]; then
        mkdir -p /mnt/etc/udev/rules.d
        echo 'ACTION=="add|change", KERNEL=="nvme[0-9]n[0-9]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="none"' \
            > /mnt/etc/udev/rules.d/60-nvme-scheduler.conf
    fi

    # -- SSD I/O scheduler ------------------------------------
    if [[ "$HAS_SSD" == true ]]; then
        mkdir -p /mnt/etc/udev/rules.d
        echo 'ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="none"' \
            > /mnt/etc/udev/rules.d/60-ssd-scheduler.conf
    fi

    # -- ZRAM (auto-sized) ------------------------------------
    local zram_frac="ram / 2"
    [[ "$MEM_TOTAL_GB" -le 8 ]] && zram_frac="ram"
    cat > /mnt/etc/systemd/zram-generator.conf <<ZRAMEOF
[zram0]
zram-size = $zram_frac
compression-algorithm = zstd
ZRAMEOF

    # -- Firewall (configure rules - ufw starts on boot via systemctl) --
    # NetworkManager connection-sharing (hotspot) hands out 10.42.0.0/24 and
    # runs its own per-interface dnsmasq. ufw's default "deny incoming" blocks
    # the phone's DHCP (67/udp) + DNS (53) requests, and the default DROP
    # forward policy blocks its traffic - so the phone connects but gets no IP
    # or no internet. The rules below open exactly that: DHCP/DNS from the
    # hotspot subnet, and forwarding ONLY for traffic originating from it
    # (replies are already allowed by ufw's conntrack rules). The global
    # forward policy stays DROP.
    arch-chroot /mnt bash <<'FWEOF'
ufw default deny incoming 2>/dev/null || true
ufw default allow outgoing 2>/dev/null || true
ufw default deny routed 2>/dev/null || true
# Steam (in-home streaming / remote play)
ufw allow 27036:27037/tcp 2>/dev/null || true
ufw allow 27031:27036/udp 2>/dev/null || true
# Hotspot: let clients on the shared subnet pull DHCP + DNS from us
ufw allow in proto udp from 10.42.0.0/24 to any port 67 2>/dev/null || true
ufw allow in proto udp from 10.42.0.0/24 to any port 53 2>/dev/null || true
ufw allow in proto tcp from 10.42.0.0/24 to any port 53 2>/dev/null || true
ufw route allow from 10.42.0.0/24 2>/dev/null || true
# Don't run 'ufw --force enable' in chroot - no iptables modules
# ufw will start on boot via systemctl enable ufw
FWEOF

    grep -qs '10.42.0.0/24' /mnt/etc/ufw/user.rules \
        || warn "ufw hotspot rules were not written - the hotspot may not hand out addresses"

    # -- niix-hotspot helper - phone-friendly 2.4GHz WPA2 hotspot ---------
    # Defaults to 2.4GHz / channel 7 / WPA2-AES only. This is the combination
    # phones reliably connect to: 5GHz AP mode fails on many adapters, WPA3/SAE
    # is rejected by older phones, and TKIP is rejected by newer ones.
    cat > /mnt/usr/local/bin/niix-hotspot <<'HSEOF'
#!/bin/bash
# niix-hotspot - phone-friendly 2.4GHz WPA2 Wi-Fi hotspot (NetworkManager)
#   niix-hotspot start [SSID] [PASSWORD]  create/update the hotspot and bring it up
#   niix-hotspot setup SSID [PASSWORD]    create/update it without bringing it up
#   niix-hotspot up                       bring the saved hotspot up
#   niix-hotspot stop                     take it down (auto mode paused until reboot)
#   niix-hotspot auto on|off              keep it up automatically (from boot, and
#                                         restored after internet outages)
#   niix-hotspot password                 show the saved password
#   niix-hotspot status
# PASSWORD: 8-63 characters; omit it to get a random one. It is stored only in
# NetworkManager's root-only profile. Defaults: 2.4GHz, channel 7, WPA2-AES
# only - the combination phones connect to most reliably.
# Exit codes: 0 ok, 1 error, 2 refused (Wi-Fi is the only internet connection),
#             3 no saved hotspot
set -uo pipefail
CON="niix-hotspot"
SVC="niix-hotspot-watchdog.service"
ACTION="${1:-}"

usage() {
    echo "Usage: niix-hotspot {start [SSID] [PASSWORD]|setup SSID [PASSWORD]|up|stop|auto on|off|password|status}"
}

run_root() {
    if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi
}

wifi_dev() {
    nmcli -t -f DEVICE,TYPE device 2>/dev/null | awk -F: '$2=="wifi"{print $1; exit}'
}

have_profile() {
    nmcli connection show "$CON" >/dev/null 2>&1
}

hotspot_active() {
    [ "$(nmcli -g GENERAL.STATE connection show "$CON" 2>/dev/null)" = "activated" ]
}

random_pass() {
    local p
    p=$(tr -dc 'A-Za-z0-9' < /dev/urandom 2>/dev/null | head -c 16) || true
    printf '%s' "$p"
}

# One Wi-Fi card cannot be a client and an access point at the same time. If
# Wi-Fi is the ONLY internet connection, turning it into a hotspot would cut
# this machine off and leave nothing to share - refuse unless another uplink
# (Ethernet, USB tethering, mobile modem) is up. NIIX_HOTSPOT_FORCE=1 overrides.
uplink_ok() {
    local dev="$1" state conn other
    [ "${NIIX_HOTSPOT_FORCE:-0}" = "1" ] && return 0
    state=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | awk -F: -v d="$dev" '$1==d{print $2; exit}')
    conn=$(nmcli -g GENERAL.CONNECTION device show "$dev" 2>/dev/null)
    [ "$state" = "connected" ] && [ "$conn" != "$CON" ] || return 0
    other=$(nmcli -t -f DEVICE,TYPE,STATE device 2>/dev/null | awk -F: -v d="$dev" \
        '$1!=d && $3=="connected" && $2!~/^(wifi|wifi-p2p|loopback|tun|wireguard|dummy|bridge)$/ {print $1; exit}')
    [ -n "$other" ] && return 0
    echo "Wi-Fi $dev is your internet connection ('$conn')."
    echo "A hotspot on the same Wi-Fi card would disconnect you and share nothing."
    echo "Connect Ethernet or USB tethering first - or force it: NIIX_HOTSPOT_FORCE=1 niix-hotspot up"
    return 1
}

create_profile() {
    local ssid="$1" pass="$2" dev
    dev=$(wifi_dev)
    [ -n "$dev" ] || { echo "No Wi-Fi device found."; return 1; }
    local ssid_bytes
    ssid_bytes=$(printf '%s' "$ssid" | wc -c)
    if [ "$ssid_bytes" -lt 1 ] || [ "$ssid_bytes" -gt 32 ]; then
        echo "SSID must be 1-32 bytes."; return 1
    fi
    if [ "${#pass}" -lt 8 ] || [ "${#pass}" -gt 63 ]; then
        echo "Password must be 8-63 characters."; return 1
    fi
    nmcli connection delete "$CON" >/dev/null 2>&1 || true
    nmcli connection add type wifi ifname "$dev" con-name "$CON" \
        autoconnect no ssid "$ssid" \
        802-11-wireless.mode ap \
        802-11-wireless.band bg \
        802-11-wireless.channel 7 \
        802-11-wireless.powersave 2 \
        ipv4.method shared \
        ipv6.method ignore \
        wifi-sec.key-mgmt wpa-psk \
        wifi-sec.proto rsn \
        wifi-sec.pairwise ccmp \
        wifi-sec.group ccmp \
        wifi-sec.pmf optional \
        wifi-sec.psk "$pass" >/dev/null \
        || { echo "Could not create the hotspot profile."; return 1; }
}

bring_up() {
    local dev bound ssid
    have_profile || { echo "No saved hotspot - run: niix-hotspot start [SSID] [PASSWORD]"; return 3; }
    dev=$(wifi_dev)
    [ -n "$dev" ] || { echo "No Wi-Fi device found."; return 1; }
    # Keep the profile bound to the current Wi-Fi card (names can change,
    # e.g. a different USB adapter).
    bound=$(nmcli -g connection.interface-name connection show "$CON" 2>/dev/null)
    if [ "$bound" != "$dev" ]; then
        nmcli connection modify "$CON" connection.interface-name "$dev" >/dev/null 2>&1 || true
    fi
    uplink_ok "$dev" || return 2
    if ! nmcli connection up "$CON" >/dev/null 2>&1; then
        echo "Failed to start hotspot. Check: nmcli -f WIFI-PROPERTIES.AP device show $dev"
        return 1
    fi
    ssid=$(nmcli -g 802-11-wireless.ssid connection show "$CON" 2>/dev/null)
    echo "Hotspot '$ssid' is UP on $dev (2.4GHz, WPA2-AES)."
    return 0
}

# Turn on auto mode: the hotspot comes up at every boot and is restored after
# an internet outage. Used by 'auto on' AND by 'start', so enabling the hotspot
# later behaves exactly like choosing it during first boot.
enable_auto() {
    have_profile || { echo "Set up the hotspot first: niix-hotspot setup SSID [PASSWORD]"; return 3; }
    # The dispatcher runs the hook that wakes the keeper on network events.
    run_root systemctl enable --now NetworkManager-dispatcher.service >/dev/null 2>&1 \
        || echo "Note: could not enable NetworkManager-dispatcher - the hotspot still recovers, just on the 15s timer."
    if run_root systemctl enable --now "$SVC"; then
        echo "Auto hotspot ON: kept up from boot and restored after internet outages."
        return 0
    fi
    echo "Could not enable $SVC - the hotspot will not come back after a reboot." >&2
    return 1
}

# setup/start argument handling: SSID defaults to NiiX-Arch; a missing
# password is generated randomly and printed once.
prepare_profile() {
    local ssid="${1:-NiiX-Arch}" pass="${2:-}" generated=false
    if [ -z "$pass" ]; then
        pass=$(random_pass)
        generated=true
    fi
    create_profile "$ssid" "$pass" || return 1
    echo "Hotspot saved: SSID '$ssid'"
    if [ "$generated" = true ]; then
        echo "Generated password: $pass  (show it again any time: niix-hotspot password)"
    fi
    return 0
}

case "$ACTION" in
    start)
        if [ $# -ge 2 ] || ! have_profile; then
            prepare_profile "${2:-}" "${3:-}" || exit 1
        fi
        bring_up; rc=$?
        if [ "$rc" -eq 0 ]; then
            if systemctl is-enabled --quiet "$SVC" 2>/dev/null; then
                # Resume auto mode if it is enabled but was paused by 'stop'.
                systemctl is-active --quiet "$SVC" 2>/dev/null \
                    || systemctl start "$SVC" >/dev/null 2>&1 || true
            elif [ "${NIIX_HOTSPOT_ONCE:-0}" != "1" ]; then
                # Setting the hotspot up later must give the same result as
                # choosing it during first boot: on at every boot, with the same
                # connectivity checks. NIIX_HOTSPOT_ONCE=1 starts it just once.
                echo "Turning on auto mode so the hotspot also comes back after a reboot..."
                enable_auto || true
            fi
        fi
        exit "$rc"
        ;;
    setup)
        [ $# -ge 2 ] || { usage; exit 1; }
        prepare_profile "$2" "${3:-}" || exit 1
        ;;
    up)
        bring_up; exit $?
        ;;
    stop)
        if systemctl is-active --quiet "$SVC" 2>/dev/null; then
            if systemctl stop "$SVC" >/dev/null 2>&1; then
                echo "Auto mode paused until the next boot ('niix-hotspot start' resumes it, 'niix-hotspot auto off' disables it)."
            else
                echo "Could not pause auto mode - it will bring the hotspot back. Try: sudo niix-hotspot stop"
            fi
        fi
        nmcli connection down "$CON" >/dev/null 2>&1 || true
        echo "Hotspot stopped."
        ;;
    auto)
        case "${2:-}" in
            on)
                enable_auto || exit $? ;;
            off)
                run_root systemctl disable --now "$SVC" \
                    && echo "Auto hotspot OFF." ;;
            *)
                auto_state=$(systemctl is-enabled "$SVC" 2>/dev/null) || true
                echo "Auto hotspot: ${auto_state:-unknown}"
                echo "Usage: niix-hotspot auto on|off" ;;
        esac
        ;;
    password)
        have_profile || { echo "No saved hotspot."; exit 3; }
        nmcli -s -g 802-11-wireless-security.psk connection show "$CON" 2>/dev/null \
            || { echo "Could not read the password (try: sudo niix-hotspot password)"; exit 1; }
        ;;
    status)
        if hotspot_active; then
            echo "Hotspot: UP ('$(nmcli -g 802-11-wireless.ssid connection show "$CON" 2>/dev/null)')"
        elif have_profile; then
            echo "Hotspot: down (saved as '$(nmcli -g 802-11-wireless.ssid connection show "$CON" 2>/dev/null)')"
        else
            echo "Hotspot: not set up - run: niix-hotspot start [SSID] [PASSWORD]"
        fi
        auto_state=$(systemctl is-enabled "$SVC" 2>/dev/null) || true
        run_state=$(systemctl is-active "$SVC" 2>/dev/null) || true
        echo "Auto mode: ${auto_state:-unknown} (${run_state:-unknown})"
        ;;
    *) usage; exit 1 ;;
esac
HSEOF
    chmod 755 /mnt/usr/local/bin/niix-hotspot

    # -- Hotspot keeper (auto mode) --------------------------------------
    # A root system service owns the logic: internet checked every 120s
    # (every 5s while offline) against two independent endpoints; hotspot
    # checked every 15s and restored once the internet is back. A
    # NetworkManager dispatcher hook wakes it instantly on network events.
    # Runs from boot (no login needed). Disabled until the user opts in
    # (first-boot prompt, or: niix-hotspot auto on).
    cat > /mnt/usr/local/bin/niix-hotspot-watchdog <<'HSWEOF'
#!/bin/bash
# niix-hotspot-watchdog - keeps the NiiX Wi-Fi hotspot up (system service, root).
#   Internet: HTTP checks of two independent endpoints - online if EITHER
#             answers correctly (a captive portal fails both). Every 120s
#             while online, every 5s while offline.
#   Hotspot:  checked every 15s, and immediately on NetworkManager events
#             (the dispatcher hook sends SIGUSR1). When it is found down the
#             internet is re-checked on the spot; if online, 'niix-hotspot up'
#             brings it back - which itself
#             refuses to take over Wi-Fi when Wi-Fi is the only internet.
#   While offline the hotspot is left as it is, so phones stay associated.
# Logs: journalctl -u niix-hotspot-watchdog -f
set -u

ONLINE_INTERVAL=120
OFFLINE_INTERVAL=5
HOTSPOT_INTERVAL=15

probe_gnome() {
    local body
    body=$(curl -fsS --connect-timeout 3 --max-time 5 \
        http://nmcheck.gnome.org/check_network_status.txt 2>/dev/null) || return 1
    [ "$body" = "NetworkManager is online" ]
}

probe_google() {
    local code
    code=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 5 \
        http://www.gstatic.com/generate_204 2>/dev/null) || return 1
    [ "$code" = "204" ]
}

internet_ok() {
    probe_gnome || probe_google
}

hotspot_active() {
    [ "$(nmcli -g GENERAL.STATE connection show niix-hotspot 2>/dev/null)" = "activated" ]
}

WAKE=0
SLEEP_PID=""
trap 'WAKE=1' USR1
trap '[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null; exit 0' TERM INT

# Interruptible sleep: SIGUSR1 (a NetworkManager event) ends it early.
nap() {
    sleep "$1" &
    SLEEP_PID=$!
    wait "$SLEEP_PID" 2>/dev/null
    kill "$SLEEP_PID" 2>/dev/null
    wait "$SLEEP_PID" 2>/dev/null
    SLEEP_PID=""
}

online=unknown
next_probe=0
last_probe=-1000
last_rc=""
echo "started: internet check every ${ONLINE_INTERVAL}s (every ${OFFLINE_INTERVAL}s while offline), hotspot every ${HOTSPOT_INTERVAL}s"

while true; do
    if [ "$WAKE" -eq 1 ]; then
        WAKE=0
        next_probe=0
    fi

    now=$(date +%s)
    hotspot_up=true
    hotspot_active || hotspot_up=false

    # Probe when due - and also right away whenever the hotspot is found down,
    # so the decision to bring it back never rests on a stale "online".
    if [ "$now" -ge "$next_probe" ] \
        || { [ "$hotspot_up" = false ] && [ $(( now - last_probe )) -ge "$OFFLINE_INTERVAL" ]; }; then
        last_probe=$now
        if internet_ok; then
            [ "$online" != true ] && echo "internet: online"
            online=true
            next_probe=$(( $(date +%s) + ONLINE_INTERVAL ))
        else
            [ "$online" != false ] && echo "internet: OFFLINE - re-checking every ${OFFLINE_INTERVAL}s"
            online=false
            next_probe=$(( $(date +%s) + OFFLINE_INTERVAL ))
        fi
    fi

    if [ "$online" = true ] && [ "$hotspot_up" = false ]; then
        niix-hotspot up >/dev/null 2>&1
        rc=$?
        if [ "$rc" -eq 0 ]; then
            echo "hotspot: brought up"
            last_rc=""
        elif [ "$rc" != "$last_rc" ]; then
            case "$rc" in
                2) echo "hotspot: waiting - Wi-Fi is the only internet connection (connect Ethernet or USB tethering)" ;;
                3) echo "hotspot: no saved hotspot - run 'niix-hotspot start' once" ;;
                *) echo "hotspot: failed to come up (rc=$rc) - retrying every ${HOTSPOT_INTERVAL}s" ;;
            esac
            last_rc="$rc"
        fi
    fi

    wait_s=$(( next_probe - $(date +%s) ))
    [ "$wait_s" -gt "$HOTSPOT_INTERVAL" ] && wait_s=$HOTSPOT_INTERVAL
    [ "$wait_s" -lt 1 ] && wait_s=1
    [ "$WAKE" -eq 1 ] || nap "$wait_s"
done
HSWEOF
    chmod 755 /mnt/usr/local/bin/niix-hotspot-watchdog

    cat > /mnt/etc/systemd/system/niix-hotspot-watchdog.service <<'HSUNITEOF'
[Unit]
Description=NiiX Wi-Fi hotspot keeper (kept up from boot, restored after internet outages)
Wants=NetworkManager.service
After=NetworkManager.service
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart=/usr/local/bin/niix-hotspot-watchdog
Restart=always
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
HSUNITEOF

    # NetworkManager only runs dispatcher scripts that are root-owned,
    # executable and not writable by group/others.
    mkdir -p /mnt/etc/NetworkManager/dispatcher.d
    cat > /mnt/etc/NetworkManager/dispatcher.d/90-niix-hotspot <<'HSDISPEOF'
#!/bin/sh
# Wake the hotspot keeper immediately on network changes instead of waiting
# for its next scheduled check. No-op when auto mode is off.
case "$2" in
    up|down|connectivity-change)
        systemctl kill --kill-whom=main --signal=USR1 niix-hotspot-watchdog.service >/dev/null 2>&1 || true
        ;;
esac
exit 0
HSDISPEOF
    chown root:root /mnt/etc/NetworkManager/dispatcher.d/90-niix-hotspot
    chmod 755 /mnt/etc/NetworkManager/dispatcher.d/90-niix-hotspot

    # 'niix-hotspot stop' pauses auto mode and 'start' resumes it: let the
    # active local wheel user start/stop/restart THIS one unit without a
    # password prompt (enable/disable still needs sudo).
    mkdir -p /mnt/etc/polkit-1/rules.d
    cat > /mnt/etc/polkit-1/rules.d/92-niix-hotspot.rules <<'HSPOLEOF'
polkit.addRule(function(action, subject) {
    if (action.id == "org.freedesktop.systemd1.manage-units" &&
        action.lookup("unit") == "niix-hotspot-watchdog.service" &&
        (action.lookup("verb") == "start" ||
         action.lookup("verb") == "stop" ||
         action.lookup("verb") == "restart") &&
        subject.local == true && subject.active == true &&
        subject.isInGroup("wheel")) {
        return polkit.Result.YES;
    }
});
HSPOLEOF
    log "niix-hotspot installed (run: niix-hotspot start; auto mode: niix-hotspot auto on)"

    # -- NetworkManager configuration ------------------------
    mkdir -p /mnt/etc/NetworkManager/conf.d

    # Use systemd-resolved for DNS - stops NM from fighting with dnsmasq
    # (dnsmasq is installed for hotspot support but must not be the global DNS)
    cat > /mnt/etc/NetworkManager/conf.d/10-dns.conf <<'NMEOF'
[main]
dns=systemd-resolved
systemd-resolved=true
NMEOF

    # Connectivity check - fixes the ? icon on WiFi, confirms internet is up
    cat > /mnt/etc/NetworkManager/conf.d/20-connectivity.conf <<'CONNEOF'
[connectivity]
uri=http://nmcheck.gnome.org/check_network_status.txt
response=NetworkManager is online
interval=300
CONNEOF

    # WiFi MAC randomization - scoped to WiFi only so Ethernet stays stable
    cat > /mnt/etc/NetworkManager/conf.d/30-wifi-privacy.conf <<'MACEOF'
[device]
match-device=type:wifi
wifi.scan-rand-mac-address=yes

[connection]
match-device=type:wifi
wifi.cloned-mac-address=random
connection.stable-id=${CONNECTION}/${BOOT}
MACEOF

    # Ethernet stability - prevents NM from aborting DHCP during link negotiation
    cat > /mnt/etc/NetworkManager/conf.d/40-ethernet.conf <<'ETHEOF'
[device]
match-device=type:ethernet
ethernet.wake-on-lan=ignore
ETHEOF

    # -- Import WiFi credentials from live ISO (iwd -> NM keyfile) ----------
    # The Arch live ISO uses iwd for WiFi. If the user connected via iwctl,
    # their credentials are in /var/lib/iwd/<SSID>.psk on the live system.
    # We convert them to NM keyfiles so the installed system auto-connects
    # on first boot - without ever installing iwd (it conflicts with wpa_supplicant).
    # Safe to skip: if no .psk files exist (ethernet-only install), nothing happens.
    mkdir -p /mnt/etc/NetworkManager/system-connections

    IWD_DIR="/var/lib/iwd"
    if compgen -G "${IWD_DIR}/*.psk" > /dev/null 2>&1; then
        info "Found iwd WiFi profiles - importing into NetworkManager..."
        local imported=0
        for psk_file in "${IWD_DIR}"/*.psk; do
            [[ -f "$psk_file" ]] || continue

            # iwd names the file after the SSID only when it is letters,
            # digits, space, '-' or '_'; any other SSID is stored as
            # "=<hex of the raw bytes>". Decode both forms, and write the SSID
            # into the NM keyfile as a byte list, which NM always reads back
            # exactly - no escaping rules for quotes, ';' or accents.
            local base ssid_info ssid_bytes ssid_label
            base=$(basename "$psk_file" .psk)
            if ! ssid_info=$(python3 - "$base" <<'SSIDPY'
import sys
base = sys.argv[1]
try:
    raw = bytes.fromhex(base[1:]) if base.startswith("=") else base.encode("utf-8")
except ValueError:
    sys.exit(1)
if not 1 <= len(raw) <= 32:
    sys.exit(1)
print(";".join(str(b) for b in raw) + ";")
label = "".join(c if (c.isalnum() or c in " -_.") else "_" for c in raw.decode("utf-8", "replace")).strip(" .")
print(label or "wifi")
SSIDPY
            ); then
                warn "iwd profile '$base' has an unreadable network name - skipping"
                continue
            fi
            ssid_bytes=${ssid_info%%$'\n'*}
            ssid_label=${ssid_info#*$'\n'}

            # Read the credential - iwd uses either PreSharedKey= (64-char hex)
            # or Passphrase= (human-readable). NM accepts both in psk=. The
            # value is copied verbatim: iwd and NM keyfiles escape the same way.
            local psk_value=""
            local psk_line
            psk_line=$(grep -m1 "^PreSharedKey=" "$psk_file" 2>/dev/null || true)
            if [[ -n "$psk_line" ]]; then
                psk_value="${psk_line#PreSharedKey=}"
            else
                local pass_line
                pass_line=$(grep -m1 "^Passphrase=" "$psk_file" 2>/dev/null || true)
                [[ -n "$pass_line" ]] && psk_value="${pass_line#Passphrase=}"
            fi

            if [[ -z "$psk_value" ]]; then
                warn "iwd profile '$ssid_label' has no PSK or Passphrase - skipping"
                continue
            fi

            # File name: the readable label, made unique.
            local nm_file n=1
            nm_file="/mnt/etc/NetworkManager/system-connections/${ssid_label}.nmconnection"
            while [[ -e "$nm_file" ]]; do
                n=$(( n + 1 ))
                nm_file="/mnt/etc/NetworkManager/system-connections/${ssid_label}-${n}.nmconnection"
            done

            # umask 077: the file holds the Wi-Fi password and must never
            # exist world-readable, not even for a moment. NM also ignores
            # keyfiles that are not 0600 root-owned.
            (
                umask 077
                cat > "$nm_file" <<NMWIFI
[connection]
id=${ssid_label}
type=wifi
autoconnect=true

[wifi]
ssid=${ssid_bytes}
mode=infrastructure

[wifi-security]
key-mgmt=wpa-psk
psk=${psk_value}

[ipv4]
method=auto

[ipv6]
method=auto
addr-gen-mode=stable-privacy
NMWIFI
            )
            chmod 600 "$nm_file"

            log "WiFi imported: '$ssid_label' -> $(basename "$nm_file")"
            imported=$(( imported + 1 ))
        done
        [[ "$imported" -eq 0 ]] && warn "iwd profiles found but none could be imported"             || log "$imported WiFi profile(s) imported - will auto-connect on first boot"
    else
        info "No iwd WiFi profiles found (ethernet or WiFi not used during install) - skipping"
    fi

    # -- Also copy any live NetworkManager connections to the target ------
    # The NiiX live ISO uses NetworkManager, so WiFi joined during pre-flight
    # is stored here rather than in iwd. Copy the profiles across so the
    # installed system auto-connects on first boot too.
    if compgen -G "/etc/NetworkManager/system-connections/*.nmconnection" > /dev/null 2>&1; then
        info "Copying live NetworkManager connections to the target..."
        local nmcopied=0 src base dst
        for src in /etc/NetworkManager/system-connections/*.nmconnection; do
            [ -f "$src" ] || continue
            base=$(basename "$src")
            dst="/mnt/etc/NetworkManager/system-connections/$base"
            [ -e "$dst" ] && continue
            if cp "$src" "$dst" 2>/dev/null; then
                chmod 600 "$dst"
                nmcopied=$(( nmcopied + 1 ))
            fi
        done
        [ "$nmcopied" -gt 0 ] \
            && log "$nmcopied NetworkManager connection(s) copied - target will auto-connect" \
            || info "No new NetworkManager connections to copy"
    fi

    # -- systemd-resolved DNS config -------------------------
    # Keep it simple - no DNSSEC/DoT which can silently break resolution
    # on many home/corporate networks. Cloudflare as primary, Quad9 fallback.
    mkdir -p /mnt/etc/systemd/resolved.conf.d
    cat > /mnt/etc/systemd/resolved.conf.d/dns.conf <<'DNSEOF'
[Resolve]
DNS=1.1.1.1 1.0.0.1
FallbackDNS=9.9.9.9 149.112.112.112
DNSSEC=no
DNSOverTLS=no
DNSEOF

    # (LAVD scheduler set up during first-boot with CachyOS kernel)

    # -- ntsync module ----------------------------------------
    mkdir -p /mnt/etc/modules-load.d
    echo "ntsync" > /mnt/etc/modules-load.d/ntsync.conf

    # -- Auto-update timer ------------------------------------
    # Skip this run entirely if a game/launcher process looks active - an
    # unattended pacman -Syu mid-session can replace libs/binaries out from
    # under a running game. ExecStartPre failing just skips this cycle; the
    # timer tries again at the next 12h interval.
    cat > /mnt/usr/local/bin/niix-update-gate <<'GATEEOF'
#!/bin/bash
for p in steam steamwebhelper lutris heroic wine64-preloader wineserver \
         retroarch gamescope; do
    if pgrep -ix "$p" >/dev/null 2>&1; then
        echo "NiiX update gate: '$p' is running - skipping this update cycle"
        exit 1
    fi
done
exit 0
GATEEOF
    chmod +x /mnt/usr/local/bin/niix-update-gate

    cat > /mnt/etc/systemd/system/niixarch-update.service <<'UPDEOF'
[Unit]
Description=NiiX Arch System Update
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStartPre=/usr/local/bin/niix-update-gate
ExecStart=/usr/bin/pacman -Syu --noconfirm
UPDEOF

    cat > /mnt/etc/systemd/system/niixarch-update.timer <<'TMREOF'
[Unit]
Description=NiiX Arch Update Timer (every 12h)

[Timer]
OnBootSec=15min
OnUnitActiveSec=12h
Persistent=true
RandomizedDelaySec=30min

[Install]
WantedBy=timers.target
TMREOF

    # -- CoreCtrl polkit (AMD only) ---------------------------
    if [[ "$GPU_VENDOR" == "amd" ]]; then
        mkdir -p /mnt/etc/polkit-1/rules.d
        cat > /mnt/etc/polkit-1/rules.d/90-corectrl.rules <<'POLEOF'
polkit.addRule(function(action, subject) {
    if ((action.id == "org.corectrl.helper.init" ||
         action.id == "org.corectrl.helperkiller.init") &&
        subject.local == true && subject.active == true &&
        subject.isInGroup("wheel")) { return polkit.Result.YES; }
});
POLEOF
    fi

    # -- NetworkManager polkit (hotspot) -----------------------
    # org.freedesktop.NetworkManager.settings.modify.system defaults to
    # auth_admin_keep, which pops an interactive polkit prompt. niix-hotspot
    # creates/activates a system-wide connection, and the watchdog service
    # runs headless with no polkit agent to answer that prompt - without this
    # rule it would silently fail every time (nmcli errors get swallowed by
    # the watchdog's >/dev/null 2>&1). Scoped to the active local wheel user.
    mkdir -p /mnt/etc/polkit-1/rules.d
    cat > /mnt/etc/polkit-1/rules.d/91-niix-networkmanager.rules <<'POLEOF'
polkit.addRule(function(action, subject) {
    if (action.id.indexOf("org.freedesktop.NetworkManager.") == 0 &&
        subject.local == true && subject.active == true &&
        subject.isInGroup("wheel")) { return polkit.Result.YES; }
});
POLEOF

    # -- Configure + enable services in chroot ----------------
    # Quoted heredoc: nothing in the body is expanded by THIS shell, so a '$' or
    # backtick added later can never be evaluated at the wrong layer. Values are
    # passed in explicitly through the environment instead.
    log "Configuring system in chroot..."
    arch-chroot /mnt env \
        NX_TIMEZONE="$TIMEZONE" NX_LOCALE="$LOCALE" \
        NX_KEYMAP="$KEYMAP" NX_HOSTNAME="$HOSTNAME" \
        bash <<'CHROOT'
set -e

ln -sf "/usr/share/zoneinfo/${NX_TIMEZONE}" /etc/localtime
hwclock --systohc

grep -qxF "${NX_LOCALE} UTF-8" /etc/locale.gen || echo "${NX_LOCALE} UTF-8" >> /etc/locale.gen
grep -qxF "en_US.UTF-8 UTF-8" /etc/locale.gen || echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
locale-gen
echo "LANG=${NX_LOCALE}" > /etc/locale.conf

echo "KEYMAP=${NX_KEYMAP}" > /etc/vconsole.conf

echo "${NX_HOSTNAME}" > /etc/hostname
cat > /etc/hosts <<EOF
127.0.0.1   localhost
::1         localhost
127.0.1.1   ${NX_HOSTNAME}.localdomain ${NX_HOSTNAME}
EOF

# Grant the wheel group sudo via a drop-in (safer than editing /etc/sudoers in
# place) and validate it with visudo before it can take effect.
install -d -m 750 /etc/sudoers.d
printf '%%wheel ALL=(ALL:ALL) ALL\n' > /etc/sudoers.d/10-wheel
chmod 440 /etc/sudoers.d/10-wheel
visudo -cf /etc/sudoers.d/10-wheel \
    || { echo "FATAL: generated sudoers drop-in failed validation"; rm -f /etc/sudoers.d/10-wheel; exit 1; }

# -- Critical networking services - these MUST succeed ----
# Placed first, before set -e can be triggered by anything else.
# If these fail the system will have no network after boot.
# NOTE: wpa_supplicant.service is intentionally NOT enabled here - NM spawns
# its own per-interface wpa_supplicant instance, and a system-wide service
# competing for the same interface causes "supplicant interface failed"
# conflicts. The wpa_supplicant package is still installed (NM links against
# it), it just shouldn't be a separately-enabled unit.
systemctl enable NetworkManager
systemctl enable systemd-resolved

# -- resolv.conf -> systemd-resolved stub ------------------
# rm -f first - ln -sf will NOT overwrite a regular file on all kernels.
rm -f /etc/resolv.conf
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

# -- Bluetooth - BlueZ daemon configuration ----------------
# Experimental=true  -> battery percentage of headsets shows up in GNOME.
# ControllerMode=dual -> classic + LE on the same adapter (LE Audio headsets).
# FastConnectable    -> quicker reconnect when a paired headset powers on.
# AutoEnable         -> adapter is powered at boot; no "turn BT on every boot".
cat > /etc/bluetooth/main.conf <<'BTMAINEOF'
[General]
ControllerMode = dual
FastConnectable = true
Experimental = true

[Policy]
AutoEnable = true
BTMAINEOF

# -- Optional services - || true so a missing package can't kill the chroot --
systemctl enable ananicy-cpp           2>/dev/null || true
systemctl enable irqbalance            2>/dev/null || true
systemctl enable ufw                   2>/dev/null || true
systemctl enable power-profiles-daemon 2>/dev/null || true
systemctl enable niixarch-update.timer 2>/dev/null || true
systemctl enable bluetooth             2>/dev/null || true
systemctl enable iio-sensor-proxy      2>/dev/null || true
systemctl enable fstrim.timer          2>/dev/null || true
# Runs /etc/NetworkManager/dispatcher.d hooks (wakes the hotspot keeper on network events)
systemctl enable NetworkManager-dispatcher.service 2>/dev/null || true
systemctl disable NetworkManager-wait-online.service 2>/dev/null || true
# Mask the standalone dnsmasq service: NetworkManager runs its OWN per-interface
# dnsmasq for the hotspot. A system-wide dnsmasq on port 53 would conflict and
# break the hotspot's DHCP/DNS. The binary stays installed for NM to use.
systemctl mask dnsmasq                  2>/dev/null || true

# -- Snapper - Btrfs snapshot management ------------------
# snap-pac auto-creates pre/post snapshots on every pacman transaction.
# Timeline disabled - only pacman snapshots, last 10 kept.
mkdir -p /etc/snapper/configs
cat > /etc/snapper/configs/root <<'SNAPEOF'
SUBVOLUME="/"
FSTYPE="btrfs"
ALLOW_USERS=""
ALLOW_GROUPS=""
SYNC_ACL="no"
BACKGROUND_COMPARISON="yes"
NUMBER_CLEANUP="yes"
NUMBER_MIN_AGE="1800"
NUMBER_LIMIT="10"
NUMBER_LIMIT_IMPORTANT="5"
TIMELINE_CREATE="no"
TIMELINE_CLEANUP="no"
EMPTY_PRE_POST_CLEANUP="yes"
EMPTY_PRE_POST_MIN_AGE="1800"
SNAPEOF
# Register the config - on Arch, snapper/snap-pac/snapper-cleanup only act on
# configs listed in SNAPPER_CONFIGS (/etc/conf.d/snapper). A hand-written config
# file alone is invisible to them, so pacman snapshots would never be taken and
# cleanup would never run.
mkdir -p /etc/conf.d
if grep -q '^SNAPPER_CONFIGS=' /etc/conf.d/snapper 2>/dev/null; then
    sed -i 's/^SNAPPER_CONFIGS=.*/SNAPPER_CONFIGS="root"/' /etc/conf.d/snapper
else
    echo 'SNAPPER_CONFIGS="root"' >> /etc/conf.d/snapper
fi
chmod 750 /.snapshots
systemctl enable snapper-cleanup.timer 2>/dev/null || true
# Create the initial clean-install snapshot (pre-firstboot baseline)
snapper --no-dbus -c root create --description "NiiX Arch - clean install" 2>/dev/null || true
CHROOT
    local chroot_rc=$?
    [[ "$chroot_rc" -eq 0 ]] \
        || err "chroot system configuration failed (exit $chroot_rc) - check the log"

    # Hard verification of the one thing a silent failure would hide longest:
    # a system without NetworkManager enabled has no network after first boot.
    [[ -L /mnt/etc/systemd/system/multi-user.target.wants/NetworkManager.service ]] \
        || err "chroot configuration failed - NetworkManager was not enabled. Check the log."

    log "LUKS UUID: $LUKS_UUID"

    # Set passwords and create user (passwords only ever travel over stdin)
    log "Setting passwords..."
    printf '%s\n' "root:${USER_PASS}" | arch-chroot /mnt chpasswd \
        || err "Could not set the root password"
    arch-chroot /mnt useradd -m -G wheel,audio,video,storage,optical \
        -s /bin/bash "${USERNAME}" \
        || err "Could not create user '${USERNAME}'"
    printf '%s\n' "${USERNAME}:${USER_PASS}" | arch-chroot /mnt chpasswd \
        || err "Could not set the password for '${USERNAME}'"

    log "System configured"
}

# -- NiiX logo ------------------------------------------------
# A normal PNG file in the project's GitHub repo - nothing is encoded in this
# script. Purely cosmetic (About page, ArcMenu button): if it cannot be
# fetched, or what arrives is not a PNG, the install carries on without it.
NIIX_LOGO_URL="https://raw.githubusercontent.com/techniixdotcom/niixarch-install/main/niixarch-logo.png"

write_logo() {
    local pixmaps="/mnt/usr/share/pixmaps" brand="/mnt/etc/niixarch" tmp
    mkdir -p "$pixmaps" "$brand"
    tmp=$(mktemp) || { warn "Could not create a temporary file - branding logo skipped"; return 0; }
    if curl -fsSL --retry 3 --max-time 60 --max-filesize 5242880 "$NIIX_LOGO_URL" -o "$tmp" \
        && [[ "$(head -c 8 "$tmp" | od -An -tx1 | tr -d ' \n')" == "89504e470d0a1a0a" ]]; then
        install -m 644 "$tmp" "$pixmaps/niixarch-logo.png"
        install -m 644 "$tmp" "$brand/niixarch-logo.png"
        # GNOME Settings "About" reads the distributor logo named after os-release LOGO=
        install -m 644 "$tmp" "$pixmaps/niixarch.png"
        log "NiiX logo installed (/usr/share/pixmaps/niixarch-logo.png)"
    else
        warn "Could not download the NiiX logo from GitHub - branding logo skipped (nothing else is affected)"
    fi
    rm -f "$tmp"
}

# -- NiiX icon theme ------------------------------------------
# Written out from the readable tables below: every icon is the same dark
# rounded tile with one line drawing in teal or pink, so the whole theme is
# just "which drawing, which colour" per icon file.
write_icons() {
    local dest="/mnt/usr/share/icons/NiiX" count
    rm -rf "$dest"
    if count=$(NIIX_ICON_DEST="$dest" python3 - <<'NIIX_ICONS_PY'
import os

DEST = os.environ["NIIX_ICON_DEST"]
TEAL = "#5eead4"
PINK = "#f0a6c0"

INDEX_THEME = """[Icon Theme]
Name=NiiX
Comment=NiiX Arch flat icon theme (Tabler-glyph squircles, teal + pink accents)
Inherits=Adwaita,hicolor
Directories=scalable/apps,scalable/places,scalable/mimetypes

[scalable/apps]
Context=Applications
Size=256
MinSize=8
MaxSize=512
Type=Scalable

[scalable/places]
Context=Places
Size=256
MinSize=8
MaxSize=512
Type=Scalable

[scalable/mimetypes]
Context=MimeTypes
Size=256
MinSize=8
MaxSize=512
Type=Scalable
"""

# Every icon is the same dark rounded tile with one line drawing on it.
HEAD = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">\n'
    '  <rect x="14" y="14" width="228" height="228" rx="58" fill="#121821" stroke="#1f2a33" stroke-width="2"/>\n'
    '  <g transform="translate(64,64) scale(5.3333)" fill="none" stroke="'
)
MID = '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
TAIL = '</g>\n</svg>\n'

# The line drawings (Tabler Icons glyphs, MIT licence), one SVG path per line.
GLYPHS = {
    "activity": [
        '<path d="M3 12h4l3 8l4 -16l3 8h4" />',
    ],
    "app-window": [
        '<path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10" />',
        '<path d="M6 8h.01" />',
        '<path d="M9 8h.01" />',
    ],
    "apps": [
        '<path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />',
        '<path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />',
        '<path d="M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />',
        '<path d="M14 7l6 0" />',
        '<path d="M17 4l0 6" />',
    ],
    "battery": [
        '<path d="M6 7h11a2 2 0 0 1 2 2v.5a.5 .5 0 0 0 .5 .5a.5 .5 0 0 1 .5 .5v3a.5 .5 0 0 1 -.5 .5a.5 .5 0 0 0 -.5 .5v.5a2 2 0 0 1 -2 2h-11a2 2 0 0 1 -2 -2v-6a2 2 0 0 1 2 -2" />',
    ],
    "book": [
        '<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />',
        '<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />',
        '<path d="M3 6l0 13" />',
        '<path d="M12 6l0 13" />',
        '<path d="M21 6l0 13" />',
    ],
    "box": [
        '<path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" />',
        '<path d="M12 12l8 -4.5" />',
        '<path d="M12 12l0 9" />',
        '<path d="M12 12l-8 -4.5" />',
    ],
    "calculator": [
        '<path d="M4 5a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -14" />',
        '<path d="M8 8a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v1a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1l0 -1" />',
        '<path d="M8 14l0 .01" />',
        '<path d="M12 14l0 .01" />',
        '<path d="M16 14l0 .01" />',
        '<path d="M8 17l0 .01" />',
        '<path d="M12 17l0 .01" />',
        '<path d="M16 17l0 .01" />',
    ],
    "calendar": [
        '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12" />',
        '<path d="M16 3v4" />',
        '<path d="M8 3v4" />',
        '<path d="M4 11h16" />',
        '<path d="M11 15h1" />',
        '<path d="M12 15v3" />',
    ],
    "camera": [
        '<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />',
        '<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
    ],
    "characters": [
        '<path d="M14 15.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0" />',
        '<path d="M3 19v-10.5a3.5 3.5 0 0 1 7 0v10.5" />',
        '<path d="M3 13h7" />',
        '<path d="M21 12v7" />',
    ],
    "clock": [
        '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />',
        '<path d="M12 7v5l3 3" />',
    ],
    "cloud": [
        '<path d="M6.657 18c-2.572 0 -4.657 -2.007 -4.657 -4.483c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 1.927 -1.551 3.487 -3.465 3.487h-11.878" />',
    ],
    "contacts": [
        '<path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />',
        '<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />',
        '<path d="M16 3.13a4 4 0 0 1 0 7.75" />',
        '<path d="M21 21v-2a4 4 0 0 0 -3 -3.85" />',
    ],
    "desktop": [
        '<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10" />',
        '<path d="M7 20h10" />',
        '<path d="M9 16v4" />',
        '<path d="M15 16v4" />',
    ],
    "device-gamepad-2": [
        '<path d="M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4" />',
        '<path d="M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232" />',
        '<path d="M8 9v2" />',
        '<path d="M7 10h2" />',
        '<path d="M14 10h2" />',
    ],
    "disc": [
        '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />',
        '<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />',
        '<path d="M7 12a5 5 0 0 1 5 -5" />',
        '<path d="M12 17a5 5 0 0 0 5 -5" />',
    ],
    "disk-usage": [
        '<path d="M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-7a.9 .9 0 0 0 -1 -.8" />',
        '<path d="M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5" />',
    ],
    "download": [
        '<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />',
        '<path d="M7 11l5 5l5 -5" />',
        '<path d="M12 4l0 12" />',
    ],
    "extensions-dev": [
        '<path d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />',
    ],
    "file-text": [
        '<path d="M14 3v4a1 1 0 0 0 1 1h4" />',
        '<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />',
        '<path d="M9 9l1 0" />',
        '<path d="M9 13l6 0" />',
        '<path d="M9 17l6 0" />',
    ],
    "folder": [
        '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />',
    ],
    "fonts": [
        '<path d="M4 20l3 0" />',
        '<path d="M14 20l7 0" />',
        '<path d="M6.9 15l6.9 0" />',
        '<path d="M10.2 6.3l5.8 13.7" />',
        '<path d="M5 20l6 -16l2 0l7 16" />',
    ],
    "gpu-fan": [
        '<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1l0 -10" />',
        '<path d="M7 20h10" />',
        '<path d="M9 16v4" />',
        '<path d="M15 16v4" />',
        '<path d="M9 12v-4" />',
        '<path d="M12 12v-1" />',
        '<path d="M15 12v-2" />',
        '<path d="M12 12v-1" />',
    ],
    "gpu-tune": [
        '<path d="M5 6a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -12" />',
        '<path d="M9 9h6v6h-6l0 -6" />',
        '<path d="M3 10h2" />',
        '<path d="M3 14h2" />',
        '<path d="M10 3v2" />',
        '<path d="M14 3v2" />',
        '<path d="M21 10h-2" />',
        '<path d="M21 14h-2" />',
        '<path d="M14 21v-2" />',
        '<path d="M10 21v-2" />',
    ],
    "heroic": [
        '<path d="M9 12l-3 -3h-2a1 1 0 0 0 -1 1v4a1 1 0 0 0 1 1h2l3 -3" />',
        '<path d="M15 12l3 -3h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2l-3 -3" />',
        '<path d="M12 15l-3 3v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1 -1v-2l-3 -3" />',
        '<path d="M12 9l-3 -3v-2a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v2l-3 3" />',
    ],
    "home": [
        '<path d="M5 12l-2 0l9 -9l9 9l-2 0" />',
        '<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />',
        '<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />',
    ],
    "key": [
        '<path d="M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0" />',
        '<path d="M15 9h.01" />',
    ],
    "logs": [
        '<path d="M13 5h8" />',
        '<path d="M13 9h5" />',
        '<path d="M13 15h8" />',
        '<path d="M13 19h5" />',
        '<path d="M3 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />',
        '<path d="M3 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />',
    ],
    "lutris": [
        '<path d="M2 8a2 2 0 0 1 2 -2h16a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2l0 -8" />',
        '<path d="M6 12h4m-2 -2v4" />',
        '<path d="M15 11l0 .01" />',
        '<path d="M18 13l0 .01" />',
    ],
    "map-2": [
        '<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />',
        '<path d="M9 4v13" />',
        '<path d="M15 7v5.5" />',
        '<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />',
        '<path d="M19 18v.01" />',
    ],
    "movie": [
        '<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12" />',
        '<path d="M8 4l0 16" />',
        '<path d="M16 4l0 16" />',
        '<path d="M4 8l4 0" />',
        '<path d="M4 16l4 0" />',
        '<path d="M4 12l16 0" />',
        '<path d="M16 8l4 0" />',
        '<path d="M16 16l4 0" />',
    ],
    "music": [
        '<path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
        '<path d="M13 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
        '<path d="M9 17v-13h10v13" />',
        '<path d="M9 8h10" />',
    ],
    "nvidia": [
        '<path d="M13.256 20.473c-.855 .907 -2.583 .643 -2.931 -.79a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.07 .26 1.488 1.29 1.254 2.15" />',
        '<path d="M19 16l-2 3h4l-2 3" />',
        '<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
    ],
    "photo": [
        '<path d="M15 8h.01" />',
        '<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12" />',
        '<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" />',
        '<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" />',
    ],
    "proton-up": [
        '<path d="M9 12h-3.586a1 1 0 0 1 -.707 -1.707l6.586 -6.586a1 1 0 0 1 1.414 0l6.586 6.586a1 1 0 0 1 -.707 1.707h-3.586v3h-6v-3" />',
        '<path d="M9 21h6" />',
        '<path d="M9 18h6" />',
    ],
    "remote-desktop": [
        '<path d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5" />',
        '<path d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5" />',
        '<path d="M3 21l2.5 -2.5" />',
        '<path d="M18.5 5.5l2.5 -2.5" />',
        '<path d="M10 11l-2 2" />',
        '<path d="M13 14l-2 2" />',
    ],
    "scanner": [
        '<path d="M5 12h14" />',
        '<path d="M3 7v-2a2 2 0 0 1 2 -2h2" />',
        '<path d="M3 17v2a2 2 0 0 0 2 2h2" />',
        '<path d="M17 3h2a2 2 0 0 1 2 2v2" />',
        '<path d="M17 21h2a2 2 0 0 0 2 -2v-2" />',
    ],
    "server-2": [
        '<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2" />',
        '<path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" />',
        '<path d="M7 8l0 .01" />',
        '<path d="M7 16l0 .01" />',
        '<path d="M11 8h6" />',
        '<path d="M11 16h6" />',
    ],
    "settings": [
        '<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" />',
        '<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
    ],
    "templates": [
        '<path d="M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2" />',
        '<path d="M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6" />',
        '<path d="M14 12l6 0" />',
        '<path d="M14 16l6 0" />',
        '<path d="M14 20l6 0" />',
    ],
    "terminal-2": [
        '<path d="M8 9l3 3l-3 3" />',
        '<path d="M13 15l3 0" />',
        '<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -12" />',
    ],
    "trash-empty": [
        '<path d="M4 7l16 0" />',
        '<path d="M10 11l0 6" />',
        '<path d="M14 11l0 6" />',
        '<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />',
        '<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />',
    ],
    "tweaks": [
        '<path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />',
        '<path d="M6 4v4" />',
        '<path d="M6 12v8" />',
        '<path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />',
        '<path d="M12 4v10" />',
        '<path d="M12 18v2" />',
        '<path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />',
        '<path d="M18 4v1" />',
        '<path d="M18 9v11" />',
    ],
    "usage": [
        '<path d="M4 19l16 0" />',
        '<path d="M4 15l4 -6l4 2l4 -5l4 4" />',
    ],
    "world": [
        '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />',
        '<path d="M3.6 9h16.8" />',
        '<path d="M3.6 15h16.8" />',
        '<path d="M11.5 3a17 17 0 0 0 0 18" />',
        '<path d="M12.5 3a17 17 0 0 1 0 18" />',
    ],
}

# Icon file (under scalable/), drawing, colour.
ICONS = [
    ("apps/accessories-calculator.svg", "calculator", TEAL),
    ("apps/accessories-text-editor.svg", "file-text", TEAL),
    ("apps/applets-screenshooter.svg", "camera", TEAL),
    ("apps/application-default-icon.svg", "app-window", TEAL),
    ("apps/application-x-addon.svg", "app-window", TEAL),
    ("apps/application-x-executable.svg", "app-window", TEAL),
    ("apps/applications-other.svg", "app-window", TEAL),
    ("apps/baobab.svg", "disk-usage", TEAL),
    ("apps/brasero.svg", "disc", TEAL),
    ("apps/com.heroicgameslauncher.hgl.svg", "heroic", PINK),
    ("apps/com.leinardi.gwe.svg", "gpu-fan", TEAL),
    ("apps/com.stremio.Stremio.svg", "movie", PINK),
    ("apps/corectrl.svg", "gpu-tune", TEAL),
    ("apps/drive-harddisk.svg", "server-2", TEAL),
    ("apps/eog.svg", "photo", PINK),
    ("apps/evince.svg", "book", TEAL),
    ("apps/exec.svg", "app-window", TEAL),
    ("apps/folder.svg", "folder", TEAL),
    ("apps/gedit.svg", "file-text", TEAL),
    ("apps/gnome-calculator.svg", "calculator", TEAL),
    ("apps/gnome-calendar.svg", "calendar", TEAL),
    ("apps/gnome-control-center.svg", "settings", TEAL),
    ("apps/gnome-disks.svg", "server-2", TEAL),
    ("apps/gnome-other.svg", "app-window", TEAL),
    ("apps/gnome-power-statistics.svg", "battery", TEAL),
    ("apps/gnome-software.svg", "apps", TEAL),
    ("apps/gnome-system-monitor.svg", "activity", TEAL),
    ("apps/gnome-terminal.svg", "terminal-2", TEAL),
    ("apps/gnome-tweaks.svg", "tweaks", TEAL),
    ("apps/gwe.svg", "gpu-fan", TEAL),
    ("apps/helium-browser.svg", "world", PINK),
    ("apps/helium.svg", "world", PINK),
    ("apps/heroic.svg", "heroic", PINK),
    ("apps/image-viewer.svg", "photo", PINK),
    ("apps/inode-directory.svg", "folder", TEAL),
    ("apps/internet-web-browser.svg", "world", PINK),
    ("apps/lutris.svg", "lutris", PINK),
    ("apps/multimedia-audio-player.svg", "music", PINK),
    ("apps/multimedia-photo-viewer.svg", "photo", PINK),
    ("apps/multimedia-video-player.svg", "movie", PINK),
    ("apps/nautilus.svg", "folder", TEAL),
    ("apps/net.davidotek.pupgui2.svg", "proton-up", PINK),
    ("apps/net.lutris.Lutris.svg", "lutris", PINK),
    ("apps/nvidia-settings.svg", "nvidia", TEAL),
    ("apps/org.corectrl.CoreCtrl.svg", "gpu-tune", TEAL),
    ("apps/org.corectrl.corectrl.svg", "gpu-tune", TEAL),
    ("apps/org.gnome.Boxes.svg", "box", TEAL),
    ("apps/org.gnome.Calculator.svg", "calculator", TEAL),
    ("apps/org.gnome.Calendar.Devel.svg", "calendar", TEAL),
    ("apps/org.gnome.Calendar.svg", "calendar", TEAL),
    ("apps/org.gnome.Characters.svg", "characters", TEAL),
    ("apps/org.gnome.Clocks.svg", "clock", TEAL),
    ("apps/org.gnome.Connections.svg", "remote-desktop", TEAL),
    ("apps/org.gnome.Console.Devel.svg", "terminal-2", TEAL),
    ("apps/org.gnome.Console.svg", "terminal-2", TEAL),
    ("apps/org.gnome.Contacts.svg", "contacts", TEAL),
    ("apps/org.gnome.Decibels.svg", "music", PINK),
    ("apps/org.gnome.DiskUsageAnalyzer.svg", "disk-usage", TEAL),
    ("apps/org.gnome.DiskUtility.svg", "server-2", TEAL),
    ("apps/org.gnome.Disks.svg", "server-2", TEAL),
    ("apps/org.gnome.Documents.svg", "book", TEAL),
    ("apps/org.gnome.Epiphany.svg", "world", PINK),
    ("apps/org.gnome.Evince.svg", "book", TEAL),
    ("apps/org.gnome.Extensions.Devel.svg", "extensions-dev", TEAL),
    ("apps/org.gnome.Extensions.svg", "extensions-dev", TEAL),
    ("apps/org.gnome.Files.svg", "folder", TEAL),
    ("apps/org.gnome.FontViewer.svg", "fonts", TEAL),
    ("apps/org.gnome.Image-Viewer.svg", "photo", PINK),
    ("apps/org.gnome.Logs.svg", "logs", TEAL),
    ("apps/org.gnome.Loupe.svg", "photo", PINK),
    ("apps/org.gnome.Maps.svg", "map-2", TEAL),
    ("apps/org.gnome.MultiWriter.svg", "disc", TEAL),
    ("apps/org.gnome.Music.svg", "music", PINK),
    ("apps/org.gnome.Nautilus.svg", "folder", TEAL),
    ("apps/org.gnome.Papers.svg", "book", TEAL),
    ("apps/org.gnome.PowerStats.svg", "battery", TEAL),
    ("apps/org.gnome.Screenshot.svg", "camera", TEAL),
    ("apps/org.gnome.Settings.svg", "settings", TEAL),
    ("apps/org.gnome.SimpleScan.svg", "scanner", TEAL),
    ("apps/org.gnome.Snapshot.svg", "camera", TEAL),
    ("apps/org.gnome.Software.svg", "apps", TEAL),
    ("apps/org.gnome.SystemMonitor.Devel.svg", "activity", TEAL),
    ("apps/org.gnome.SystemMonitor.svg", "activity", TEAL),
    ("apps/org.gnome.Terminal.svg", "terminal-2", TEAL),
    ("apps/org.gnome.TextEditor.svg", "file-text", TEAL),
    ("apps/org.gnome.Totem.svg", "movie", PINK),
    ("apps/org.gnome.Tweaks.svg", "tweaks", TEAL),
    ("apps/org.gnome.Usage.svg", "usage", TEAL),
    ("apps/org.gnome.Videos.svg", "movie", PINK),
    ("apps/org.gnome.Weather.svg", "cloud", TEAL),
    ("apps/org.gnome.Web.svg", "world", PINK),
    ("apps/org.gnome.baobab.svg", "disk-usage", TEAL),
    ("apps/org.gnome.clocks.svg", "clock", TEAL),
    ("apps/org.gnome.eog.svg", "photo", PINK),
    ("apps/org.gnome.font-viewer.svg", "fonts", TEAL),
    ("apps/org.gnome.gedit.svg", "file-text", TEAL),
    ("apps/org.gnome.seahorse.Application.svg", "key", TEAL),
    ("apps/org.gnome.tweaks.svg", "tweaks", TEAL),
    ("apps/preferences-desktop-font.svg", "fonts", TEAL),
    ("apps/preferences-desktop.svg", "settings", TEAL),
    ("apps/preferences-other.svg", "tweaks", TEAL),
    ("apps/preferences-system.svg", "settings", TEAL),
    ("apps/protonup-qt.svg", "proton-up", PINK),
    ("apps/rhythmbox.svg", "music", PINK),
    ("apps/seahorse.svg", "key", TEAL),
    ("apps/simple-scan.svg", "scanner", TEAL),
    ("apps/software-properties-gtk.svg", "apps", TEAL),
    ("apps/software-properties.svg", "apps", TEAL),
    ("apps/steam-native.svg", "device-gamepad-2", PINK),
    ("apps/steam.svg", "device-gamepad-2", PINK),
    ("apps/stremio.svg", "movie", PINK),
    ("apps/system-file-manager.svg", "folder", TEAL),
    ("apps/system-software-install.svg", "apps", TEAL),
    ("apps/terminal.svg", "terminal-2", TEAL),
    ("apps/text-editor.svg", "file-text", TEAL),
    ("apps/text-x-generic.svg", "file-text", TEAL),
    ("apps/totem.svg", "movie", PINK),
    ("apps/utilities-system-monitor.svg", "activity", TEAL),
    ("apps/utilities-terminal.svg", "terminal-2", TEAL),
    ("apps/web-browser.svg", "world", PINK),
    ("mimetypes/application-octet-stream.svg", "app-window", TEAL),
    ("mimetypes/application-x-executable.svg", "app-window", TEAL),
    ("mimetypes/inode-directory.svg", "folder", TEAL),
    ("mimetypes/text-plain.svg", "file-text", TEAL),
    ("mimetypes/text-x-generic.svg", "file-text", TEAL),
    ("places/default-folder.svg", "folder", TEAL),
    ("places/emptytrash.svg", "trash-empty", TEAL),
    ("places/folder-desktop.svg", "desktop", TEAL),
    ("places/folder-documents.svg", "file-text", TEAL),
    ("places/folder-download.svg", "download", TEAL),
    ("places/folder-downloads.svg", "download", TEAL),
    ("places/folder-home.svg", "home", TEAL),
    ("places/folder-music.svg", "music", TEAL),
    ("places/folder-network.svg", "server-2", TEAL),
    ("places/folder-open.svg", "folder", TEAL),
    ("places/folder-pictures.svg", "photo", TEAL),
    ("places/folder-public.svg", "contacts", TEAL),
    ("places/folder-publicshare.svg", "contacts", TEAL),
    ("places/folder-remote.svg", "server-2", TEAL),
    ("places/folder-templates.svg", "templates", TEAL),
    ("places/folder-videos.svg", "movie", TEAL),
    ("places/folder.svg", "folder", TEAL),
    ("places/inode-directory.svg", "folder", TEAL),
    ("places/network-server.svg", "server-2", TEAL),
    ("places/network-workgroup.svg", "server-2", TEAL),
    ("places/trashcan_empty.svg", "trash-empty", TEAL),
    ("places/user-desktop.svg", "desktop", TEAL),
    ("places/user-home.svg", "home", TEAL),
    ("places/user-trash-full.svg", "trash-empty", TEAL),
    ("places/user-trash.svg", "trash-empty", TEAL),
]


def svg(glyph, colour):
    paths = GLYPHS[glyph]
    return HEAD + colour + MID + "\n    " + "\n  ".join(paths) + "\n  " + TAIL


os.makedirs(DEST, mode=0o755, exist_ok=True)
with open(os.path.join(DEST, "index.theme"), "w", encoding="utf-8") as f:
    f.write(INDEX_THEME)
for rel, glyph, colour in ICONS:
    path = os.path.join(DEST, "scalable", rel)
    os.makedirs(os.path.dirname(path), mode=0o755, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(svg(glyph, colour))
for root, dirs, files in os.walk(DEST):
    os.chmod(root, 0o755)
    for name in files:
        os.chmod(os.path.join(root, name), 0o644)
print(len(ICONS))
NIIX_ICONS_PY
    ) && [[ -f "$dest/index.theme" ]]; then
        arch-chroot /mnt gtk-update-icon-cache -f /usr/share/icons/NiiX 2>/dev/null || true
        log "NiiX icon theme installed ($count icons)"
    else
        warn "Could not write the NiiX icon theme - icons skipped"
    fi
}

# -- Auto-iconify: future apps adopt NiiX icons automatically --
# Installs the niix-iconify tool + a pacman hook + systemd watchers so that
# any app installed later (pacman, AUR, or flatpak) gets a matching NiiX icon
# generated on the fly. Curated icons are never overwritten.
setup_auto_icons() {
    log "Installing NiiX auto-iconify (future apps get NiiX icons automatically)..."

    cat > /mnt/usr/local/bin/niix-iconify <<'NIIX_ICONIFY_EOF'
#!/usr/bin/env python3
# niix-iconify - auto-generate NiiX flat icons for installed apps.
# Idempotent reconcile: scans .desktop files, and for any app icon not already
# present in the NiiX theme, composes a matching flat squircle tile and writes it.
# Curated icons (shipped with the theme) are never overwritten.
#
# Usage:
#   niix-iconify                 reconcile (add missing icons)
#   niix-iconify --quiet         no per-icon output
#   niix-iconify --force         also regenerate previously auto-made icons
#   niix-iconify --prune         remove auto-made icons no longer referenced
#   niix-iconify --add NAME GLYPH [pink|teal]   add one icon by hand
#   niix-iconify --list-glyphs   print available glyph names
import os, re, sys, glob, subprocess

THEME_DIR = "/usr/share/icons/NiiX/scalable/apps"
AUTO_MARK = "<!--niix-auto-->"
TEAL = "#5eead4"
PINK = "#f0a6c0"
TILE_FILL = "#121821"
TILE_BORDER = "#1f2a33"

DESKTOP_DIRS = [
    "/usr/share/applications",
    "/usr/local/share/applications",
    "/var/lib/flatpak/exports/share/applications",
]
DESKTOP_DIRS += glob.glob("/home/*/.local/share/applications")
DESKTOP_DIRS += glob.glob("/home/*/.local/share/flatpak/exports/share/applications")

GLYPHS = {
"device-gamepad-2": "<path d=\"M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4\" /> <path d=\"M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232\" /> <path d=\"M8 9v2\" /> <path d=\"M7 10h2\" /> <path d=\"M14 10h2\" />",
"message-circle": "<path d=\"M3 20l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c3.255 2.777 3.695 7.266 1.029 10.501c-2.666 3.235 -7.615 4.215 -11.574 2.293l-4.7 1\" />",
"world": "<path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0\" /> <path d=\"M3.6 9h16.8\" /> <path d=\"M3.6 15h16.8\" /> <path d=\"M11.5 3a17 17 0 0 0 0 18\" /> <path d=\"M12.5 3a17 17 0 0 1 0 18\" />",
"music": "<path d=\"M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" /> <path d=\"M13 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" /> <path d=\"M9 17v-13h10v13\" /> <path d=\"M9 8h10\" />",
"movie": "<path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M8 4l0 16\" /> <path d=\"M16 4l0 16\" /> <path d=\"M4 8l4 0\" /> <path d=\"M4 16l4 0\" /> <path d=\"M4 12l16 0\" /> <path d=\"M16 8l4 0\" /> <path d=\"M16 16l4 0\" />",
"brush": "<path d=\"M3 21v-4a4 4 0 1 1 4 4h-4\" /> <path d=\"M21 3a16 16 0 0 0 -12.8 10.2\" /> <path d=\"M21 3a16 16 0 0 1 -10.2 12.8\" /> <path d=\"M10.6 9a9 9 0 0 1 4.4 4.4\" />",
"photo": "<path d=\"M15 8h.01\" /> <path d=\"M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12\" /> <path d=\"M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5\" /> <path d=\"M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3\" />",
"camera": "<path d=\"M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2\" /> <path d=\"M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" />",
"download": "<path d=\"M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2\" /> <path d=\"M7 11l5 5l5 -5\" /> <path d=\"M12 4l0 12\" />",
"terminal-2": "<path d=\"M8 9l3 3l-3 3\" /> <path d=\"M13 15l3 0\" /> <path d=\"M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -12\" />",
"code": "<path d=\"M7 8l-4 4l4 4\" /> <path d=\"M17 8l4 4l-4 4\" /> <path d=\"M14 4l-4 16\" />",
"file-text": "<path d=\"M14 3v4a1 1 0 0 0 1 1h4\" /> <path d=\"M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2\" /> <path d=\"M9 9l1 0\" /> <path d=\"M9 13l6 0\" /> <path d=\"M9 17l6 0\" />",
"calculator": "<path d=\"M4 5a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -14\" /> <path d=\"M8 8a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v1a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1l0 -1\" /> <path d=\"M8 14l0 .01\" /> <path d=\"M12 14l0 .01\" /> <path d=\"M16 14l0 .01\" /> <path d=\"M8 17l0 .01\" /> <path d=\"M12 17l0 .01\" /> <path d=\"M16 17l0 .01\" />",
"table": "<path d=\"M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14\" /> <path d=\"M3 10h18\" /> <path d=\"M10 3v18\" />",
"presentation": "<path d=\"M3 4l18 0\" /> <path d=\"M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10\" /> <path d=\"M12 16l0 4\" /> <path d=\"M9 20l6 0\" /> <path d=\"M8 12l3 -3l2 2l3 -3\" />",
"book": "<path d=\"M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0\" /> <path d=\"M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0\" /> <path d=\"M3 6l0 13\" /> <path d=\"M12 6l0 13\" /> <path d=\"M21 6l0 13\" />",
"mail": "<path d=\"M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10\" /> <path d=\"M3 7l9 6l9 -6\" />",
"folder": "<path d=\"M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2\" />",
"settings": "<path d=\"M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065\" /> <path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" />",
"activity": "<path d=\"M3 12h4l3 8l4 -16l3 8h4\" />",
"server-2": "<path d=\"M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2\" /> <path d=\"M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2\" /> <path d=\"M7 8l0 .01\" /> <path d=\"M7 16l0 .01\" /> <path d=\"M11 8h6\" /> <path d=\"M11 16h6\" />",
"map-2": "<path d=\"M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5\" /> <path d=\"M9 4v13\" /> <path d=\"M15 7v5.5\" /> <path d=\"M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879\" /> <path d=\"M19 18v.01\" />",
"key": "<path d=\"M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0\" /> <path d=\"M15 9h.01\" />",
"file-zip": "<path d=\"M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1\" /> <path d=\"M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2\" /> <path d=\"M11 5l-1 0\" /> <path d=\"M13 7l-1 0\" /> <path d=\"M11 9l-1 0\" /> <path d=\"M13 11l-1 0\" /> <path d=\"M11 13l-1 0\" /> <path d=\"M13 15l-1 0\" />",
"wifi": "<path d=\"M12 18l.01 0\" /> <path d=\"M9.172 15.172a4 4 0 0 1 5.656 0\" /> <path d=\"M6.343 12.343a8 8 0 0 1 11.314 0\" /> <path d=\"M3.515 9.515c4.686 -4.687 12.284 -4.687 17 0\" />",
"calendar": "<path d=\"M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12\" /> <path d=\"M16 3v4\" /> <path d=\"M8 3v4\" /> <path d=\"M4 11h16\" /> <path d=\"M11 15h1\" /> <path d=\"M12 15v3\" />",
"clock": "<path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0\" /> <path d=\"M12 7v5l3 3\" />",
"cloud": "<path d=\"M6.657 18c-2.572 0 -4.657 -2.007 -4.657 -4.483c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 1.927 -1.551 3.487 -3.465 3.487h-11.878\" />",
"apps": "<path d=\"M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\" /> <path d=\"M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\" /> <path d=\"M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\" /> <path d=\"M14 7l6 0\" /> <path d=\"M17 4l0 6\" />",
"printer": "<path d=\"M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2\" /> <path d=\"M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4\" /> <path d=\"M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4\" />",
"box": "<path d=\"M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5\" /> <path d=\"M12 12l8 -4.5\" /> <path d=\"M12 12l0 9\" /> <path d=\"M12 12l-8 -4.5\" />",
"database": "<path d=\"M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0\" /> <path d=\"M4 6v6a8 3 0 0 0 16 0v-6\" /> <path d=\"M4 12v6a8 3 0 0 0 16 0v-6\" />",
"bug": "<path d=\"M9 9v-1a3 3 0 0 1 6 0v1\" /> <path d=\"M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3\" /> <path d=\"M3 13l4 0\" /> <path d=\"M17 13l4 0\" /> <path d=\"M12 20l0 -6\" /> <path d=\"M4 19l3.35 -2\" /> <path d=\"M20 19l-3.35 -2\" /> <path d=\"M4 7l3.75 2.4\" /> <path d=\"M20 7l-3.75 2.4\" />",
"app-window": "<path d=\"M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10\" /> <path d=\"M6 8h.01\" /> <path d=\"M9 8h.01\" />"
}

# (regex tested against  iconname + name + categories , glyph, pink?)
RULES = [
 (r"steam|lutris|heroic|emulat|retroarch|minecraft|gamepad|playstation|xbox|proton|\bgame", "device-gamepad-2", True),
 (r"discord|telegram|signal|slack|matrix|element|whatsapp|messeng|\bchat\b|\birc\b|rocketchat", "message-circle", True),
 (r"chromium|firefox|chrome|brave|helium|vivaldi|opera|epiphany|browser|\bweb\b", "world", True),
 (r"spotify|rhythmbox|clementine|lollypop|deezer|tidal|headphone|\bmusic\b|\baudio\b", "music", True),
 (r"\bmpv\b|\bvlc\b|kodi|stremio|plex|jellyfin|netflix|youtube|totem|movie|video|\bplayer\b", "movie", True),
 (r"gimp|krita|inkscape|blender|aseprite|\bpaint\b|\bdraw\b|pixel", "brush", True),
 (r"shotwell|gthumb|\bphoto\b|\bimage\b|picture|\bviewer\b|loupe|\beog\b", "photo", True),
 (r"\bobs\b|record|stream|webcam|camera|cheese|kamoso", "camera", True),
 (r"torrent|transmission|qbittorrent|deluge|fragments|\bdownload\b", "download", True),
 (r"terminal|console|\bkgx\b|tilix|alacritty|kitty|wezterm|ptyxis|\bshell\b|\btty\b", "terminal-2", False),
 (r"vscode|vscodium|\bvim\b|neovim|emacs|sublime|jetbrains|pycharm|\bidea\b|builder|\bide\b|\bcode\b|\bdevel\b", "code", False),
 (r"gedit|writer|\bnotes\b|typora|obsidian|markdown|text|document", "file-text", False),
 (r"calculator|\bcalc\b", "calculator", False),
 (r"gnumeric|spreadsheet|\bsheet\b|excel|\btable\b", "table", False),
 (r"impress|presentation|\bslides\b|powerpoint", "presentation", False),
 (r"evince|papers|okular|xreader|\breader\b|calibre|foliate|\bpdf\b|\bbook\b|ebook", "book", False),
 (r"thunderbird|geary|evolution|kmail|\bmail\b|email", "mail", False),
 (r"nautilus|nemo|thunar|dolphin|pcmanfm|\bfiles?\b|explorer|\bmanager\b", "folder", False),
 (r"system-monitor|\bhtop\b|\bbtop\b|mission-center|\busage\b|resources|\bstats\b|\btask\b", "activity", False),
 (r"gparted|gnome-disks|baobab|filelight|\bdisk\b|\bdrive\b|partition", "server-2", False),
 (r"gnome-maps|navigation|\bgps\b|\bmaps?\b", "map-2", False),
 (r"keyring|seahorse|bitwarden|keepass|\bpassword\b|\bsecret\b|\bvault\b|authent", "key", False),
 (r"file-roller|xarchiver|\bark\b|archive|\bzip\b|compress", "file-zip", False),
 (r"wireguard|openvpn|\bvpn\b|\bwifi\b|network|\bnm-", "wifi", False),
 (r"calendar|\bdate\b", "calendar", False),
 (r"\bclock\b|\balarm\b|stopwatch|\btime\b", "clock", False),
 (r"weather", "cloud", False),
 (r"software|appstore|discover|pamac|octopi|warehouse|\bstore\b|market", "apps", False),
 (r"\bcups\b|printer|\bprint\b|\bscan\b", "printer", False),
 (r"virtualbox|vmware|\bqemu\b|\bboxes\b|\bvirt", "box", False),
 (r"dbeaver|postgres|mysql|\bsql\b|database", "database", False),
 (r"\bbug\b|debug", "bug", False),
 (r"settings|preferences|control-center|\btweak\b|\bdconf\b|\bconfig\b", "settings", False),
]

NAME_RE = re.compile(r"^[A-Za-z0-9._+-]+$")

def compose(glyph, pink):
    color = PINK if pink else TEAL
    inner = GLYPHS.get(glyph, GLYPHS["app-window"])
    return (
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">'
+ AUTO_MARK +
'<rect x="14" y="14" width="228" height="228" rx="58" fill="%s" stroke="%s" stroke-width="2"/>'
'<g transform="translate(64,64) scale(5.3333)" fill="none" stroke="%s" stroke-width="1.6" '
'stroke-linecap="round" stroke-linejoin="round">%s</g></svg>\n'
) % (TILE_FILL, TILE_BORDER, color, inner)

def pick(primary, secondary):
    # Match the app's own icon-name + Name first; fall back to Categories.
    # This stops generic category words (e.g. "AudioVideo") from outranking
    # a more specific match on the application's actual name.
    for hay in (primary, secondary):
        for rx, glyph, pink in RULES:
            if re.search(rx, hay):
                return glyph, pink
    return "app-window", False

def read_desktops():
    seen = {}
    for d in DESKTOP_DIRS:
        for path in glob.glob(os.path.join(d, "*.desktop")):
            try:
                icon = name = cats = ""
                with open(path, encoding="utf-8", errors="ignore") as fh:
                    in_entry = False
                    for line in fh:
                        s = line.strip()
                        if s.startswith("["):
                            in_entry = (s == "[Desktop Entry]")
                            continue
                        if not in_entry:
                            continue
                        if s.startswith("Icon=") and not icon:
                            icon = s[5:].strip()
                        elif s.startswith("Name=") and not name:
                            name = s[5:].strip()
                        elif s.startswith("Categories=") and not cats:
                            cats = s[11:].strip()
                if icon and icon not in seen:
                    seen[icon] = (name, cats)
            except Exception:
                pass
    return seen

def is_auto(path):
    try:
        with open(path, encoding="utf-8", errors="ignore") as fh:
            return AUTO_MARK in fh.read(200)
    except Exception:
        return False

def main():
    args = sys.argv[1:]
    quiet = "--quiet" in args
    force = "--force" in args

    if "--list-glyphs" in args:
        print(" ".join(sorted(GLYPHS))); return
    if "--help" in args or "-h" in args:
        print(__doc__); return

    os.makedirs(THEME_DIR, exist_ok=True)

    if "--add" in args:
        i = args.index("--add")
        rest = [a for a in args[i+1:] if not a.startswith("-")]
        if len(rest) < 2:
            print("usage: niix-iconify --add NAME GLYPH [pink|teal]"); sys.exit(1)
        nm, gl = rest[0], rest[1]
        pink = len(rest) > 2 and rest[2].lower() == "pink"
        if not NAME_RE.match(nm):
            print("refused: unsafe icon name"); sys.exit(1)
        if gl not in GLYPHS:
            print("unknown glyph '%s' (see --list-glyphs)" % gl); sys.exit(1)
        open(os.path.join(THEME_DIR, nm + ".svg"), "w").write(compose(gl, pink))
        if not quiet: print("added %s -> %s" % (nm, gl))
        update_cache(); return

    desktops = read_desktops()
    referenced = set()
    added = 0
    for icon, (name, cats) in desktops.items():
        # skip absolute paths / names with slashes - those apps ship their own icon
        if "/" in icon or icon.startswith(".") or not NAME_RE.match(icon):
            continue
        referenced.add(icon)
        dest = os.path.join(THEME_DIR, icon + ".svg")
        if os.path.exists(dest):
            if not (force and is_auto(dest)):
                continue  # keep curated + existing
        glyph, pink = pick(" ".join((icon, name)).lower(), cats.lower())
        try:
            open(dest, "w").write(compose(glyph, pink))
            added += 1
            if not quiet:
                print("  + %-40s %s%s" % (icon, glyph, " (pink)" if pink else ""))
        except Exception as e:
            if not quiet: print("  ! %s: %s" % (icon, e))

    pruned = 0
    if "--prune" in args:
        for f in glob.glob(os.path.join(THEME_DIR, "*.svg")):
            nm = os.path.basename(f)[:-4]
            if nm not in referenced and is_auto(f):
                os.remove(f); pruned += 1
                if not quiet: print("  - %s (pruned)" % nm)

    if added or pruned:
        update_cache()
    if not quiet:
        print("niix-iconify: %d added, %d pruned (%d apps scanned)"
              % (added, pruned, len(referenced)))

def update_cache():
    try:
        subprocess.run(["gtk-update-icon-cache", "-f", "/usr/share/icons/NiiX"],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
    except Exception:
        pass

if __name__ == "__main__":
    main()
NIIX_ICONIFY_EOF
    chmod +x /mnt/usr/local/bin/niix-iconify

    # pacman hook - fires on any package that ships a .desktop launcher
    mkdir -p /mnt/etc/pacman.d/hooks
    cat > /mnt/etc/pacman.d/hooks/niix-iconify.hook <<'HOOKEOF'
[Trigger]
Operation = Install
Operation = Upgrade
Type = Path
Target = usr/share/applications/*.desktop
Target = usr/local/share/applications/*.desktop

[Action]
Description = Generating NiiX icons for new apps...
When = PostTransaction
Exec = /usr/local/bin/niix-iconify --quiet
HOOKEOF

    # systemd: run once at boot, and on flatpak/local-app changes
    cat > /mnt/etc/systemd/system/niix-iconify.service <<'SVCEOF'
[Unit]
Description=Generate NiiX flat icons for installed apps

[Service]
Type=oneshot
ExecStart=/usr/local/bin/niix-iconify --quiet

[Install]
WantedBy=multi-user.target
SVCEOF

    # Pre-create the watched directories - a systemd .path unit fails to start
    # if a watched path does not exist yet (flatpak only creates exports/ on
    # its first app install).
    mkdir -p /mnt/var/lib/flatpak/exports/share/applications \
             /mnt/usr/local/share/applications

    cat > /mnt/etc/systemd/system/niix-iconify.path <<'PATHEOF'
[Unit]
Description=Watch app directories for NiiX icon generation

[Path]
PathChanged=/var/lib/flatpak/exports/share/applications
PathChanged=/usr/local/share/applications
Unit=niix-iconify.service

[Install]
WantedBy=multi-user.target
PATHEOF

    arch-chroot /mnt systemctl enable niix-iconify.service niix-iconify.path 2>/dev/null || true
    log "Auto-iconify installed (pacman hook + boot/flatpak watchers)"
}

# -- GNOME desktop + branding ---------------------------------
install_desktop() {
    hr "GNOME + Branding"
    log "Installing GNOME (minimal - no gnome-extra bloat)..."
    # gnome-extra is a huge group full of GNOME games, maps, weather, etc.
    # We install only what's actually needed for a clean desktop.
    pac_install_retry \
        gnome \
        gdm \
        gnome-terminal \
        gnome-tweaks \
        gnome-shell-extensions \
        gnome-menus \
        gnome-disk-utility \
        || err "GNOME failed to install after retries"

    # Extras that are NOT required for a working desktop go through the
    # per-package fallback, so one renamed or temporarily unavailable package
    # can never abort the whole install.
    chroot_install \
        gnome-text-editor \
        ttf-hack-nerd \
        gvfs-mtp gvfs-gphoto2 gvfs-afc gvfs-smb

    # Fonts: without these, Chinese/Japanese/Korean text, emoji and many
    # other scripts render as empty boxes.
    #   noto-fonts        Latin/Greek/Cyrillic plus most other scripts
    #                     (Arabic, Hebrew, Thai, Indic, ...)
    #   noto-fonts-cjk    Chinese, Japanese, Korean (~300 MB, unavoidable)
    #   noto-fonts-emoji  colour emoji
    #   wqy-zenhei        CJK fallback Steam and Wine/Proton games pick up
    chroot_install noto-fonts noto-fonts-cjk noto-fonts-emoji wqy-zenhei

    setup_maintenance

    # The 'gnome' group pulls in a bunch of apps we don't want on a minimal
    # gaming install. Strip them right after the group install rather than
    # trying to exclude them from the group (pacman groups install as a
    # whole). Papers is kept as the PDF viewer, Evince is redundant with it.
    # Remove PER PACKAGE, not as one atomic transaction: a few of these (evince,
    # yelp, gnome-color-manager) are still required by packages we keep (sushi,
    # gnome-user-docs, gnome-control-center), and an atomic -Rns aborts the WHOLE
    # removal on the first such conflict - leaving all the bloat installed. Per
    # package, the removable ones go and the still-needed ones are simply kept
    # (their menu entries are hidden at first boot anyway).
    log "Removing unwanted GNOME group apps (keeping Papers as the PDF viewer)..."
    gnome_removed=0; gnome_kept=0
    for gp in gnome-tour epiphany gnome-maps gnome-contacts gnome-characters \
              gnome-connections gnome-font-viewer simple-scan yelp gnome-user-share \
              gnome-color-manager evince; do
        if arch-chroot /mnt pacman -Rns --noconfirm "$gp" &>/dev/null; then
            gnome_removed=$((gnome_removed + 1))
        else
            gnome_kept=$((gnome_kept + 1))
        fi
    done
    log "GNOME cleanup: ${gnome_removed} removed, ${gnome_kept} kept (still required by other apps; menu entries hidden at first boot)"

    # Quoted heredoc + explicit env (same reasoning as configure_system).
    arch-chroot /mnt env NX_USERNAME="$USERNAME" NX_KEYMAP_X11="$KEYMAP_X11" \
        NX_KEYMAP_X11_VARIANT="$KEYMAP_X11_VARIANT" \
        bash <<'CHROOT'
set -e
systemctl enable gdm

# GDM autologin. Trade-off (shown on the confirmation screen): the LUKS
# passphrase is then the ONLY secret between power-on and a logged-in,
# sudo-capable session - anyone who knows it gets the desktop too. The screen
# lock and sudo still require the user password.
mkdir -p /etc/gdm
cat > /etc/gdm/custom.conf <<GDMEOF
[daemon]
AutomaticLoginEnable=True
AutomaticLogin=${NX_USERNAME}
GDMEOF

mkdir -p /etc/X11/xorg.conf.d
# systemd-localed reads this file, and GNOME builds the user's input sources
# from it on first login ("us,ru" becomes two switchable layouts).
{
    echo 'Section "InputClass"'
    echo '    Identifier "system-keyboard"'
    echo '    MatchIsKeyboard "on"'
    echo "    Option \"XkbLayout\" \"${NX_KEYMAP_X11}\""
    if [ -n "${NX_KEYMAP_X11_VARIANT:-}" ]; then
        echo "    Option \"XkbVariant\" \"${NX_KEYMAP_X11_VARIANT}\""
    fi
    echo 'EndSection'
} > /etc/X11/xorg.conf.d/00-keyboard.conf

# NOTE: localectl is deliberately NOT used here - it needs a running systemd/dbus
# and always fails inside a chroot (which would abort this set -e block).
# The X11 keymap is already configured via 00-keyboard.conf above.
CHROOT
    local desk_rc=$?
    [[ "$desk_rc" -eq 0 ]] \
        || err "GDM/desktop chroot configuration failed (exit $desk_rc) - check the log"

    # Default app for text and script files: GNOME Text Editor, not vim.
    # A system-wide /etc/xdg/mimeapps.list beats the defaults packages ship
    # with, and each user can still override it in ~/.config/mimeapps.list.
    mkdir -p /mnt/etc/xdg
    cat > /mnt/etc/xdg/mimeapps.list <<'MIMEEOF'
[Default Applications]
text/plain=org.gnome.TextEditor.desktop
text/markdown=org.gnome.TextEditor.desktop
text/x-log=org.gnome.TextEditor.desktop
text/csv=org.gnome.TextEditor.desktop
text/xml=org.gnome.TextEditor.desktop
application/xml=org.gnome.TextEditor.desktop
application/json=org.gnome.TextEditor.desktop
application/x-yaml=org.gnome.TextEditor.desktop
application/toml=org.gnome.TextEditor.desktop
application/x-shellscript=org.gnome.TextEditor.desktop
text/x-shellscript=org.gnome.TextEditor.desktop
application/x-sh=org.gnome.TextEditor.desktop
application/x-perl=org.gnome.TextEditor.desktop
text/x-python=org.gnome.TextEditor.desktop
text/x-lua=org.gnome.TextEditor.desktop
text/x-ruby=org.gnome.TextEditor.desktop
text/x-php=org.gnome.TextEditor.desktop
text/x-java=org.gnome.TextEditor.desktop
text/javascript=org.gnome.TextEditor.desktop
application/javascript=org.gnome.TextEditor.desktop
text/css=org.gnome.TextEditor.desktop
text/html=org.gnome.TextEditor.desktop
text/x-csrc=org.gnome.TextEditor.desktop
text/x-chdr=org.gnome.TextEditor.desktop
text/x-c++src=org.gnome.TextEditor.desktop
text/x-c++hdr=org.gnome.TextEditor.desktop
text/x-makefile=org.gnome.TextEditor.desktop
text/x-cmake=org.gnome.TextEditor.desktop
text/x-diff=org.gnome.TextEditor.desktop
text/x-patch=org.gnome.TextEditor.desktop
text/x-sql=org.gnome.TextEditor.desktop
text/x-ini=org.gnome.TextEditor.desktop
application/x-desktop=org.gnome.TextEditor.desktop
application/x-executable=org.gnome.TextEditor.desktop
application/octet-stream=org.gnome.TextEditor.desktop
MIMEEOF
    chmod 644 /mnt/etc/xdg/mimeapps.list
    arch-chroot /mnt update-desktop-database /usr/share/applications 2>/dev/null || true
    log "Text Editor set as the default for text, script and unknown (.bin) files"

    # Console editor for sudoedit/visudo/git: nano (vim is not installed).
    grep -q '^EDITOR=' /mnt/etc/environment 2>/dev/null || {
        printf 'EDITOR=nano\nVISUAL=nano\n' >> /mnt/etc/environment
    }

    disable_donation_prompts
    # A missing display-manager link means the machine boots to a text console.
    [[ -L /mnt/etc/systemd/system/display-manager.service ]] \
        && [[ "$(readlink /mnt/etc/systemd/system/display-manager.service)" == */gdm.service ]] \
        || err "GDM was not enabled as the display manager - check the log"

    # -- NiiX Arch branding -----------------------------------
    log "Applying NiiX Arch branding..."

    # os-release
    sed -i 's/^NAME=.*/NAME="NiiX Arch"/' /mnt/etc/os-release
    sed -i 's/^PRETTY_NAME=.*/PRETTY_NAME="NiiX Arch"/' /mnt/etc/os-release

    # NiiX logo (About page) + os-release LOGO key
    write_logo
    # NiiX flat icon theme (overrides every installed app icon)
    write_icons
    # Auto-iconify for any app installed later
    setup_auto_icons

    # Drop-down terminal: ddterm (a GNOME Shell extension, Wayland-native) is
    # installed and configured in the first-boot phase, since it needs the AUR.

    # -- Hide-app helpers: tidy the menu without uninstalling -----
    # Many apps are pulled in as GNOME dependencies and can't be removed, but you
    # can hide them from the menu with a per-user NoDisplay override (reversible).
    cat > /mnt/usr/local/bin/niix-hide-app <<'HIDEEOF'
#!/bin/bash
# Hide one or more apps from the menu (does NOT uninstall). Reverse: niix-unhide-app
#   niix-hide-app org.gnome.Tour gnome-system-monitor ...
set -uo pipefail
A="$HOME/.local/share/applications"; mkdir -p "$A"
[ $# -ge 1 ] || { echo "usage: niix-hide-app <desktop-id> [more...]"; exit 1; }
for id in "$@"; do
    id="${id%.desktop}"; src=""
    for d in /usr/share/applications /usr/local/share/applications \
             /var/lib/flatpak/exports/share/applications \
             "$HOME/.local/share/flatpak/exports/share/applications"; do
        [ -f "$d/$id.desktop" ] && { src="$d/$id.desktop"; break; }
    done
    if [ -n "$src" ]; then
        cp -f "$src" "$A/$id.desktop"
        if grep -q '^NoDisplay=' "$A/$id.desktop"; then
            sed -i 's/^NoDisplay=.*/NoDisplay=true/' "$A/$id.desktop"
        else
            sed -i '/^\[Desktop Entry\]/a NoDisplay=true' "$A/$id.desktop"
        fi
    else
        printf '[Desktop Entry]\nType=Application\nName=%s\nNoDisplay=true\nHidden=true\n' "$id" > "$A/$id.desktop"
    fi
    echo "hidden: $id"
done
update-desktop-database "$A" 2>/dev/null || true
HIDEEOF
    chmod +x /mnt/usr/local/bin/niix-hide-app

    cat > /mnt/usr/local/bin/niix-unhide-app <<'UNHIDEEOF'
#!/bin/bash
# Un-hide apps previously hidden with niix-hide-app
set -uo pipefail
A="$HOME/.local/share/applications"
[ $# -ge 1 ] || { echo "usage: niix-unhide-app <desktop-id> [more...]"; exit 1; }
for id in "$@"; do id="${id%.desktop}"; rm -f "$A/$id.desktop" && echo "shown: $id"; done
update-desktop-database "$A" 2>/dev/null || true
UNHIDEEOF
    chmod +x /mnt/usr/local/bin/niix-unhide-app
    log "Menu hide/unhide helpers installed (niix-hide-app / niix-unhide-app)"
    if grep -q '^LOGO=' /mnt/etc/os-release; then
        sed -i 's/^LOGO=.*/LOGO=niixarch-logo/' /mnt/etc/os-release
    else
        echo 'LOGO=niixarch-logo' >> /mnt/etc/os-release
    fi

    # /etc/issue
    echo "NiiX Arch \r (\l)" > /mnt/etc/issue

    # MOTD - kept plain text to avoid GDM/PAM rendering Unicode box art as
    # garbage characters on the graphical login screen (pam_motd displays this).
    # The NiiX Arch ASCII logo is shown via fastfetch in the terminal instead.
    cat > /mnt/etc/motd <<'MOTDEOF'

  Welcome to NiiX Arch
  Run 'fastfetch' for system info.

MOTDEOF

    # Branding persistence hook
    mkdir -p /mnt/etc/pacman.d/hooks
    cat > /mnt/etc/pacman.d/hooks/niixarch-branding.hook <<'HOOKEOF'
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = filesystem

[Action]
Description = Re-applying NiiX Arch branding...
When = PostTransaction
Exec = /bin/bash -c "sed -i 's/^NAME=.*/NAME=\"NiiX Arch\"/' /etc/os-release; sed -i 's/^PRETTY_NAME=.*/PRETTY_NAME=\"NiiX Arch\"/' /etc/os-release; grep -q '^LOGO=' /etc/os-release && sed -i 's/^LOGO=.*/LOGO=niixarch-logo/' /etc/os-release || echo 'LOGO=niixarch-logo' >> /etc/os-release"
HOOKEOF

    # Shell performance vars (for all users)
    cat > /mnt/etc/profile.d/niixarch-gaming.sh <<'SHEOF'
export DXVK_ASYNC=1
export mesa_glthread=true
export WINEFSYNC=1
export WINEESYNC=1
export WINE_FULLSCREEN_FSR=1
export MESA_SHADER_CACHE_MAX_SIZE=10G
export PROTON_ENABLE_NTSYNC=1
SHEOF
    # GPU-specific vars
    if [[ "$GPU_VENDOR" == "nvidia" ]]; then
        cat >> /mnt/etc/profile.d/niixarch-gaming.sh <<'NVEOF'
export __GL_SHADER_DISK_CACHE=1
export __GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1
export __GL_THREADED_OPTIMIZATIONS=1
export PROTON_ENABLE_NVAPI=1
export PROTON_HIDE_NVIDIA_GPU=0
NVEOF
    elif [[ "$GPU_VENDOR" == "amd" ]]; then
        echo 'export RADV_PERFTEST=gpl' >> /mnt/etc/profile.d/niixarch-gaming.sh
    fi

    log "GNOME + branding installed"
}

# -- Final initramfs build (MUST run after ALL packages) ------
# -- Step: firmware updates + niix-update ------------------------------------
setup_maintenance() {
    info "Setting up firmware updates and niix-update..."
    # fwupd pulls in its UEFI helper (fwupd-efi); udisks2 (installed) is what
    # it needs for UEFI capsule updates. GNOME Software shows firmware updates
    # once fwupd is present. The refresh timer keeps the LVFS metadata current.
    chroot_install fwupd pacman-contrib
    arch-chroot /mnt systemctl enable fwupd-refresh.timer 2>/dev/null \
        && log "Firmware updates: fwupd + LVFS (GNOME Software or niix-update --firmware)" \
        || warn "Could not enable fwupd-refresh.timer - firmware metadata refreshes on demand only"

    cat > /mnt/usr/local/bin/niix-update <<'NIIXUPDEOF'
#!/bin/bash
# niix-update - update everything on NiiX Arch, in the order that is safe:
#   1. Arch news   anything new that may need a manual step BEFORE upgrading
#                  is shown first (the most common way Arch installs break)
#   2. System      pacman -Syu  (snap-pac takes a snapshot before and after)
#   3. AUR         yay - only if the system upgrade succeeded, so the AUR
#                  never builds against a half-upgraded system
#   4. Flatpak     apps and runtimes, then unused runtimes are removed
#   5. Firmware    lists available updates (fwupd/LVFS); installs on request
#   6. Report      config files to review (.pacnew), whether to reboot
#
#   niix-update                interactive (asks before each step's changes)
#   niix-update --yes          no questions for packages (news is still shown)
#   niix-update --firmware     also install firmware updates (always confirmed)
#   niix-update --clean        also remove orphaned packages and old caches
#   niix-update --no-aur | --no-flatpak
set -uo pipefail

YES=0; FIRMWARE=0; CLEAN=0; DO_AUR=1; DO_FLATPAK=1
for arg in "$@"; do
    case "$arg" in
        -y|--yes)    YES=1 ;;
        --firmware)  FIRMWARE=1 ;;
        --clean)     CLEAN=1 ;;
        --no-aur)    DO_AUR=0 ;;
        --no-flatpak) DO_FLATPAK=0 ;;
        -h|--help)   sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *)           echo "Unknown option: $arg (see niix-update --help)" >&2; exit 1 ;;
    esac
done

G=$'\033[38;5;118m'; P=$'\033[38;5;205m'; Y=$'\033[1;33m'; R=$'\033[0;31m'
W=$'\033[1;37m'; N=$'\033[0m'
step() { echo; echo "${P}== $* ${N}"; }
ok()   { echo "  ${G}[ok]${N} $*"; }
note() { echo "  ${Y}[!!]${N} $*"; }
bad()  { echo "  ${R}[xx]${N} $*"; }
indent() { sed 's/^/     /'; }
ask()  { # ask "question" default(y|n) -> 0 for yes
    local q=$1 def=${2:-y} a
    [ "$YES" = 1 ] && return 0
    if [ "$def" = y ]; then read -rp "  $q [Y/n] " a; [[ -z "$a" || "$a" =~ ^[Yy] ]]
    else read -rp "  $q [y/N] " a; [[ "$a" =~ ^[Yy] ]]; fi
}

# yay refuses to run as root, and per-user Flatpaks belong to the user.
if [ "$(id -u)" -eq 0 ]; then
    echo "Run niix-update as your normal user - it uses sudo where needed." >&2
    exit 1
fi

STATE="${XDG_STATE_HOME:-$HOME/.local/state}/niix-update"
mkdir -p "$STATE"
# (No output capture: pacman, yay and fwupdmgr keep their colours, progress
# bars and prompts only when they talk to the terminal directly.)

# One run at a time, and never while pacman is busy (e.g. first boot).
exec 8>"$STATE/lock"
flock -n 8 || { bad "niix-update is already running."; exit 1; }
if [ -e /var/lib/pacman/db.lck ]; then
    bad "Another package operation is running (/var/lib/pacman/db.lck). Try again when it finishes."
    exit 1
fi

sudo -v || { bad "sudo is needed for system updates."; exit 1; }
FAILED=0

# -- 1. Arch news ---------------------------------------------------------
step "Arch Linux news"
SEEN_FILE="$STATE/news-seen"
seen=$(cat "$SEEN_FILE" 2>/dev/null || echo 0)
[[ "$seen" =~ ^[0-9]+$ ]] || seen=0
feed=$(mktemp); trap 'rm -f "$feed"' EXIT
if curl -fsSL --max-time 20 --retry 2 -o "$feed" https://archlinux.org/feeds/news/; then
    if ! news=$(python3 - "$feed" "$seen" <<'PY'
import sys, time, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
path, seen = sys.argv[1], int(sys.argv[2])
# First run: show the last 30 days, so a recent manual step is not missed.
floor = seen if seen else int(time.time()) - 30 * 86400
newest = seen
for item in ET.parse(path).getroot().iter("item"):
    try:
        ts = int(parsedate_to_datetime(item.findtext("pubDate", "")).timestamp())
    except Exception:
        continue
    newest = max(newest, ts)
    if ts > floor:
        print("ITEM\t%s\t%s\t%s" % (time.strftime("%Y-%m-%d", time.localtime(ts)),
              (item.findtext("title") or "").strip(), (item.findtext("link") or "").strip()))
print("NEWEST\t%d" % newest)
PY
)
    then
        news=""
    fi
    if [ -z "$news" ]; then
        note "Could not read the news feed - check https://archlinux.org/news/ yourself."
        ask "Continue with the update?" n || exit 1
    else
        newest=$(printf '%s\n' "$news" | awk -F'\t' '$1=="NEWEST"{print $2}')
        if printf '%s\n' "$news" | grep -q '^ITEM'; then
            note "New Arch news since your last update - read before upgrading:"
            printf '%s\n' "$news" | awk -F'\t' '$1=="ITEM"{printf "     %s  %s\n                 %s\n", $2, $3, $4}'
            echo
            if [ "$YES" = 1 ]; then
                note "--yes given: continuing. If an item above needs a manual step, do it now."
            else
                ask "Read them? Continue with the update?" n || { echo "  Stopped - nothing was changed."; exit 0; }
            fi
        else
            ok "Nothing new"
        fi
        [ -n "$newest" ] && echo "$newest" > "$SEEN_FILE"
    fi
else
    note "Arch news unreachable - check https://archlinux.org/news/ before upgrading."
    ask "Continue anyway?" n || exit 1
fi

# -- 2. System packages ----------------------------------------------------
step "System packages (pacman)"
pac_args=(-Syu); [ "$YES" = 1 ] && pac_args+=(--noconfirm)
SYSTEM_OK=0
if sudo pacman "${pac_args[@]}"; then
    SYSTEM_OK=1; ok "System up to date (snapshots taken by snap-pac)"
else
    FAILED=1; bad "System upgrade failed - fix the message above, then run niix-update again."
fi

# -- 3. AUR ----------------------------------------------------------------
if [ "$DO_AUR" = 1 ]; then
    step "AUR packages (yay)"
    if ! command -v yay >/dev/null 2>&1; then
        note "yay is not installed - skipped"
    elif [ "$SYSTEM_OK" != 1 ]; then
        note "Skipped: building AUR packages on a half-upgraded system can break them."
    else
        yay_args=(-Sua); [ "$YES" = 1 ] && yay_args+=(--noconfirm --answerdiff None --answerclean None --answeredit None)
        if yay "${yay_args[@]}"; then ok "AUR packages up to date"; else FAILED=1; bad "AUR update failed (see above)"; fi
    fi
fi

# -- 4. Flatpak --------------------------------------------------------------
if [ "$DO_FLATPAK" = 1 ] && command -v flatpak >/dev/null 2>&1; then
    step "Flatpak apps"
    if sudo flatpak update --system -y --noninteractive; then ok "System Flatpaks up to date"
    else FAILED=1; bad "Flatpak update failed (see above)"; fi
    if flatpak list --user --columns=application 2>/dev/null | grep -q .; then
        flatpak update --user -y --noninteractive || { FAILED=1; bad "User Flatpak update failed"; }
    fi
    sudo flatpak uninstall --system --unused -y --noninteractive >/dev/null 2>&1 \
        && ok "Unused runtimes removed"
fi

# -- 5. Firmware -------------------------------------------------------------
if command -v fwupdmgr >/dev/null 2>&1; then
    step "Firmware (fwupd / LVFS)"
    sudo fwupdmgr refresh --force >/dev/null 2>&1 || true
    fw=$(fwupdmgr get-updates --no-unreported-check --no-metadata-check 2>&1); fw_rc=$?
    if [ "$fw_rc" -eq 0 ]; then
        printf '%s\n' "$fw" | indent
        # Firmware is never applied silently: it can reboot into a flasher.
        if [ "$FIRMWARE" = 1 ] || { [ "$YES" != 1 ] && ask "Install these firmware updates now?" n; }; then
            sudo fwupdmgr update || { FAILED=1; bad "Firmware update failed (see above)"; }
        else
            note "Firmware updates available - install with: niix-update --firmware (or GNOME Software)"
        fi
    else
        ok "No firmware updates"
    fi
fi

# -- Optional clean-up ---------------------------------------------------------
if [ "$CLEAN" = 1 ]; then
    step "Clean-up"
    orphans=$(pacman -Qdtq 2>/dev/null)
    if [ -n "$orphans" ]; then
        printf '%s\n' "$orphans" | indent
        if ask "Remove these unused packages?" y; then
            # shellcheck disable=SC2086
            sudo pacman -Rns --noconfirm $orphans && ok "Orphans removed"
        fi
    else
        ok "No orphaned packages"
    fi
    if command -v paccache >/dev/null 2>&1; then
        sudo paccache -rk2 >/dev/null && sudo paccache -ruk0 >/dev/null && ok "Package cache trimmed (last 2 versions kept)"
    fi
fi

# -- 6. Report -------------------------------------------------------------------
step "Summary"
if command -v pacdiff >/dev/null 2>&1; then
    pn=$(pacdiff -o 2>/dev/null)
    if [ -n "$pn" ]; then
        note "Config files with a new default version to review (sudo DIFFPROG=meld pacdiff, or merge by hand):"
        printf '%s\n' "$pn" | indent
    fi
fi
if [ ! -d "/usr/lib/modules/$(uname -r)" ]; then
    note "The running kernel was updated - ${W}reboot${N} to use it (and before plugging in new hardware)."
fi
if [ "$FAILED" = 0 ]; then ok "Everything is up to date."; else bad "Finished with errors - see above."; fi
exit "$FAILED"
NIIXUPDEOF
    chmod 755 /mnt/usr/local/bin/niix-update

    # Menu entry: runs it in a terminal and keeps the window open at the end.
    cat > /mnt/usr/share/applications/niix-update.desktop <<'NIIXUPDDTEOF'
[Desktop Entry]
Type=Application
Name=NiiX Update
Comment=Update system, AUR and Flatpak apps and firmware - after checking Arch news
Exec=bash -c 'niix-update; echo; read -rp "Press Enter to close..."'
Icon=software-update-available
Terminal=true
Categories=System;Settings;
Keywords=update;upgrade;pacman;yay;flatpak;firmware;
NIIXUPDDTEOF
    chmod 644 /mnt/usr/share/applications/niix-update.desktop
    log "niix-update installed (menu: NiiX Update)"
}

# -- Step: donation prompts permanently off ---------------------------------
# Written at install time (not first boot) so nothing can appear even at the
# very first login, and LOCKED so neither the user nor an extension/app can
# switch them back on. Every key below was verified against upstream source:
#   GNOME 49+    gnome-settings-daemon "housekeeping": twice-a-year reminder
#   ArcMenu      its "vNN Released!" notification IS the donation request
#                (Donate button). Both the switch and the "last version shown"
#                counter are pinned, so even a renamed switch in a future
#                ArcMenu can't make the version check fire.
#   LibreOffice  "Donate" and "Get involved" infobars, finalized in a system
#                .xcd layer (the documented admin method, LibreOffice 7.0+).
# Checked and needing nothing: Dash to Panel, Tiling Assistant, ddterm,
# screen-rotate, AppIndicator, Whatsie, Signal - none shows a donation prompt.
disable_donation_prompts() {
    info "Disabling donation prompts (GNOME, ArcMenu, LibreOffice)..."
    mkdir -p /mnt/etc/dconf/profile /mnt/etc/dconf/db/local.d/locks
    if ! grep -qs 'system-db:local' /mnt/etc/dconf/profile/user; then
        printf 'user-db:user\nsystem-db:local\n' > /mnt/etc/dconf/profile/user
    fi
    cat > /mnt/etc/dconf/db/local.d/10-niix-nonags <<'NONAGEOF'
[org/gnome/settings-daemon/plugins/housekeeping]
donation-reminder-enabled=false

[org/gnome/shell/extensions/arcmenu]
update-notifier-enabled=false
update-notifier-project-version=2147483647
NONAGEOF
    cat > /mnt/etc/dconf/db/local.d/locks/10-niix-nonags <<'NONAGLOCKEOF'
/org/gnome/settings-daemon/plugins/housekeeping/donation-reminder-enabled
/org/gnome/shell/extensions/arcmenu/update-notifier-enabled
/org/gnome/shell/extensions/arcmenu/update-notifier-project-version
NONAGLOCKEOF
    chmod 644 /mnt/etc/dconf/db/local.d/10-niix-nonags /mnt/etc/dconf/db/local.d/locks/10-niix-nonags
    if arch-chroot /mnt dconf update 2>/dev/null; then
        log "GNOME + ArcMenu donation notifications: off and locked"
    else
        warn "dconf update failed - the donation settings apply after first boot's dconf update"
    fi

    # Not owned by any package, so LibreOffice updates never touch it; the
    # directory is created even if LibreOffice installs later.
    mkdir -p /mnt/usr/lib/libreoffice/share/registry
    cat > /mnt/usr/lib/libreoffice/share/registry/niix-nonags.xcd <<'XCDEOF'
<?xml version="1.0" encoding="UTF-8"?>
<oor:data xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:oor="http://openoffice.org/2001/registry">
<dependency file="main"/>
<oor:component-data xmlns:install="http://openoffice.org/2004/installation" oor:name="Infobar" oor:package="org.openoffice.Office.UI">
 <node oor:name="Enabled">
  <prop oor:name="Donate" oor:finalized="true"><value>false</value></prop>
  <prop oor:name="GetInvolved" oor:finalized="true"><value>false</value></prop>
 </node>
</oor:component-data>
</oor:data>
XCDEOF
    chmod 644 /mnt/usr/lib/libreoffice/share/registry/niix-nonags.xcd
    log "LibreOffice Donate / Get involved banners: off and locked"
}

# -- Step: storage - NTFS usable out of the box, removable media writable --
setup_storage() {
    hr "Storage: NTFS and removable media"

    # Internal NTFS volumes: mounted read-write under /media at boot (and when
    # a disk is attached later) by a small root service, so GNOME Files shows
    # them in the sidebar and they are usable without a click. Hibernated or
    # unclean volumes fall back to read-only rather than risking the data.
    # Filesystem tools and the automount backend. Non-fatal on purpose: none of
    # them is needed to boot, and GNOME already pulled in udisks2 and gvfs.
    chroot_install ntfsprogs exfatprogs e2fsprogs mtools udisks2 gvfs

    info "Installing NTFS auto-mount..."
    cat > /mnt/usr/local/bin/niix-ntfs <<'NTFSEOF'
#!/bin/bash
# niix-ntfs - make NTFS volumes usable out of the box.
#   niix-ntfs mount        mount every internal NTFS volume under /media (default)
#   niix-ntfs status       show what is mounted and how
#   niix-ntfs umount-all   unmount everything this tool mounted
#   niix-ntfs list         list NTFS volumes found, and why any were skipped
# Runs at boot and whenever a disk appears (niix-ntfs-mount.service + udev).
# USB sticks and other removable media are deliberately left to GNOME/udisks2,
# which mounts them per-user under /run/media/$USER.
# Settings: /etc/niix-ntfs.conf
set -uo pipefail

ENABLED=1
MOUNT_ROOT=/media
NTFS_UID=""
NTFS_GID=""
NTFS_UMASK=0022
EXCLUDE_UUIDS=""
INCLUDE_REMOVABLE=0
EXTRA_OPTIONS=""
WINDOWS_RW=0
CONF=/etc/niix-ntfs.conf
# shellcheck source=/dev/null
[ -r "$CONF" ] && . "$CONF"

STATE_DIR=/run/niix-ntfs
LOCKFILE=/run/lock/niix-ntfs.lock
ACTION="${1:-mount}"

msg()  { printf '%s\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }

need_root() {
    [ "$(id -u)" -eq 0 ] || { echo "niix-ntfs $ACTION must run as root (try: sudo niix-ntfs $ACTION)" >&2; exit 1; }
}

# Lowest regular account - the desktop user the installer created.
resolve_owner() {
    local u
    if [ -z "$NTFS_UID" ]; then
        u=$(getent passwd | awk -F: '$3>=1000 && $3<65000 {print $3}' | sort -n | head -1)
        NTFS_UID="${u:-0}"
    fi
    if [ -z "$NTFS_GID" ]; then
        NTFS_GID=$(getent passwd | awk -F: -v u="$NTFS_UID" '$3==u {print $4; exit}')
        NTFS_GID="${NTFS_GID:-$NTFS_UID}"
    fi
}

# USB / SD / FireWire media are udisks2's job, not ours.
disk_is_removable() {
    local disk="$1" tran
    [ -n "$disk" ] || return 1
    [ "$(cat "/sys/block/$disk/removable" 2>/dev/null)" = "1" ] && return 0
    tran=$(lsblk -dno TRAN "/dev/$disk" 2>/dev/null | tr -d ' ')
    case "$tran" in usb|mmc|ieee1394) return 0 ;; esac
    return 1
}

sanitize() {
    printf '%s' "$1" \
        | tr -c 'A-Za-z0-9._ -' '_' \
        | sed -e 's/^[ .]*//' -e 's/[ ]*$//' -e 's/ \+/_/g' \
        | cut -c1-48
}

excluded() {
    local uuid="$1" x
    for x in $EXCLUDE_UUIDS; do
        [ "$x" = "$uuid" ] && return 0
    done
    return 1
}

# Every NTFS partition on the machine, one device path per line.
ntfs_parts() {
    local dev
    while IFS= read -r dev; do
        [ -b "$dev" ] || continue
        [ "$(blkid -s TYPE -o value "$dev" 2>/dev/null)" = "ntfs" ] && printf '%s\n' "$dev"
    done < <(lsblk -lno PATH,TYPE 2>/dev/null | awk '$2=="part"{print $1}')
}

# Prints a skip reason, or nothing when the volume should be mounted.
skip_reason() {
    local dev="$1" uuid="$2" parent
    [ -n "$uuid" ] || { echo "no UUID"; return; }
    excluded "$uuid" && { echo "excluded in $CONF"; return; }
    parent=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1 | tr -d ' ')
    if [ "$INCLUDE_REMOVABLE" != "1" ] && disk_is_removable "$parent"; then
        echo "removable - handled by GNOME/udisks2"
        return
    fi
    if grep -qsE "^[^#]*\\b(UUID=)?$uuid\\b" /etc/fstab; then
        echo "already in /etc/fstab"
        return
    fi
    if findmnt -rn -S "$dev" >/dev/null 2>&1; then
        echo "already mounted at $(findmnt -rn -o TARGET -S "$dev" | head -1)"
        return
    fi
}

# Looks inside the volume (read-only, briefly) once, and sets:
#   PROBE_WINDOWS  true when it holds an installed Windows (\Windows\System32)
#   PROBE_NAME     the mount-point name: the label, else "Windows" for a
#                  Windows system volume, else NTFS-<uuid>; made unique.
probe_volume() {
    local dev="$1" uuid="$2" label probe
    PROBE_WINDOWS=false
    probe="$STATE_DIR/probe"
    mkdir -p "$probe"
    if mount -t ntfs3 -o ro "$dev" "$probe" 2>/dev/null 9>&- \
        || mount -t ntfs-3g -o ro "$dev" "$probe" 2>/dev/null 9>&-; then
        [ -d "$probe/Windows/System32" ] && PROBE_WINDOWS=true
        umount "$probe" 2>/dev/null
    fi
    rmdir "$probe" 2>/dev/null

    label=$(blkid -s LABEL -o value "$dev" 2>/dev/null)
    PROBE_NAME=$(sanitize "$label")
    if [ -z "$PROBE_NAME" ]; then
        if [ "$PROBE_WINDOWS" = true ]; then
            PROBE_NAME=Windows
        else
            PROBE_NAME="NTFS-${uuid:0:8}"
        fi
    fi
    # Keep names unique: a second volume with the same label gets its UUID.
    if mountpoint -q "$MOUNT_ROOT/$PROBE_NAME" 2>/dev/null || [ -e "$MOUNT_ROOT/$PROBE_NAME" ]; then
        if ! [ -d "$MOUNT_ROOT/$PROBE_NAME" ] || [ -n "$(ls -A "$MOUNT_ROOT/$PROBE_NAME" 2>/dev/null)" ]; then
            PROBE_NAME="${PROBE_NAME}-${uuid:0:8}"
        fi
    fi
}

# Read-write with the kernel driver first, then the FUSE driver, then
# read-only. A volume left hibernated by Windows Fast Startup cannot be
# written safely, and both drivers refuse it - read-only is the correct,
# non-destructive outcome there.
# mount_one DEVICE TARGET [ro]   - "ro" skips the read-write attempts
mount_one() {
    local dev="$1" target="$2" want="${3:-rw}" common driver=""
    common="noatime,nosuid,nodev,uid=$NTFS_UID,gid=$NTFS_GID,umask=$NTFS_UMASK,windows_names"
    [ -n "$EXTRA_OPTIONS" ] && common="$common,$EXTRA_OPTIONS"

    # 9>&- on every mount: the ntfs-3g FUSE daemon stays alive for the life of
    # the mount and would otherwise inherit the lock fd and hold it forever,
    # deadlocking every later run of this script.
    if [ "$want" != ro ] && mount -t ntfs3 -o "rw,$common" "$dev" "$target" 2>/dev/null 9>&-; then
        driver=ntfs3
    elif [ "$want" != ro ] && mount -t ntfs-3g -o "rw,$common" "$dev" "$target" 2>/dev/null 9>&-; then
        driver=ntfs-3g
    elif mount -t ntfs3 -o "ro,$common" "$dev" "$target" 2>/dev/null 9>&-; then
        driver=ntfs3
    elif mount -t ntfs-3g -o "ro,$common" "$dev" "$target" 2>/dev/null 9>&-; then
        driver=ntfs-3g
    else
        warn "could not mount $dev - see: dmesg | tail"
        return 1
    fi

    # Report what the kernel actually gave us: asking for rw does not always
    # get it (a hibernated volume, a dirty volume, or read-only hardware).
    if [ "$want" = ro ]; then
        msg "mounted $dev -> $target ($driver, read-only: Windows system drive)"
        msg "  Protects Windows from accidental changes. To allow writing, set"
        msg "  WINDOWS_RW=1 in $CONF (Fast Startup must be off in Windows)."
    elif findmnt -rn -o OPTIONS -M "$target" 2>/dev/null | grep -qE '(^|,)ro(,|$)'; then
        msg "mounted $dev -> $target ($driver, READ-ONLY)"
        msg "  Writing is blocked because the volume is hibernated, was not shut down"
        msg "  cleanly, or the disk itself is read-only. In Windows turn off Fast Startup"
        msg "  (Power Options > Choose what the power buttons do) and shut down fully."
        msg "  Check/repair from here: sudo ntfsfix $dev"
    else
        msg "mounted $dev -> $target ($driver, read-write)"
    fi
    return 0
}

do_mount() {
    need_root
    [ "$ENABLED" = "1" ] || { msg "disabled in $CONF"; return 0; }
    resolve_owner
    mkdir -p "$MOUNT_ROOT" "$STATE_DIR"
    local dev uuid reason target want mounted=0

    while IFS= read -r dev; do
        uuid=$(blkid -s UUID -o value "$dev" 2>/dev/null)
        reason=$(skip_reason "$dev" "$uuid")
        [ -n "$reason" ] && continue
        probe_volume "$dev" "$uuid"
        target="$MOUNT_ROOT/$PROBE_NAME"
        want=rw
        [ "$PROBE_WINDOWS" = true ] && [ "$WINDOWS_RW" != "1" ] && want=ro
        mkdir -p "$target" || { warn "could not create $target"; continue; }
        if mount_one "$dev" "$target" "$want"; then
            printf '%s\n' "$target" >> "$STATE_DIR/mounts"
            mounted=$((mounted + 1))
        else
            rmdir "$target" 2>/dev/null
        fi
    done < <(ntfs_parts)

    [ "$mounted" -eq 0 ] && msg "no new NTFS volumes to mount"
    return 0
}

do_list() {
    resolve_owner
    local dev uuid label reason found=0
    printf '%-12s %-18s %-18s %s\n' DEVICE LABEL UUID STATUS
    while IFS= read -r dev; do
        found=1
        uuid=$(blkid -s UUID -o value "$dev" 2>/dev/null)
        label=$(blkid -s LABEL -o value "$dev" 2>/dev/null)
        reason=$(skip_reason "$dev" "$uuid")
        printf '%-12s %-18s %-18s %s\n' "$dev" "${label:-(no label)}" "$uuid" "${reason:-will be mounted under $MOUNT_ROOT}"
    done < <(ntfs_parts)
    [ "$found" -eq 0 ] && msg "no NTFS volumes found"
    return 0
}

do_status() {
    local any=0 line
    while IFS= read -r line; do
        any=1; printf '%s\n' "$line"
    done < <(findmnt -rn -t ntfs3,ntfs-3g,ntfs,fuseblk -o SOURCE,TARGET,FSTYPE,OPTIONS 2>/dev/null \
             | awk '{printf "%-14s %-28s %-8s %s\n", $1, $2, $3, ($4 ~ /(^|,)ro(,|$)/ ? "read-only" : "read-write")}')
    [ "$any" -eq 0 ] && msg "no NTFS volumes mounted"
    return 0
}

do_umount_all() {
    need_root
    local target
    [ -s "$STATE_DIR/mounts" ] || { msg "nothing mounted by niix-ntfs"; return 0; }
    while IFS= read -r target; do
        [ -n "$target" ] || continue
        if mountpoint -q "$target"; then
            if umount "$target" 2>/dev/null; then
                msg "unmounted $target"
            else
                warn "$target is busy - close anything using it (lsof +f -- $target)"
                continue
            fi
        fi
        rmdir "$target" 2>/dev/null
    done < "$STATE_DIR/mounts"
    : > "$STATE_DIR/mounts"
    return 0
}

case "$ACTION" in
    mount)
        # Serialise: udev can fire several times while disks are being probed.
        mkdir -p "$(dirname "$LOCKFILE")"
        exec 9>"$LOCKFILE" || exit 1
        # Bounded wait: if another run really is stuck, this one gives up
        # quietly instead of hanging the boot service or a udev trigger.
        if ! flock -w 120 9; then
            echo "niix-ntfs: another run is in progress - skipping" >&2
            exit 0
        fi
        do_mount
        ;;
    list)       do_list ;;
    status)     do_status ;;
    umount-all) do_umount_all ;;
    *)
        echo "Usage: niix-ntfs {mount|list|status|umount-all}"
        exit 1
        ;;
esac
NTFSEOF
    chmod 755 /mnt/usr/local/bin/niix-ntfs

    if [[ ! -f /mnt/etc/niix-ntfs.conf ]]; then
        cat > /mnt/etc/niix-ntfs.conf <<'NTFSCONFEOF'
# NiiX NTFS auto-mount settings. Apply changes with: sudo niix-ntfs mount
# List volumes and their UUIDs with: niix-ntfs list

# Turn auto-mounting of internal NTFS volumes off entirely.
#ENABLED=0

# Where volumes are mounted. /media is what GNOME Files shows in the sidebar.
#MOUNT_ROOT=/media

# Who owns the files. Defaults to the first regular account on the system.
#NTFS_UID=1000
#NTFS_GID=1000

# 0022 = owner read/write, everyone else read-only. Use 0002 to share with
# other accounts in the same group, 0077 to keep it entirely private.
#NTFS_UMASK=0022

# Volumes to leave alone, by UUID, space-separated.
#EXCLUDE_UUIDS="1234ABCD5678EF90 ABCD12345678EF90"

# Also auto-mount USB/SD NTFS media. Off by default: GNOME already mounts
# removable media per-user under /run/media, with safe-eject in Files.
#INCLUDE_REMOVABLE=1

# Extra mount options appended to the defaults.
#EXTRA_OPTIONS="prealloc"

# A drive holding an installed Windows (\Windows\System32) is mounted
# read-only, so nothing on the Linux side can damage it by accident. Data
# drives are always read-write. Set to 1 to make the Windows drive writable
# too - only with Fast Startup turned off in Windows.
#WINDOWS_RW=1

# Repair dirty USB filesystems (NTFS/FAT/exFAT) when they are plugged in, so
# they mount read-write instead of read-only. Set to 0 to never touch them.
#USB_AUTOFIX=0
NTFSCONFEOF
        chmod 644 /mnt/etc/niix-ntfs.conf
    fi

    cat > /mnt/etc/systemd/system/niix-ntfs-mount.service <<'NTFSUNITEOF'
[Unit]
Description=NiiX: mount internal NTFS volumes under /media
Documentation=man:niix-ntfs
After=local-fs.target
Wants=local-fs.target
ConditionPathExists=/usr/local/bin/niix-ntfs

[Service]
Type=oneshot
ExecStart=/usr/local/bin/niix-ntfs mount
RemainAfterExit=no
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target
NTFSUNITEOF

    mkdir -p /mnt/etc/udev/rules.d
    cat > /mnt/etc/udev/rules.d/99-niix-ntfs.rules <<'NTFSUDEVEOF'
# Mount internal NTFS volumes as soon as they appear, so a drive attached
# after boot is usable without a reboot. niix-ntfs itself skips removable
# media (GNOME/udisks2 handles those) and anything already mounted.
ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", ENV{ID_FS_TYPE}=="ntfs", TAG+="systemd", ENV{SYSTEMD_WANTS}+="niix-ntfs-mount.service"
NTFSUDEVEOF

    arch-chroot /mnt systemctl enable niix-ntfs-mount.service 2>/dev/null \
        && log "NTFS volumes will be mounted under /media at every boot" \
        || warn "Could not enable niix-ntfs-mount.service - run 'sudo niix-ntfs mount' by hand"

    # Removable media (USB sticks, SD cards, phones) are GNOME's job: udisks2
    # mounts them per-user under /run/media with safe-eject in Files. Out of
    # the box udisks mounts foreign filesystems root-owned, so the desktop
    # user cannot write to them - this config fixes exactly that.
    mkdir -p /mnt/etc/udisks2
    if [[ ! -f /mnt/etc/udisks2/mount_options.conf ]]; then
        cat > /mnt/etc/udisks2/mount_options.conf <<'UDISKSEOF'
# Removable media mounted by GNOME Files / udisks2.
# Out of the box udisks mounts foreign filesystems root-owned, so the desktop
# user can read but not write them. $UID/$GID are placeholders udisks fills in
# with the user doing the mounting - they are NOT shell variables.
# Key names and driver names must match udisks' own builtin_mount_options.conf:
# the drivers are "ntfs" (ntfs-3g FUSE / legacy kernel) and "ntfs3" (kernel).
[defaults]

# Prefer ntfs-3g on removable media: slower than ntfs3, but the more forgiving
# of the two with half-unplugged sticks and unclean Windows shutdowns. Falls
# back to the kernel ntfs3 driver if ntfs-3g is missing.
ntfs_drivers=ntfs,ntfs3
ntfs:ntfs_defaults=uid=$UID,gid=$GID,noatime,windows_names,big_writes
ntfs:ntfs_allow=uid=$UID,gid=$GID,umask,dmask,fmask,locale,norecover,ignore_case,windows_names,compression,nocompression,big_writes
ntfs:ntfs3_defaults=uid=$UID,gid=$GID,noatime,windows_names
ntfs:ntfs3_allow=uid=$UID,gid=$GID,umask,dmask,fmask,iocharset,discard,nodiscard,sparse,nosparse,hidden,nohidden,sys_immutable,nosys_immutable,showmeta,noshowmeta,prealloc,noprealloc,hide_dot_files,nohide_dot_files,windows_names,nocase,case

# errors=continue keeps a stick writable through a transient error instead of
# flipping it read-only mid-copy; the filesystem is marked dirty and niix-usb-
# autofix repairs it on the next plug-in. "errors" is not in udisks' builtin
# allow list for these filesystems, so it is added below.
vfat_defaults=uid=$UID,gid=$GID,shortname=mixed,utf8=1,showexec,flush,errors=continue
vfat_allow=uid=$UID,gid=$GID,flush,utf8,shortname,umask,dmask,fmask,codepage,iocharset,usefree,showexec,errors
exfat_defaults=uid=$UID,gid=$GID,iocharset=utf8,errors=continue
exfat_allow=uid=$UID,gid=$GID,umask,dmask,fmask,allow_utime,iocharset,errors,discard,keep_last_dots,sys_tz,time_offset,zero_size_dir,namecase,codepage
UDISKSEOF
        chmod 644 /mnt/etc/udisks2/mount_options.conf
    fi

    # A stick left dirty by Windows, or yanked out mid-write, otherwise mounts
    # read-only and every copy to it fails. This repairs it at plug-in time,
    # before udisks mounts it - checking first and only writing when the check
    # reports a problem. Disable with USB_AUTOFIX=0 in /etc/niix-ntfs.conf.
    cat > /mnt/usr/local/bin/niix-usb-autofix <<'USBFIXEOF'
#!/bin/bash
# niix-usb-autofix DEVICE FSTYPE
# Run from udev the moment a USB filesystem appears and before udisks mounts
# it, so a stick left dirty by Windows (or by being yanked out) is repaired and
# mounts read-write instead of read-only.
# Deliberately conservative:
#   - never touches a filesystem that is already mounted
#   - checks first, and only writes when the check reports a problem
#   - a volume hibernated by Windows is left alone: clearing its dirty flag
#     would not make writing safe, and both NTFS drivers refuse it anyway
#   - every tool runs under a timeout so a udev worker can never wedge
# Turn it off with USB_AUTOFIX=0 in /etc/niix-ntfs.conf
# Log: journalctl -t niix-usb-autofix
set -u
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin

USB_AUTOFIX=1
CONF=/etc/niix-ntfs.conf
# shellcheck source=/dev/null
[ -r "$CONF" ] && . "$CONF"
[ "${USB_AUTOFIX:-1}" = "1" ] || exit 0

DEV="${1:-}"
FS="${2:-}"
[ -b "$DEV" ] || exit 0

say() { logger -t niix-usb-autofix -- "$*" 2>/dev/null || true; }

# Anything already mounted is off limits - repairing a mounted filesystem
# corrupts it.
findmnt -rn -S "$DEV" >/dev/null 2>&1 && exit 0

case "$FS" in
    ntfs)
        command -v ntfsfix >/dev/null 2>&1 || exit 0
        # Without the probe there is no safe way to tell "unclean" from
        # "hibernated", so do nothing rather than guess.
        command -v ntfs-3g.probe >/dev/null 2>&1 || exit 0
        timeout 20 ntfs-3g.probe --readwrite "$DEV" >/dev/null 2>&1
        rc=$?
        # ntfs-3g.probe(8): 0 mountable, 14 hibernated, 15 not cleanly
        # unmounted. Only 15 is repaired: that is the yanked-out / Windows-
        # crashed case ntfsfix exists for. Hibernated (Fast Startup), corrupt,
        # in-use or unknown volumes are left untouched.
        case "$rc" in
            0)  exit 0 ;;
            14) say "$DEV: hibernated by Windows - left alone (turn off Fast Startup and shut down fully)"
                exit 0 ;;
            15) ;;
            *)  say "$DEV: not repairable automatically (ntfs-3g.probe code $rc) - left alone"
                exit 0 ;;
        esac
        if out=$(timeout 45 ntfsfix -d "$DEV" 2>&1); then
            say "$DEV: NTFS was not cleanly unmounted - repaired, mounts read-write now"
        else
            say "$DEV: ntfsfix failed: $(printf '%s' "$out" | tail -1)"
        fi
        ;;
    vfat)
        command -v fsck.fat >/dev/null 2>&1 || exit 0
        timeout 20 fsck.fat -n "$DEV" >/dev/null 2>&1 && exit 0
        if out=$(timeout 45 fsck.fat -a "$DEV" 2>&1); then
            say "$DEV: FAT repaired"
        else
            say "$DEV: fsck.fat could not repair it: $(printf '%s' "$out" | tail -1)"
        fi
        ;;
    exfat)
        command -v fsck.exfat >/dev/null 2>&1 || exit 0
        timeout 20 fsck.exfat -n "$DEV" >/dev/null 2>&1 && exit 0
        if out=$(timeout 45 fsck.exfat -p "$DEV" 2>&1); then
            say "$DEV: exFAT repaired"
        else
            say "$DEV: fsck.exfat could not repair it: $(printf '%s' "$out" | tail -1)"
        fi
        ;;
esac
exit 0
USBFIXEOF
    chmod 755 /mnt/usr/local/bin/niix-usb-autofix

    cat > /mnt/etc/udev/rules.d/99-niix-usb-autofix.rules <<'USBUDEVEOF'
# Repair a dirty USB filesystem the moment it is plugged in, before udisks
# mounts it - otherwise it mounts read-only and copying to it fails.
# "add" only, so an already-mounted filesystem is never touched. The helper
# checks before it writes and gives up rather than blocking a udev worker.
ACTION=="add", SUBSYSTEM=="block", ENV{ID_BUS}=="usb", ENV{ID_FS_USAGE}=="filesystem", ENV{ID_FS_TYPE}=="ntfs|vfat|exfat", RUN+="/usr/bin/timeout 55 /usr/local/bin/niix-usb-autofix $env{DEVNAME} $env{ID_FS_TYPE}"
USBUDEVEOF
    chmod 644 /mnt/etc/udev/rules.d/99-niix-usb-autofix.rules

    log "Removable media: auto-mounted read-write by GNOME, dirty filesystems repaired on plug-in"
}

rebuild_initramfs() {
    hr "Initramfs Rebuild"
    log "Writing final mkinitcpio.conf..."

    # mkinitcpio hooks. NOTE: NVIDIA modules are deliberately NOT forced here -
    # DKMS builds reliably only against the running kernel at first boot, so adding
    # them now risks an unbootable "module not found: nvidia" initramfs. The first
    # boot adds them once the module is confirmed built. The first boot runs on
    # nouveau, which is fine for reaching the desktop.
    cat > /mnt/etc/mkinitcpio.conf <<'MKINIT'
MODULES=()
BINARIES=()
FILES=()
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap block encrypt btrfs filesystems fsck)
MKINIT
    # No 'consolefont' hook: no FONT= is configured in vconsole.conf, so the hook
    # would add nothing and only emit a warning on every rebuild.

    log "mkinitcpio.conf:"
    cat /mnt/etc/mkinitcpio.conf

    log "Rebuilding initramfs (final - after all packages)..."
    arch-chroot /mnt mkinitcpio -P \
        || err "initramfs build failed (mkinitcpio -P) - the system would not boot. Check the log."

    # Verify the image exists and really contains cryptsetup. The listing is
    # captured first (not piped into grep -q): under pipefail, grep exiting
    # early would SIGPIPE lsinitcpio and turn a match into a false failure.
    [[ -s /mnt/boot/initramfs-linux.img ]] \
        || err "initramfs-linux.img is missing or empty after mkinitcpio - check the log"
    local initrd_list
    initrd_list=$(arch-chroot /mnt lsinitcpio /boot/initramfs-linux.img 2>&1) || true
    if grep -q "cryptsetup" <<<"$initrd_list"; then
        log "initramfs contains cryptsetup - LUKS prompt will work"
    else
        {
            echo "--- lsinitcpio initramfs-linux.img ---"
            printf '%s\n' "$initrd_list"
        } >> "$LOGFILE" 2>&1
        err "cryptsetup NOT found in the initramfs - the encrypted root could not be unlocked. Check the log."
    fi
}

# -- Limine bootloader ----------------------------------------
install_limine() {
    hr "Limine Bootloader"
    log "Installing Limine bootloader..."

    # -- 1. Copy EFI binary -----------------------------------
    mkdir -p /mnt/boot/EFI/limine /mnt/boot/EFI/BOOT \
        || err "Could not create Limine directories on the EFI partition"
    cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/limine/BOOTX64.EFI \
        || err "Could not copy Limine to the EFI partition (full ESP?)"
    # Also install as the UEFI fallback path - many BIOSes boot this
    # automatically when no NVRAM entry is found or has been cleared
    cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/BOOT/BOOTX64.EFI \
        || err "Could not copy the Limine fallback loader to the EFI partition"
    log "BOOTX64.EFI copied (\EFI\limine\ + fallback \EFI\BOOT\)"

    # -- 2. Build kernel cmdline ------------------------------
    local CMDLINE="cryptdevice=UUID=${LUKS_UUID}:${MAPPER} root=/dev/mapper/${MAPPER} rootflags=subvol=@ rootfstype=btrfs rw"

    case "$CPU_VENDOR" in
        amd)   CMDLINE+=" amd_pstate=active split_lock_detect=off" ;;
        intel) CMDLINE+=" intel_pstate=active split_lock_detect=off" ;;
    esac
    [[ "$USE_MITIGATIONS" == false ]] && CMDLINE+=" mitigations=off"
    CMDLINE+=" usbhid.mousepoll=1"
    [[ "$GPU_VENDOR" == "amd" ]] && CMDLINE+=" amdgpu.ppfeaturemask=0xffffffff"
    [[ "$GPU_VENDOR" == "nvidia" ]] && CMDLINE+=" nvidia-drm.modeset=1 nvidia-drm.fbdev=1"

    # -- 3. Write Limine config -------------------------------
    # Limine 8+ uses volume-relative paths - no boot() macro.
    # /boot IS the EFI partition, so vmlinuz-linux is at the EFI root.
    mkdir -p /mnt/boot/limine /mnt/boot/EFI/limine

    if [[ "$INSTALL_MODE" == "dualboot" ]]; then
        # default_entry is 1-based. Entry 1 is Windows, entry 2 is NiiX Arch -
        # default to 2 so an unattended reboot lands in the freshly installed Arch.
        cat > /mnt/boot/EFI/limine/limine.cfg <<EOF
timeout: 5
interface_branding: NiiX Arch
interface_branding_colour: 6
interface_help_hidden: yes
default_entry: 2

/Windows Boot Manager
    protocol: efi
    path: boot():/EFI/Microsoft/Boot/bootmgfw.efi

/NiiX Arch
    protocol: linux
    path: boot():/vmlinuz-linux
    cmdline: $CMDLINE
    module_path: boot():/initramfs-linux.img
EOF
    else
        cat > /mnt/boot/EFI/limine/limine.cfg <<EOF
timeout: 5
interface_branding: NiiX Arch
interface_branding_colour: 6
interface_help_hidden: yes
default_entry: 1

/NiiX Arch
    protocol: linux
    path: boot():/vmlinuz-linux
    cmdline: $CMDLINE
    module_path: boot():/initramfs-linux.img
EOF
    fi

    [[ -s /mnt/boot/EFI/limine/limine.cfg ]] || err "Could not write the Limine config"
    # Mirror config to every location Limine searches
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/EFI/limine/limine.conf \
        || err "Could not write limine.conf next to the Limine loader"
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/EFI/BOOT/limine.cfg
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/EFI/BOOT/limine.conf
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/limine/limine.cfg
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/limine/limine.conf
    cp /mnt/boot/EFI/limine/limine.cfg /mnt/boot/limine.cfg
    log "limine.cfg written to all search locations"

    # -- 4. Purge all stale NiiX Arch / Limine NVRAM entries --
    # Previous installs leave entries pointing to GPT GUIDs that no longer
    # exist. These shadow the new entry and break the boot order silently.
    info "Removing stale NiiX Arch / Limine NVRAM entries..."
    while IFS= read -r bootnum; do
        efibootmgr -b "$bootnum" -B 2>/dev/null             && log "  Removed stale NVRAM entry Boot${bootnum}"             || true
    done < <(efibootmgr 2>/dev/null         | grep -iE "NiiX Arch|Limine"         | grep -oP 'Boot\K[0-9A-Fa-f]{4}')
    log "Stale NVRAM entries cleared"

    # -- 5. Register a single clean NVRAM entry ---------------
    local part_num
    part_num=$(echo "$EFI_PART_NUM" | tr -d '[:space:]')
    efibootmgr \
        --create \
        --disk  "$EFI_DRIVE" \
        --part  "$part_num" \
        --label "NiiX Arch (Limine)" \
        --loader '\EFI\limine\BOOTX64.EFI' \
        --unicode \
        && log "Limine registered in UEFI NVRAM" \
        || warn "efibootmgr failed - BIOS will use fallback \EFI\BOOT\BOOTX64.EFI"

    # Pacman hooks
    mkdir -p /mnt/etc/pacman.d/hooks
    cat > /mnt/etc/pacman.d/hooks/limine.hook <<'HOOKEOF'
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = limine

[Action]
Description = Updating Limine EFI binary...
When = PostTransaction
Exec = /bin/sh -c 'cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/'
HOOKEOF

    cat > /mnt/etc/pacman.d/hooks/niixarch-limine.hook <<'HOOKEOF'
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = linux
Target = linux-cachyos
Target = limine

[Action]
Description = Re-applying NiiX Arch Limine branding...
When = PostTransaction
Exec = /bin/bash -c "for f in /boot/limine.conf /boot/limine/limine.conf; do [ -f \"$f\" ] && sed -i 's|^/Arch Linux.*|/NiiX Arch|' \"$f\"; done"
HOOKEOF

    log "Pacman hooks created"
}

# -- First-boot script (AUR, GNOME settings, flatpaks) --------
setup_firstboot() {
    hr "First-Boot Setup"
    log "Setting up first-boot script..."

    # Temporary passwordless sudo for first-boot only.
    # Scoped to the specific user; removed by the firstboot script itself on completion.
    cat > /mnt/etc/sudoers.d/niixarch-firstboot <<SUDOEOF
# Temporary - grants ${USERNAME} passwordless sudo during first-boot setup.
# This file is automatically deleted when first-boot completes.
${USERNAME} ALL=(ALL) NOPASSWD: ALL
SUDOEOF
    chmod 440 /mnt/etc/sudoers.d/niixarch-firstboot
    arch-chroot /mnt visudo -cf /etc/sudoers.d/niixarch-firstboot >/dev/null \
        || { rm -f /mnt/etc/sudoers.d/niixarch-firstboot; err "Temporary first-boot sudo rule failed validation"; }
    log "Temporary passwordless sudo configured for first-boot"

    # Belt-and-suspenders: force-remove the drop-in 2h after boot regardless
    # of whether firstboot ever actually ran. The EXIT trap inside the
    # firstboot script (removes it on script death) and the cleanup at the
    # end of a successful run both only fire if firstboot actually launched -
    # neither covers "the autostart never fired at all", which would
    # otherwise leave passwordless root sitting there indefinitely.
    # The check never pulls sudo out from under a first boot that is still
    # running (a slow connection can push it past 2h), but has a hard 12h cap
    # so a first boot stuck waiting for a network can't hold root open forever.
    # First boot disables the timer itself when it finishes.
    install -d -m 755 /mnt/usr/local/lib/niixarch
    cat > /mnt/usr/local/lib/niixarch/sudo-expiry-check <<'EXPCEOF'
#!/bin/sh
# exit 0 = remove the temporary first-boot sudo rule now; 1 = not yet.
up=$(cut -d. -f1 /proc/uptime)
[ "$up" -ge 7200 ] || exit 1
[ "$up" -ge 43200 ] && exit 0
pgrep -f 'niixarch-firstboot[.]sh' >/dev/null 2>&1 && exit 1
exit 0
EXPCEOF
    chmod 755 /mnt/usr/local/lib/niixarch/sudo-expiry-check
    cat > /mnt/etc/systemd/system/niixarch-sudo-expiry.service <<'EXPEOF'
[Unit]
Description=NiiX first-boot passwordless sudo expiry (safety net)

[Service]
Type=oneshot
ExecCondition=/usr/local/lib/niixarch/sudo-expiry-check
ExecStart=/usr/bin/rm -f /etc/sudoers.d/niixarch-firstboot
EXPEOF
    cat > /mnt/etc/systemd/system/niixarch-sudo-expiry.timer <<'EXPTEOF'
[Unit]
Description=NiiX first-boot passwordless sudo expiry timer (safety net)

[Timer]
OnBootSec=2h
OnCalendar=*:0/15
AccuracySec=1min

[Install]
WantedBy=timers.target
EXPTEOF
    arch-chroot /mnt systemctl enable niixarch-sudo-expiry.timer 2>/dev/null || true
    log "Passwordless sudo expires after first boot (safety net: 2h if it never runs, 12h hard cap)"

    # Save hardware config for first-boot script
    # First boot 'source's this file, so every value is shell-quoted (%q): a
    # quote or '$' in a hardware name can never break it or run as code.
    # (No-op reference: these two are otherwise only read via ${!v} below,
    # which static analysis cannot see.)
    : "${NVIDIA_LEGACY-}" "${DRI_PRIME_TAG-}"
    {
        local v
        for v in GPU_VENDOR CPU_VENDOR GPU_MODEL GPU_GEN NVIDIA_LEGACY HAS_NVIDIA \
                 HAS_AMD HAS_INTEL IS_HYBRID OFFLOAD_MODE DGPU_PCI DRI_PRIME_TAG; do
            printf '%s=%q\n' "$v" "${!v-}"
        done
        printf 'IS_DUALBOOT=%s\n' "$([[ "$INSTALL_MODE" == "dualboot" ]] && echo true || echo false)"
    } > /mnt/etc/niixarch.conf

    # Write the first-boot script
    cat > /mnt/usr/local/bin/niixarch-firstboot.sh <<'FBEOF'
#!/bin/bash
# NiiX Arch First-Boot - CachyOS kernel, AUR packages, GNOME settings
# Run via autostart, or manually: bash /usr/local/bin/niixarch-firstboot.sh
set +e

source /etc/niixarch.conf 2>/dev/null || true

# Fail-safe: the temporary passwordless-sudo drop-in must never survive first-boot,
# however this script exits (success, error, or interruption). It is also removed
# explicitly at the end; this trap covers every early-exit path.
# sudo -n: if the rule is already gone, fail silently instead of prompting.
trap 'sudo -n rm -f /etc/sudoers.d/niixarch-firstboot 2>/dev/null || true' EXIT

GREEN='\033[38;5;118m'
PINK='\033[38;5;205m'
RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; WHITE='\033[1;37m'; BOLD='\033[1m'; NC='\033[0m'
# -- UI kit (identical look to the installer phase) ----------------------------
C_GREEN=$'\033[38;5;118m'; C_PINK=$'\033[38;5;205m'; C_WHITE=$'\033[38;5;255m'
C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RED=$'\033[38;5;203m'; C_YEL=$'\033[38;5;215m'; C_NC=$'\033[0m'
if { exec 3>/dev/tty; } 2>/dev/null; then :; else exec 3>&1; fi
ui_hr()    { printf '  %s────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_NC"; }
ui_title() { printf '\n  %s▸%s  %s%s%s\n' "$C_PINK" "$C_NC" "$C_BOLD$C_WHITE" "$*" "$C_NC"; ui_hr; }
ui_ok()    { printf '  %s✔%s  %s%s%s\n' "$C_GREEN" "$C_NC" "$C_WHITE" "$*" "$C_NC"; }
ui_info()  { printf '  %s•%s  %s%s%s\n' "$C_PINK"  "$C_NC" "$C_WHITE" "$*" "$C_NC"; }
ui_warn()  { printf '  %s!%s  %s%s%s\n' "$C_YEL"   "$C_NC" "$C_WHITE" "$*" "$C_NC"; }
ui_bar() {
    local cur=$1 tot=$2 label=${3:-} width=28 i fill pct done_="" todo_=""
    [ "$tot" -gt 0 ] 2>/dev/null || tot=1
    [ "$cur" -gt "$tot" ] 2>/dev/null && cur=$tot
    pct=$(( cur * 100 / tot )); fill=$(( cur * width / tot ))
    for ((i=0;i<fill;i++)); do done_+='█'; done
    for ((i=fill;i<width;i++)); do todo_+='░'; done
    printf '\r  %s[%s%s%s%s%s]%s %s%3d%%%s  %s%s%s' \
        "$C_PINK" "$C_GREEN" "$done_" "$C_DIM" "$todo_" "$C_NC" "$C_PINK" \
        "$C_WHITE" "$pct" "$C_NC" "$C_DIM" "$label" "$C_NC" >&3
    [ "$cur" -ge "$tot" ] && printf '\n' >&3
}
ui_run() {
    local msg=$1; shift
    local frames='|/-\' i=0 start el rc pid
    "$@" >>"${LOG:-/dev/null}" 2>&1 &
    pid=$!; start=$SECONDS
    while kill -0 "$pid" 2>/dev/null; do
        i=$(( (i+1) % 4 )); el=$(( SECONDS - start ))
        printf '\r  %s%s%s  %s%s%s %s(%ds)%s   ' \
            "$C_PINK" "${frames:i:1}" "$C_NC" "$C_WHITE" "$msg" "$C_NC" "$C_DIM" "$el" "$C_NC" >&3
        sleep 0.1
    done
    wait "$pid"; rc=$?; el=$(( SECONDS - start ))
    printf '\r\033[K' >&3
    if [ "$rc" -eq 0 ]; then ui_ok "$msg  ${C_DIM}(${el}s)${C_NC}"
    else ui_warn "$msg  ${C_DIM}(failed rc=$rc, ${el}s - see log)${C_NC}"; fi
    return "$rc"
}
niixa_banner() {
    clear 2>/dev/null || true
    local g="$C_GREEN" p="$C_PINK" n="$C_NC" w="$C_WHITE" d="$C_DIM" b="$C_BOLD"
    printf '\n'
    printf '  %s███╗   ██╗██╗██╗██╗  ██╗ █████╗ %s██████╗ ███████╗%s\n' "$g" "$p" "$n"
    printf '  %s████╗  ██║██║██║╚██╗██╔╝██╔══██╗%s██╔═══██╗██╔════╝%s\n' "$g" "$p" "$n"
    printf '  %s██╔██╗ ██║██║██║ ╚███╔╝ ███████║%s██║   ██║███████╗%s\n' "$g" "$p" "$n"
    printf '  %s██║╚██╗██║██║██║ ██╔██╗ ██╔══██║%s██║   ██║╚════██║%s\n' "$g" "$p" "$n"
    printf '  %s██║ ╚████║██║██║██╔╝ ██╗██║  ██║%s╚██████╔╝███████║%s\n' "$g" "$p" "$n"
    printf '  %s╚═╝  ╚═══╝╚═╝╚═╝╚═╝  ╚═╝╚═╝  ╚═╝%s ╚═════╝ ╚══════╝%s\n' "$g" "$p" "$n"
    printf '\n'
    printf '  %s%s%s%s   %sluks2 · cachyos · limine · btrfs · gnome%s\n' "$b" "$w" "${1:-first boot}" "$n" "$d" "$n"
    ui_hr
    printf '\n'
}
log()  { ui_ok "$@"; }
warn() { ui_warn "$@"; }
info() { ui_info "$@"; }

# Retry pacman for transient mirror/CDN errors (e.g. CachyOS 404s). Refreshes the
# package databases between attempts; pacman is transactional, so a failed attempt
# installs nothing and a retry starts clean.
pac_retry() {
    local n=1
    until sudo pacman "$@"; do
        if [ "$n" -ge 3 ]; then
            warn "pacman failed after $n attempts: $*"
            return 1
        fi
        warn "Mirror error - refreshing databases and retrying ($n/3)..."
        # force-refresh DBs AND upgrade (never -Syy alone: re-running -S after a
        # bare refresh is a partial upgrade). This only runs on the retry path.
        sudo pacman -Syyu --noconfirm 2>/dev/null || true
        sleep 3
        n=$((n + 1))
    done
    return 0
}

LOG="$HOME/niixarch-firstboot-$(date +%Y%m%d-%H%M%S).log"
# Private: the log can contain a generated hotspot password.
( umask 077; : > "$LOG" )
exec > >(tee -a "$LOG") 2>&1

niixa_banner "first boot"

# -- Resumable state --------------------------------------
# First-boot re-runs on the next login if it crashes before writing its
# done-marker. Stamp each expensive step so a resumed run skips work that
# already succeeded instead of repeating a 15-minute package storm.
STATE_DIR="$HOME/.local/state/niixarch"
mkdir -p "$STATE_DIR"
step_done() { [ -f "$STATE_DIR/$1.done" ]; }
mark_step()  { touch "$STATE_DIR/$1.done"; }
# run_once STAMP cmd... : skip if already done, else run and stamp on success.
run_once() {
    local stamp="$1"; shift
    if step_done "$stamp"; then
        info "Already done: $stamp - skipping"
        return 0
    fi
    if "$@"; then
        mark_step "$stamp"
        return 0
    fi
    return 1
}

# -- Disable CoW on the Steam library (Btrfs) -------------
# Large, frequently-rewritten game files fragment badly under Btrfs copy-on-write.
# +C only affects NEW files and only when set on an EMPTY dir, so create the
# library dir now (before Steam's first run) and flag it so every game inherits
# nodatacow. Existing files are never retro-converted; safe if not on Btrfs.
if [ ! -d "$HOME/.local/share/Steam" ] && { stat -f -c %T "$HOME" 2>/dev/null | grep -qi btrfs; }; then
    mkdir -p "$HOME/.local/share/Steam"
    if chattr +C "$HOME/.local/share/Steam" 2>/dev/null; then
        log "Btrfs CoW disabled on Steam library (less fragmentation, faster game IO)"
    else
        warn "Could not set nodatacow on the Steam library - continuing"
    fi
fi

# ========================================================
# PHASE 1 - Offline setup (no internet required)
# Runs immediately so the desktop looks right straight away.
# ========================================================

# -- GNOME settings ---------------------------------------
info "Applying GNOME settings..."
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' 2>/dev/null || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita-dark' 2>/dev/null || true
gsettings set org.gnome.desktop.interface icon-theme 'NiiX' 2>/dev/null || true
# Desktop: animations on, standard dynamic workspaces.
gsettings set org.gnome.desktop.interface enable-animations true 2>/dev/null || true
gsettings set org.gnome.desktop.sound event-sounds false 2>/dev/null || true
# Disable GNOME's donation-reminder popup
gsettings set org.gnome.settings-daemon.plugins.housekeeping donation-reminder-enabled false 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
gsettings set org.gnome.settings-daemon.plugins.power power-button-action 'interactive' 2>/dev/null || true
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing' 2>/dev/null || true
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-battery-type 'suspend' 2>/dev/null || true
gsettings set org.gnome.mutter dynamic-workspaces false 2>/dev/null || true
gsettings set org.gnome.desktop.wm.preferences num-workspaces 1 2>/dev/null || true
gsettings set org.gnome.mutter edge-tiling true 2>/dev/null || true
gsettings set org.gnome.desktop.interface enable-hot-corners false 2>/dev/null || true
gsettings set org.gnome.desktop.notifications show-in-lock-screen false 2>/dev/null || true
# Pink accent (NiiX).
gsettings set org.gnome.desktop.interface accent-color 'pink' 2>/dev/null || true
gsettings set org.gnome.desktop.interface show-battery-percentage true 2>/dev/null || true
gsettings set org.gnome.desktop.interface clock-show-weekday true 2>/dev/null || true
# Windows-style titlebar buttons (min/max/close) - matches dash-to-panel look.
gsettings set org.gnome.desktop.wm.preferences button-layout 'appmenu:minimize,maximize,close' 2>/dev/null || true

# ddterm (drop-down terminal) is bound to F12 and Ctrl+Alt+T in PHASE 3,
# after the extension is installed from the AUR.

# Enable AppIndicator (tray icons). The panel and auto-rotate
# extensions are installed + enabled in PHASE 3 (need internet).
gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com 2>/dev/null || true
log "GNOME settings applied"

# -- Wi-Fi hotspot: optional auto mode -------------------
# Kept up from boot by a root service (niix-hotspot-watchdog): internet checked
# every 2 min (every 5s while offline), hotspot restored as soon as the
# internet is back. Change later with: niix-hotspot auto on|off
echo ""
read -rp "Keep a Wi-Fi hotspot up automatically (from boot, restored after internet outages)? Needs Ethernet or USB tethering as the internet source. [y/N]: " HOTSPOT_ENABLE
if [[ "$HOTSPOT_ENABLE" =~ ^[Yy]$ ]]; then
    while true; do
        read -rp "Hotspot SSID [NiiX-Arch]: " HOTSPOT_SSID
        HOTSPOT_SSID="${HOTSPOT_SSID:-NiiX-Arch}"
        [ "$(printf '%s' "$HOTSPOT_SSID" | wc -c)" -le 32 ] && break
        echo "SSID must be at most 32 bytes - try again."
    done
    while true; do
        read -rsp "Hotspot password, 8-63 characters [leave blank for a random one]: " HOTSPOT_PASS
        echo ""
        [ -z "$HOTSPOT_PASS" ] && break
        [ "${#HOTSPOT_PASS}" -ge 8 ] && [ "${#HOTSPOT_PASS}" -le 63 ] && break
        echo "Password must be 8-63 characters - try again."
    done
    # Saved only in NetworkManager's root-only profile. 'setup' does NOT bring
    # the hotspot up: switching Wi-Fi to hotspot mode now could cut the
    # internet the rest of this first-boot needs. It comes up after the reboot.
    if sudo niix-hotspot setup "$HOTSPOT_SSID" "$HOTSPOT_PASS"; then
        # Exactly the command a user runs later - one code path, one behaviour.
        if sudo niix-hotspot auto on; then
            log "Hotspot '${HOTSPOT_SSID}' auto mode ON (show password: niix-hotspot password)"
        else
            warn "Could not enable hotspot auto mode - enable later: niix-hotspot auto on"
        fi
    else
        warn "Hotspot setup failed (no Wi-Fi adapter?) - set it up later: niix-hotspot start [SSID] [PASSWORD]"
    fi
    unset HOTSPOT_PASS
else
    log "Hotspot auto mode skipped - turn it on any time with: niix-hotspot start [SSID] [PASSWORD]"
    log "  (that also enables auto mode, so it behaves exactly as if chosen here)"
fi

# -- vim: not part of NiiX (GNOME Text Editor + nano are the editors) --------
# Only removed when nothing else needs it - an -Rns that would cascade into
# other packages is not worth it for a text editor.
if pacman -Q vim &>/dev/null; then
    if pacman -Qi vim 2>/dev/null | grep -q '^Required By *: *None'; then
        sudo pacman -Rns --noconfirm vim >/dev/null 2>&1 \
            && log "vim removed (Text Editor and nano are the defaults)" \
            || warn "Could not remove vim - harmless, it is just unused"
    else
        info "vim kept: another installed package requires it"
    fi
fi

# -- Bluetooth - ensure service is running ----------------
# Safety net: enables+starts bluetooth even on systems installed
# before the chroot fix. Harmless if already enabled.
sudo systemctl enable --now bluetooth 2>/dev/null || true

# -- Performance profile -----------------------------------
sudo powerprofilesctl set performance 2>/dev/null || true

# -- Automatic dGPU for games on hybrid systems ------------
# Tag the game launchers so GNOME runs them on the discrete ("most powerful") GPU
# automatically (via switcheroo-control). NVIDIA now always gets a PROPRIETARY,
# offload-capable driver from chwd (see the GPU-driver step in PHASE 3), so this
# applies to every hybrid system. If chwd has to fall back to nouveau, PHASE 3
# strips these tags again so nothing points at a driver that isn't there.
if [ "$IS_HYBRID" = "true" ]; then
    info "Configuring game launchers to use the discrete GPU automatically..."
    # switcheroo-control was previously only installed in the NVIDIA-specific
    # branches later in this script (PHASE 3) - on an AMD-hybrid system the
    # package never got installed at all, so the enable/start below silently
    # no-op'd against a unit that didn't exist. Install it here for every
    # hybrid system regardless of vendor; harmless if PHASE 3 installs it again.
    pac_retry -S --noconfirm --needed switcheroo-control || true
    sudo systemctl enable --now switcheroo-control.service 2>/dev/null || true
    mkdir -p "$HOME/.local/share/applications"
    for app in net.lutris.Lutris lutris heroic com.heroicgameslauncher.hgl; do
        src="/usr/share/applications/${app}.desktop"
        [ -f "$src" ] || continue
        dst="$HOME/.local/share/applications/${app}.desktop"
        cp "$src" "$dst"
        grep -q '^PrefersNonDefaultGPU=' "$dst" || \
            sed -i '0,/^\[Desktop Entry\]/s//[Desktop Entry]\nPrefersNonDefaultGPU=true\nX-KDE-RunOnDiscreteGpu=true/' "$dst"
    done
    update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
    log "Game launchers set to run on the discrete GPU"
fi

# -- Gamemode config ---------------------------------------
mkdir -p "$HOME/.config"
cat > "$HOME/.config/gamemode.ini" <<'GMEOF'
[general]
renice = 10
ioprio = 0
inhibit_screensaver = 1

[gpu]
apply_gpu_optimisations = accept-responsibility
amd_performance_level = high

[cpu]
pin_cores = yes

[custom]
start = notify-send "GameMode" "Gaming optimizations ON"
end = notify-send "GameMode" "Gaming optimizations OFF"
GMEOF

# -- Bluetooth headset audio (WirePlumber) -----------------
# Roles and codecs are deliberately NOT restricted here. WirePlumber's defaults
# already enable A2DP (music) + HFP (microphone) + BAP (LE Audio) and every
# compiled codec (aptX, aptX-HD, LDAC, AAC, LC3, ...). The old config forced
# A2DP-only roles - which silently disabled every headset microphone - and a
# codec whitelist that blocked aptX and LC3 headsets down to plain SBC.
# What we DO set:
#   enable-msbc      -> 16 kHz wideband mic instead of 8 kHz telephone quality
#   enable-sbc-xq    -> higher-bitrate SBC for headsets without a better codec
#   enable-hw-volume -> volume keys control the headset's own amplifier
#   auto-connect     -> bring up both music and mic profiles on connect
mkdir -p "$HOME/.config/wireplumber/wireplumber.conf.d"
cat > "$HOME/.config/wireplumber/wireplumber.conf.d/51-bluetooth-headsets.conf" <<'BTEOF'
monitor.bluez.properties = {
  bluez5.enable-msbc = true
  bluez5.enable-sbc-xq = true
  bluez5.enable-hw-volume = true
  bluez5.hfphsp-backend = "native"
  bluez5.auto-connect = [ hfp_hf hsp_hs a2dp_sink ]
}
BTEOF
# Remove the old restrictive config if this is a re-run
rm -f "$HOME/.config/wireplumber/wireplumber.conf.d/51-bluetooth-fix.conf"

# -- fastfetch on terminal open ----------------------------
grep -qx 'fastfetch' "$HOME/.bashrc" 2>/dev/null || echo 'fastfetch' >> "$HOME/.bashrc"

# -- Steam warm-start + one-time first-launch notice ------
# Steam bundles a Chromium browser (steamwebhelper) that makes cold starts slow.
#  1) Pre-load Steam minimized to the tray ~30s after login. The delay is a
#     plain sleep so it never competes with the desktop starting up and works
#     on any session manager, with no reliance on X-GNOME-Autostart-Delay.
#  2) A PATH wrapper (terminal) + desktop override (icon clicks) shows a
#     one-time popup explaining the first-launch wait, then hands off to the
#     real Steam. The -silent autostart bypasses the wrapper entirely, so the
#     notice only ever appears on the user's own first real launch.
if [ -x /usr/bin/steam ]; then
    info "Configuring Steam warm-start and first-launch notice..."
    pac_retry -S --noconfirm --needed zenity libnotify vulkan-tools || true

    # -- Steam UI GPU-conformance workaround (auto-detected) --
    # Brand-new GPUs (e.g. RDNA 4 / RX 9000) whose Mesa/RADV driver still reports
    # itself non-conformant crash steamwebhelper's Chromium GPU process in a loop
    # - the tray icon appears but no window ever paints. -cef-disable-gpu renders
    # ONLY Steam's UI on CPU; games are completely unaffected (they use Vulkan via
    # Proton as normal). Two independent signals; if either fires, apply it:
    #   1. fast-path  - GPU_GEN is a known-too-new generation (rdna4)
    #   2. runtime probe - the actually-installed Vulkan driver reports a
    #      non-conformant (0.0.0.0) conformanceVersion for the GPU
    # Self-correcting: once Mesa matures and the driver reports conformant, a
    # future reinstall's probe won't match and the flag won't be applied.
    _needs_cef_workaround() {
        case "$GPU_GEN" in rdna4) return 0 ;; esac
        if command -v vulkaninfo >/dev/null 2>&1; then
            vulkaninfo 2>/dev/null \
                | grep -iE "conformanceVersion" \
                | grep -qE "0\.0\.0\.0" && return 0
        fi
        return 1
    }
    mkdir -p "$HOME/.local/share/Steam"
    if _needs_cef_workaround; then
        # Append idempotently - never clobber other flags the user may have added.
        touch "$HOME/.local/share/Steam/steam_dev.cfg"
        grep -qxF -- '-cef-disable-gpu' "$HOME/.local/share/Steam/steam_dev.cfg" \
            || echo '-cef-disable-gpu' >> "$HOME/.local/share/Steam/steam_dev.cfg"
        log "Steam: UI GPU disabled (new-GPU driver not yet conformant) - games unaffected"
    else
        log "Steam: GPU UI left enabled (driver reports conformant)"
    fi

    # -- Per-hardware GPU environment for Steam ------------
    # On hybrid systems, Steam (and therefore every game it launches) is started
    # with the PRIME offload environment for the detected driver stack, so games
    # render on the dGPU instead of the APU/iGPU with zero per-game setup.
    # Written as a sourceable file so users can inspect/edit/disable it. If chwd has
    # to fall back to nouveau (proprietary driver unavailable), the GPU-driver step in
    # PHASE 3 removes this file again so Steam runs on the iGPU rather than aiming at a
    # driver that isn't there.
    sudo mkdir -p /etc/niix
    if [ "$IS_HYBRID" = "true" ]; then
        if [ "$OFFLOAD_MODE" = "nvidia" ]; then
            sudo tee /etc/niix/steam-env > /dev/null <<'SENVEOF'
# NiiX: PRIME render offload for Steam and all games it launches (NVIDIA proprietary).
# Sourced by /usr/local/bin/steam and /usr/local/bin/gpu-run. Delete to disable.
export __NV_PRIME_RENDER_OFFLOAD=1
export __GLX_VENDOR_LIBRARY_NAME=nvidia
export __VK_LAYER_NV_optimus=NVIDIA_only
SENVEOF
            log "Steam: games will render on the NVIDIA dGPU (PRIME offload)"
        else
            sudo tee /etc/niix/steam-env > /dev/null <<SENVEOF
# NiiX: PRIME render offload for Steam and all games it launches (Mesa: AMD/Intel dGPU).
# Sourced by /usr/local/bin/steam and /usr/local/bin/gpu-run. Delete to disable.
export DRI_PRIME=${DRI_PRIME_TAG}
SENVEOF
            log "Steam: games will render on the dGPU (DRI_PRIME=${DRI_PRIME_TAG})"
        fi
    else
        sudo rm -f /etc/niix/steam-env
    fi

    # -- gpu-run: launch ANY app on the discrete GPU (vendor-agnostic prime-run) --
    # Usage: gpu-run <command> [args...]   e.g.  gpu-run blender
    # Reuses the same offload environment as the game launchers. On single-GPU
    # systems there is nothing to offload, so it just runs the command unchanged.
    sudo tee /usr/local/bin/gpu-run > /dev/null <<'GPRUNEOF'
#!/bin/bash
[ -f /etc/niix/steam-env ] && . /etc/niix/steam-env
exec "$@"
GPRUNEOF
    sudo chmod +x /usr/local/bin/gpu-run

    mkdir -p "$HOME/.config/autostart"
    cat > "$HOME/.config/autostart/niix-steam-warmstart.desktop" <<'STEAMAUTOEOF'
[Desktop Entry]
Type=Application
Name=Steam (pre-load, minimized)
Comment=Added by NiiX first-boot - delete this file to disable
Exec=sh -c 'sleep 30; exec /usr/local/bin/steam -silent >/dev/null 2>&1'
X-GNOME-Autostart-enabled=true
STEAMAUTOEOF

    sudo tee /usr/local/bin/steam > /dev/null <<'STEAMWRAPEOF'
#!/bin/bash
# NiiX wrapper: applies the per-hardware GPU environment, shows a one-time
# first-launch notice, then hands off to the real Steam.
# Remove with: sudo rm /usr/local/bin/steam ~/.local/share/applications/steam.desktop
# GPU env applies to every entry path (icon, terminal, warm-start) so games
# always inherit it, whichever instance ends up serving the launch.
[ -f /etc/niix/steam-env ] && . /etc/niix/steam-env
for arg in "$@"; do
    [ "$arg" = "-silent" ] && exec /usr/bin/steam "$@"
done
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/niix"
FLAG="$STATE_DIR/steam-notice-shown"
if [ ! -e "$FLAG" ]; then
    mkdir -p "$STATE_DIR" && touch "$FLAG"
    MSG="Steam can take a little while to appear - especially this very first launch, while it downloads client updates and finishes setting itself up.

This is normal: Steam ships with its own built-in browser that has to start from cold each time it launches.

One-time setup for the best experience: in Steam go to Settings -> Compatibility, turn on \"Enable Steam Play for all other titles\", and pick Proton-CachyOS (already installed; GE-Proton is there too as an alternative) as the default tool. Steam will restart once.

Tip: NiiX pre-loads Steam in the background shortly after each login, so future launches are much faster."
    if command -v zenity >/dev/null 2>&1; then
        zenity --info --title="Steam is starting..." --width=420 --text="$MSG" &
    elif command -v notify-send >/dev/null 2>&1; then
        notify-send -u normal "Steam is starting..." "$MSG" &
    fi
fi
exec /usr/bin/steam "$@"
STEAMWRAPEOF
    sudo chmod +x /usr/local/bin/steam

    # Desktop override: Arch's steam.desktop uses an absolute Exec path, so the
    # PATH wrapper alone would not catch icon clicks. Overriding with the same
    # file ID keeps the dash pin working and survives steam package updates.
    if [ -f /usr/share/applications/steam.desktop ]; then
        mkdir -p "$HOME/.local/share/applications"
        sed 's|^Exec=/usr/bin/steam|Exec=/usr/local/bin/steam|' \
            /usr/share/applications/steam.desktop \
            > "$HOME/.local/share/applications/steam.desktop"
        update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
    fi
    log "Steam: warm-start at login + one-time first-launch notice"
else
    warn "Steam not found - skipping warm-start setup"
fi

# -- fastfetch config --------------------------------------
mkdir -p "$HOME/.config/fastfetch"
cat > "$HOME/.config/fastfetch/config.jsonc" <<'FFEOF'
{
  "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
  "logo": {
    "type": "builtin",
    "source": "arch",
    "color": { "1": "38;5;118", "2": "38;5;205" }
  },
  "display": { "separator": " -> ", "color": { "keys": "38;5;118", "title": "38;5;205" } },
  "modules": [
    { "type": "title", "format": "{user-name}@niixarch" }, "separator",
    { "type": "os", "key": "OS", "format": "NiiX Arch" },
    { "type": "kernel", "key": "Kernel" }, { "type": "uptime", "key": "Uptime" },
    { "type": "packages", "key": "Packages" }, { "type": "shell", "key": "Shell" },
    { "type": "de", "key": "DE" }, { "type": "terminal", "key": "Terminal" }, "separator",
    { "type": "cpu", "key": "CPU" }, { "type": "gpu", "key": "GPU" },
    { "type": "memory", "key": "Memory" }, { "type": "disk", "key": "Disk", "folders": "/" }
  ]
}
FFEOF
log "Offline config done"

# ========================================================
# PHASE 2 - Banner + network wait
# ========================================================

echo
    echo
    echo -e "  ${PINK}========================================${NC}"
    echo -e "  ${GREEN}  niiXa${PINK}OS${NC}   ${WHITE}Arch Installer${NC}"
    echo -e "  ${PINK}========================================${NC}"
    echo
echo
echo -e "  ${WHITE}${BOLD}First-Boot Setup${NC}"
echo -e "  ${PINK}------------------------------------------------${NC}"
echo -e "  ${CYAN}-${NC}  CachyOS kernel - AUR packages - Proton-CachyOS - NiiX desktop - wallpaper"
echo

# -- Wait for network --------------------------------------
info "Checking network connectivity..."

_nm_connected() {
    nmcli -t -f CONNECTIVITY general 2>/dev/null | grep -q "^full$"
}

if _nm_connected; then
    log "Network ready"
else
    info "Waiting for network connectivity (no timeout - will wait as long as needed)..."
    attempt=0
    until _nm_connected; do
        attempt=$(( attempt + 1 ))
        echo -ne "\r  -  Waiting for network... (attempt $attempt)"
        sleep 3
    done
    echo ""
    log "Network ready"
fi

# ========================================================
# PHASE 3 - Internet-dependent setup
# ========================================================

# -- Pre-storm restore point (Btrfs) ----------------------
# Take a read-only snapshot of the freshly-installed base RIGHT BEFORE the risky
# online storm (kernel swap, AUR builds, GPU drivers). The @clean snapshot made
# later is the "known-good final state"; this one covers the window before it
# exists, so a wedged storm can be rolled back. Snapshots are instant and nearly
# free on Btrfs. Non-fatal and taken once (survives a resumed run).
if ! step_done pre-apps-snapshot && stat -f -c %T / 2>/dev/null | grep -qi btrfs; then
    SNAP="/.snapshots/pre-apps-$(date +%Y%m%d-%H%M%S)"
    if sudo btrfs subvolume snapshot -r / "$SNAP" 2>/dev/null; then
        mark_step pre-apps-snapshot
        log "Pre-install restore point: $SNAP"
        info "Roll back later from a live ISO or the 'clean state' boot entry if needed."
    else
        warn "Could not create pre-install snapshot - continuing"
    fi
fi

# -- Wallpaper ---------------------------------------------
# curl's own --retry only covers HTTP/timeout errors, NOT DNS resolution
# failures (exit code 6) - which is exactly the failure class that's bitten
# this install before. Wrap it in an outer retry loop too.
info "Downloading NiiX Arch wallpaper..."
mkdir -p "$HOME/Pictures"
WALLPAPER_OK=false
for n in 1 2 3; do
    if curl -fL --retry 3 --retry-delay 2 -o "$HOME/Pictures/niix-background.png" \
        "https://niixa.org/niix-background.png" 2>/dev/null \
        && [ -s "$HOME/Pictures/niix-background.png" ]; then
        WALLPAPER_OK=true
        break
    fi
    [ "$n" -lt 3 ] && sleep 3
done
if [ "$WALLPAPER_OK" = "true" ]; then
    log "Wallpaper downloaded"
else
    warn "Could not download wallpaper after retries - set manually later"
fi

if [ -f "$HOME/Pictures/niix-background.png" ]; then
    gsettings set org.gnome.desktop.background picture-uri \
        "file://$HOME/Pictures/niix-background.png" 2>/dev/null || true
    gsettings set org.gnome.desktop.background picture-uri-dark \
        "file://$HOME/Pictures/niix-background.png" 2>/dev/null || true
    gsettings set org.gnome.desktop.background picture-options 'zoom' 2>/dev/null || true
    log "Wallpaper set"
fi

# -- NiiX desktop (panel + start menu + flat icons) -------
# dash-to-panel (bottom taskbar) + ArcMenu (start menu, NiiX logo) + Screen Autorotate
# Screen Autorotate (360 deg laptops). Extensions are pulled
# from extensions.gnome.org at the version matching the running GNOME Shell,
# so this stays correct as GNOME updates.
info "Installing NiiX desktop extensions..."
SHELL_VER=$(gnome-shell --version 2>/dev/null | grep -oE '[0-9]+' | head -1)
EXT_DIR="$HOME/.local/share/gnome-shell/extensions"
mkdir -p "$EXT_DIR"

ego_install() {
    # $1 = extension UUID; installs the version compatible with $SHELL_VER.
    # Retries through transient extensions.gnome.org/CDN blips instead of
    # giving up after one failed curl - this exact failure killed
    # dash-to-panel on a prior run (single fetch attempt, no retry).
    local uuid="$1" meta url n
    for n in 1 2 3; do
        meta=$(curl -sf "https://extensions.gnome.org/extension-info/?uuid=${uuid}&shell_version=${SHELL_VER}" 2>/dev/null)
        if [ -n "$meta" ]; then
            url=$(printf '%s' "$meta" | python3 -c 'import sys,json
try:
    print(json.load(sys.stdin).get("download_url","") or "")
except Exception:
    print("")' 2>/dev/null)
            if [ -n "$url" ] \
                && curl -sfL -o "/tmp/${uuid}.zip" "https://extensions.gnome.org${url}" 2>/dev/null; then
                gnome-extensions install --force "/tmp/${uuid}.zip" 2>/dev/null
                local rc=$?
                rm -f "/tmp/${uuid}.zip"
                return $rc
            fi
        fi
        [ "$n" -lt 3 ] && sleep 3
    done
    return 1
}

EXT_LIST=(
    dash-to-panel@jderose9.github.com
    arcmenu@arcmenu.com
    screen-rotate@shyzus.github.io
    tiling-assistant@leleat-on-github
)
ext_total=${#EXT_LIST[@]}; ext_i=0; ext_failed=()
ui_bar 0 "$ext_total" "extensions"
for ext in "${EXT_LIST[@]}"; do
    ego_install "$ext" >>"$LOG" 2>&1 || ext_failed+=("$ext")
    ext_i=$((ext_i + 1))
    ui_bar "$ext_i" "$ext_total" "${ext%%@*}"
done
if [ "${#ext_failed[@]}" -eq 0 ]; then
    log "Extensions installed: dash-to-panel, ArcMenu, screen-rotate, Tiling Assistant"
else
    for e in "${ext_failed[@]}"; do warn "Could not fetch $e from extensions.gnome.org"; done
    # Fallback: dash-to-panel and ArcMenu are also packaged (signed) in Arch's
    # extra repo under the same UUIDs, installed system-wide - so the NiiX
    # panel still comes up if extensions.gnome.org is down or has no build
    # for this Shell version yet. (screen-rotate has its own fallback below.)
    for e in "${ext_failed[@]}"; do
        case "$e" in
            dash-to-panel@jderose9.github.com) fb_pkg=gnome-shell-extension-dash-to-panel ;;
            arcmenu@arcmenu.com)               fb_pkg=gnome-shell-extension-arc-menu ;;
            *)                                 fb_pkg="" ;;
        esac
        [ -n "$fb_pkg" ] || continue
        if pac_retry -S --noconfirm --needed "$fb_pkg"; then
            log "Installed $e from the Arch repos ($fb_pkg)"
        else
            warn "$e unavailable from both extensions.gnome.org and the repos - panel will be vanilla GNOME until it is installed"
        fi
    done
fi

# Guaranteed source fallback for the 360 deg auto-rotate extension (requested).
if [ ! -d "$EXT_DIR/screen-rotate@shyzus.github.io" ]; then
    info "Installing screen-rotate from source (fallback)..."
    rm -rf /tmp/screen-autorotate
    if git clone --depth 1 \
        https://github.com/shyzus/gnome-shell-extension-screen-autorotate.git \
        /tmp/screen-autorotate 2>/dev/null; then
        cp -r /tmp/screen-autorotate/screen-rotate@shyzus.github.io "$EXT_DIR/" 2>/dev/null
        [ -d "$EXT_DIR/screen-rotate@shyzus.github.io/schemas" ] && \
            glib-compile-schemas "$EXT_DIR/screen-rotate@shyzus.github.io/schemas" 2>/dev/null || true
        rm -rf /tmp/screen-autorotate
        log "screen-rotate installed from source"
    fi
fi

# Source fallback for Tiling Assistant (quarter-screen snapping), pinned to a
# release tag. Its upstream build step is only schema compilation (plus
# optional translations), so a checked-out copy is a complete extension.
if [ ! -d "$EXT_DIR/tiling-assistant@leleat-on-github" ]; then
    info "Installing Tiling Assistant from source (fallback)..."
    rm -rf /tmp/tiling-assistant
    if git clone --depth 1 --branch v55 \
        https://github.com/Leleat/Tiling-Assistant.git /tmp/tiling-assistant 2>/dev/null \
        && [ -f /tmp/tiling-assistant/tiling-assistant@leleat-on-github/metadata.json ]; then
        mkdir -p "$EXT_DIR"
        cp -r /tmp/tiling-assistant/tiling-assistant@leleat-on-github "$EXT_DIR/"
        glib-compile-schemas "$EXT_DIR/tiling-assistant@leleat-on-github/schemas" 2>/dev/null || true
        log "Tiling Assistant installed from source"
    else
        warn "Tiling Assistant unavailable - GNOME's built-in snapping (halves + maximise) still works"
    fi
    rm -rf /tmp/tiling-assistant
fi

# -- Configure the NiiX desktop via dconf -----------------
info "Configuring NiiX panel..."

# Dash to Panel - bottom Windows-style taskbar
dconf load /org/gnome/shell/extensions/dash-to-panel/ <<'DTP'
[/]
panel-positions='{"0":"BOTTOM"}'
panel-sizes='{"0":44}'
panel-lengths='{"0":100}'
panel-anchors='{"0":"LEFT"}'
multi-monitors=false
show-apps-button=false
panel-element-positions='{"0":[{"element":"showAppsButton","visible":false,"position":"stackedTL"},{"element":"activitiesButton","visible":false,"position":"stackedTL"},{"element":"leftBox","visible":true,"position":"stackedTL"},{"element":"taskbar","visible":true,"position":"stackedTL"},{"element":"centerBox","visible":true,"position":"stackedBR"},{"element":"rightBox","visible":true,"position":"stackedBR"},{"element":"dateMenu","visible":true,"position":"stackedBR"},{"element":"systemMenu","visible":false,"position":"stackedBR"},{"element":"desktopButton","visible":true,"position":"stackedBR"}]}'
dot-position='BOTTOM'
show-appmenu=false
group-apps=true
isolate-workspaces=false
appicon-margin=4
appicon-padding=4
animate-appicon-hover=false
show-showdesktop-button=true
trans-use-custom-opacity=true
trans-panel-opacity=0.9
DTP


# ArcMenu - Windows-style start menu with the NiiX logo as the button.
# The only menu on the left; Apps Menu (its temporary built-in replacement)
# is not enabled, so this is the sole panel button before the pinned icons.
dconf load /org/gnome/shell/extensions/arcmenu/ <<'ARCM'
[/]
menu-layout='Redmond'
position-in-panel='Left'
menu-button-appearance='Icon_Text'
custom-menu-button-text='NiiX'
menu-button-icon='Custom_Icon'
custom-menu-button-icon='/usr/share/pixmaps/niixarch-logo.png'
custom-menu-button-icon-size=28.0
menu-button-position-offset=0
multi-monitor=false
enable-menu-hotkey=true
menu-hotkey='Super_L'
ARCM

# Screen Autorotate - sane defaults for 360 deg convertibles
dconf load /org/gnome/shell/extensions/screen-rotate/ <<'ROT'
[/]
flip-orientation=false
hide-lock-rotate=false
debug-logging=false
ROT

# Window snapping (Tiling Assistant): drag to the top edge = full screen,
# left/right edge = half, any corner = quarter. It turns GNOME's own edge
# tiling off while it runs and back on if it is ever disabled.
#   enable-layout-picker=false          no Windows-11-style drop-down at the top
#   enable-tiling-popup=false           no "pick a window for the other half"
#   enable-raise-tile-group=false       clicking a tiled window raises only it
#   toggle-maximize-tophalf-timer=max   holding at the top never turns
#                                       "full screen" into "top half"
dconf load /org/gnome/shell/extensions/tiling-assistant/ <<'TILING'
[/]
enable-layout-picker=false
enable-tiling-popup=false
enable-raise-tile-group=false
toggle-maximize-tophalf-timer=2147483647
TILING

# Enable every extension + pin apps (Lutris included) for the next login
dconf load /org/gnome/shell/ <<'SHELLD'
[/]
disable-user-extensions=false
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-panel@jderose9.github.com', 'arcmenu@arcmenu.com', 'screen-rotate@shyzus.github.io', 'tiling-assistant@leleat-on-github', 'ddterm@amezin.github.com', 'system-monitor@gnome-shell-extensions.gcampax.github.com']
favorite-apps=['org.gnome.Nautilus.desktop', 'helium.desktop', 'org.gnome.TextEditor.desktop', 'net.lutris.Lutris.desktop', 'steam.desktop', 'com.heroicgameslauncher.hgl.desktop', 'com.ktechpit.whatsie.desktop', 'signal-desktop.desktop', 'com.stremio.Stremio.desktop']
SHELLD

# System Monitor is bundled with gnome-shell-extensions (already installed) -
# no download needed, just enable it. Same belt-and-suspenders as below:
# dconf load above only applies after the reboot at the end of this script,
# this flips the live setting too.
gnome-extensions enable system-monitor@gnome-shell-extensions.gcampax.github.com 2>/dev/null || true

log "NiiX desktop configured (fully applies after the reboot below)"

# -- ArcMenu + Dash-to-panel re-apply: apply on the NEXT login, then self-remove -----
# Both ArcMenu and dash-to-panel re-initialise their own settings the first
# time they actually load, which silently wipes what was written above (the
# NiiX branding, the hidden Show Apps button, panel anchoring). A one-shot
# autostart re-applies both once GNOME Shell and the extensions are actually
# running, then deletes itself.
mkdir -p "$HOME/.local/bin" "$HOME/.config/autostart"
cat > "$HOME/.local/bin/niix-panel-once.sh" <<'PANELEOF'
#!/bin/bash
# Re-apply repeatedly over ~40s rather than once after a fixed sleep - this
# is a race against ArcMenu/dash-to-panel's own first-load settings reset,
# and that reset's timing isn't guaranteed (extension compilation, slower
# hardware, etc). Writing several times outlasts it regardless of exactly
# when it happens, instead of gambling on one fixed delay being long enough.
apply_niix_panel_settings() {
    AM=/org/gnome/shell/extensions/arcmenu
    dconf write $AM/menu-button-appearance "'Icon_Text'"
    dconf write $AM/custom-menu-button-text "'NiiX'"
    dconf write $AM/menu-button-icon "'Custom_Icon'"
    dconf write $AM/custom-menu-button-icon "'/usr/share/pixmaps/niixarch-logo.png'"
    dconf write $AM/custom-menu-button-icon-size 28.0
    dconf write $AM/enable-menu-hotkey "true"
    dconf write $AM/menu-hotkey "'Super_L'"
    DTP=/org/gnome/shell/extensions/dash-to-panel
    dconf write $DTP/multi-monitors "false"
    # Per-monitor dicts (panel-element-positions, panel-anchors, ...) are NOT
    # written here: replacing them with a {"0": ...} dict deletes the
    # monitor-ID entries dash-to-panel keeps. niix-panel-fix handles them.
}
for i in 1 2 3 4 5 6 7 8 9 10; do
    sleep 4
    apply_niix_panel_settings
done
rm -f "$HOME/.config/autostart/niix-panel.desktop" "$HOME/.local/bin/niix-panel-once.sh"
PANELEOF
chmod +x "$HOME/.local/bin/niix-panel-once.sh"
cat > "$HOME/.config/autostart/niix-panel.desktop" <<PANELDT
[Desktop Entry]
Type=Application
Name=NiiX Panel Fixup (one-shot)
Exec=$HOME/.local/bin/niix-panel-once.sh
X-GNOME-Autostart-enabled=true
NoDisplay=true
PANELDT

# -- Hide dash-to-panel's 9-dot "Show Applications" button (every login) --
# ArcMenu is the start menu; the GNOME app-grid button must not appear too.
# dash-to-panel stores per-monitor layout under each monitor's ID
# (vendor-serial) and falls back to defaults - with Show Apps VISIBLE - for any
# monitor without an entry. This helper computes the IDs exactly like
# dash-to-panel (via Mutter's DisplayConfig), forces Show Apps + Activities
# hidden for every monitor, and writes only when something differs. It runs
# for ~45s at each login to outlast dash-to-panel's own start-up save.
sudo tee /usr/local/bin/niix-panel-fix > /dev/null <<'NIIXPANELFIX'
#!/usr/bin/env python3
# niix-panel-fix - keep dash-to-panel's Show Applications (9-dot) button hidden
#   niix-panel-fix           apply once
#   niix-panel-fix --watch   apply repeatedly for ~45s (used at login)
import ast, json, subprocess, sys, time

BASE = "/org/gnome/shell/extensions/dash-to-panel/"
HIDE = ("showAppsButton", "activitiesButton")
NIIX_ELEMENTS = [
    {"element": "showAppsButton", "visible": False, "position": "stackedTL"},
    {"element": "activitiesButton", "visible": False, "position": "stackedTL"},
    {"element": "leftBox", "visible": True, "position": "stackedTL"},
    {"element": "taskbar", "visible": True, "position": "stackedTL"},
    {"element": "centerBox", "visible": True, "position": "stackedBR"},
    {"element": "rightBox", "visible": True, "position": "stackedBR"},
    {"element": "dateMenu", "visible": True, "position": "stackedBR"},
    {"element": "systemMenu", "visible": False, "position": "stackedBR"},
    {"element": "desktopButton", "visible": True, "position": "stackedBR"},
]
MISSING_DEFAULTS = {
    "panel-positions": "BOTTOM",
    "panel-sizes": 44,
    "panel-lengths": 100,
    "panel-anchors": "LEFT",
}


def dconf_read(key):
    try:
        out = subprocess.run(["dconf", "read", BASE + key], capture_output=True,
                             text=True, timeout=10).stdout.strip()
    except Exception:
        return None
    if not out:
        return {}
    try:
        data = json.loads(ast.literal_eval(out))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def dconf_write(key, value):
    text = json.dumps(value, separators=(",", ":"))
    gvariant = "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
    subprocess.run(["dconf", "write", BASE + key, gvariant], timeout=10,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def monitor_ids():
    # Same derivation as dash-to-panel's panelSettings.js: vendor-serial,
    # else the connector on a clash, else the logical-monitor index.
    try:
        from gi.repository import Gio
        bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        reply = bus.call_sync("org.gnome.Mutter.DisplayConfig",
                              "/org/gnome/Mutter/DisplayConfig",
                              "org.gnome.Mutter.DisplayConfig",
                              "GetCurrentState", None, None,
                              Gio.DBusCallFlags.NONE, 5000, None)
        logical = reply.unpack()[2]
    except Exception:
        return []
    ids, seen = [], set()
    for i, lm in enumerate(logical):
        try:
            connector, vendor, _product, serial = lm[5][0]
        except Exception:
            connector = vendor = serial = ""
        mid = "%s-%s" % (vendor, serial) if vendor and serial else str(i)
        if mid in seen:
            mid = connector if connector and connector not in seen else str(i)
        seen.add(mid)
        ids.append(mid)
    return ids


def fixed_elements(current):
    if not isinstance(current, list) or not all(isinstance(e, dict) for e in current):
        current = [dict(e) for e in NIIX_ELEMENTS]
    else:
        current = [dict(e) for e in current]
    names = [e.get("element") for e in current]
    if "showAppsButton" not in names:
        current.insert(0, {"element": "showAppsButton", "visible": False, "position": "stackedTL"})
    for e in current:
        if e.get("element") in HIDE:
            e["visible"] = False
    return current


def apply():
    ids = monitor_ids()
    positions = dconf_read("panel-element-positions")
    if positions is None:
        return
    keys = list(positions.keys())
    for k in ids or (["0"] if not keys else []):
        if k not in keys:
            keys.append(k)
    wanted = {k: fixed_elements(positions.get(k)) for k in keys}
    if wanted != positions:
        dconf_write("panel-element-positions", wanted)
    for key, default in MISSING_DEFAULTS.items():
        cur = dconf_read(key)
        if cur is None:
            continue
        new = dict(cur)
        for k in keys:
            new.setdefault(k, default)
        if new != cur:
            dconf_write(key, new)


def main():
    if "--watch" in sys.argv[1:]:
        for _ in range(15):
            apply()
            time.sleep(3)
    else:
        apply()


if __name__ == "__main__":
    main()
NIIXPANELFIX
sudo chmod 755 /usr/local/bin/niix-panel-fix
cat > "$HOME/.config/autostart/niix-panel-fix.desktop" <<'PANELFIXDT'
[Desktop Entry]
Type=Application
Name=NiiX Panel: hide Show Apps button
Exec=/usr/local/bin/niix-panel-fix --watch
X-GNOME-Autostart-enabled=true
NoDisplay=true
PANELFIXDT
/usr/local/bin/niix-panel-fix 2>/dev/null || true
log "Dash-to-panel: 9-dot Show Apps button hidden on every monitor (re-checked at each login)"

# -- initramfs hook sets ------------------------------------
# encrypt  (busybox) reads cryptdevice=   - what the installer's Limine entry uses
# sd-encrypt (systemd) reads rd.luks.name= - what the CachyOS Limine entry uses
# The hook set and the Limine cmdline must ALWAYS match, or nothing can unlock root.
NIIX_ENCRYPT_HOOKS='HOOKS=(base udev autodetect microcode modconf kms keyboard keymap block encrypt btrfs filesystems fsck)'
NIIX_SD_HOOKS='HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt btrfs filesystems fsck)'
niix_write_mkinitcpio() {
    printf 'MODULES=()\nBINARIES=()\nFILES=()\n%s\n' "$1" | sudo tee /etc/mkinitcpio.conf > /dev/null
}
niix_sd_hooks_active() {
    grep -Eq '^HOOKS=.*[( ]sd-encrypt[ )]' /etc/mkinitcpio.conf 2>/dev/null
}
# Captured into a variable before grepping: a pipe into grep -q can SIGPIPE
# lsinitcpio on large images and report a false failure.
niix_initrd_has_sd_crypt() {
    local listing
    [ -s "$1" ] || return 1
    listing=$(sudo lsinitcpio "$1" 2>/dev/null) || return 1
    grep -q 'systemd-cryptsetup' <<<"$listing"
}

# -- CachyOS kernel ----------------------------------------
if ! pacman -Q linux-cachyos &>/dev/null; then
    info "Setting up CachyOS repository..."
    CACHY_TMP=$(mktemp -d /tmp/niix-cachyos.XXXXXX) || CACHY_TMP=""
    if [ -n "$CACHY_TMP" ] \
        && curl -fL --retry 5 --retry-delay 3 -o "$CACHY_TMP/cachyos-repo.tar.xz" \
            https://mirror.cachyos.org/cachyos-repo.tar.xz \
        && tar xf "$CACHY_TMP/cachyos-repo.tar.xz" -C "$CACHY_TMP" \
        && [ -f "$CACHY_TMP/cachyos-repo/cachyos-repo.sh" ]; then
        # Upstream has no non-interactive flag, and piping 'yes' into it hangs
        # forever on any numbered provider prompt ("y" is never a valid number).
        # Instead, a private shim makes every pacman call it runs use
        # --noconfirm (pacman's own defaults), and stdin is closed so an
        # unexpected prompt fails fast instead of blocking the first boot.
        mkdir -p "$CACHY_TMP/shim"
        printf '#!/bin/sh\nexec /usr/bin/pacman --noconfirm "$@"\n' > "$CACHY_TMP/shim/pacman"
        chmod 755 "$CACHY_TMP/shim/pacman"
        ( cd "$CACHY_TMP/cachyos-repo" \
          && sudo env PATH="$CACHY_TMP/shim:/usr/local/sbin:/usr/local/bin:/usr/bin" \
                ./cachyos-repo.sh --install < /dev/null ) \
            || warn "cachyos-repo.sh reported an error - checking whether the repo was added anyway..."
    else
        warn "Could not download/extract the CachyOS repo installer."
    fi
    [ -n "$CACHY_TMP" ] && rm -rf "$CACHY_TMP"
    if ! grep -q '^\[cachyos' /etc/pacman.conf; then
        warn "CachyOS repository is not configured - staying on the stock kernel."
        warn "Re-run later: bash /usr/local/bin/niixarch-firstboot.sh"
    fi

    # cachyos-repo.sh runs its own full upgrade, which frequently dies on the
    # stale cdn77.cachyos.org mirror (a known CachyOS issue: 404 on glibc/gcc/lib32).
    # Re-rank to working mirrors so this run AND the user's future 'pacman -Syu' work.
    info "Ranking CachyOS mirrors (avoids the stale cdn77 default)..."
    pac_retry -Sy --noconfirm --needed cachyos-rate-mirrors || true
    if command -v cachyos-rate-mirrors >/dev/null 2>&1; then
        sudo cachyos-rate-mirrors || warn "Mirror ranking failed - continuing with current mirrors."
    fi

    # Drop the flaky cdn77 mirror and prefer archlinux.cachyos.org. rate-mirrors
    # ranks by raw speed, so cdn77 (quick to connect but 404s on individual
    # x86_64_v4 packages) can still land at the top and stall every pacman run
    # with retries. Comment it out of every CachyOS mirrorlist, float the
    # reliable primary to the front, and NEVER leave a list with zero servers.
    info "Preferring reliable CachyOS mirrors (dropping flaky cdn77)..."
    for ml in /etc/pacman.d/cachyos-mirrorlist \
              /etc/pacman.d/cachyos-v3-mirrorlist \
              /etc/pacman.d/cachyos-v4-mirrorlist \
              /etc/pacman.d/cachyos-znver4-mirrorlist; do
        [ -f "$ml" ] || continue
        tmp="/tmp/niix-cml.$$"
        if awk '
            {
              if ($0 ~ /^[[:space:]]*Server[[:space:]]*=[[:space:]]*https?:\/\/cdn77\.cachyos\.org/)     { cdn[++c]=$0; next }
              if ($0 ~ /^[[:space:]]*Server[[:space:]]*=[[:space:]]*https?:\/\/archlinux\.cachyos\.org/) { prim[++p]=$0; next }
              if ($0 ~ /^[[:space:]]*Server[[:space:]]*=/)                                               { oth[++o]=$0; next }
              head[++h]=$0
            }
            END {
              for (i=1;i<=h;i++) print head[i]
              if (p + o > 0) {
                for (i=1;i<=p;i++) print prim[i]
                for (i=1;i<=o;i++) print oth[i]
                for (i=1;i<=c;i++) print "#" cdn[i] "  # disabled by niixaOS (flaky cdn77)"
              } else {
                for (i=1;i<=c;i++) print cdn[i]
              }
            }' "$ml" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
            sudo cp "$tmp" "$ml" && log "Preferred reliable mirrors in $(basename "$ml")"
        fi
        rm -f "$tmp"
    done
    info "Completing CachyOS system upgrade..."
    pac_retry -Syu --noconfirm \
        || warn "System upgrade incomplete - run 'sudo cachyos-rate-mirrors && sudo pacman -Syu' once mirrors sync."

    info "Installing CachyOS kernel..."
    if grep -q '^\[cachyos' /etc/pacman.conf; then
        pac_retry -Sy --noconfirm --needed linux-cachyos linux-cachyos-headers \
            || warn "CachyOS kernel install failed - staying on the stock linux kernel for now."
    fi
    pacman -Q linux-cachyos &>/dev/null && log "CachyOS kernel installed"
else
    log "CachyOS kernel already installed"
fi

# -- Switch initramfs to systemd hooks (only with the CachyOS kernel present) --
# Runs on every (re-)run until it has happened, independent of the install step
# above. A failed rebuild is reverted immediately so the stock kernel - which
# Limine still boots with cryptdevice= at this point - always stays bootable.
# No GPU modules are forced here: chwd (below) installs the driver and its
# pacman hook rebuilds the initramfs for the detected topology.
if pacman -Q linux-cachyos &>/dev/null && ! niix_sd_hooks_active; then
    info "Switching to systemd initramfs hooks for CachyOS..."
    sudo cp -a /etc/mkinitcpio.conf /etc/mkinitcpio.conf.niix-encrypt.bak 2>/dev/null || true
    niix_write_mkinitcpio "$NIIX_SD_HOOKS"
    if sudo mkinitcpio -P; then
        log "initramfs rebuilt with systemd hooks"
    else
        warn "initramfs build with systemd hooks FAILED - reverting to the encrypt hooks so the current boot entry keeps working."
        niix_write_mkinitcpio "$NIIX_ENCRYPT_HOOKS"
        sudo mkinitcpio -P \
            || warn "Reverted initramfs rebuild also failed - do NOT reboot until 'sudo mkinitcpio -P' succeeds."
    fi
fi

# -- GPU drivers ------------------------------------------
# Gated on its own completion marker, NOT on "is the kernel installed" - the
# two are unrelated. If chwd failed on a mirror blip during an earlier run,
# the CachyOS kernel could already be present (so the outer kernel-install
# branch above gets skipped on a re-run) while GPU driver setup never
# actually succeeded. Without an independent marker, a re-run of firstboot
# would silently skip this step forever since it used to live inside that
# now-skipped branch.
# Strategy:
#   * Legacy NVIDIA (Maxwell/Pascal/Kepler) -> the in-kernel NOUVEAU driver. It
#     supports these cards AND PRIME offload, runs fine under Wayland, and has
#     none of the proprietary/DKMS/kernel-version fragility that loops the login
#     on these old GPUs. No proprietary driver is installed.
#   * Everything else (modern NVIDIA Turing+, AMD, Intel) -> CachyOS hardware
#     detection (chwd), which installs the right tested profile.
# Either way the iGPU drives the display under Wayland and the dGPU is reached via
# switcheroo offload - no X11, no driver that can crash the session.
GPU_MARKER=/etc/niix/.chwd-configured
if [ ! -f "$GPU_MARKER" ]; then
    info "Detecting and installing GPU drivers (CachyOS chwd)..."
    # chwd auto-selects the correct, offload-capable driver for the detected card:
    #   NVIDIA Turing+ -> nvidia-open-dkms    Maxwell/Pascal -> nvidia-dkms-580xx
    #   NVIDIA Kepler  -> nvidia-dkms-470xx    AMD / Intel    -> Mesa profile
    # Every NVIDIA proprietary profile supports PRIME render offload, so the discrete
    # ("most powerful") GPU is actually usable for games. We take the proprietary path
    # for ALL NVIDIA generations and keep nouveau ONLY as a boot fallback for the case
    # where the proprietary driver never gets installed (offline / repo missing).
    pac_retry -S --noconfirm --needed chwd || true
    CHWD_OK=false
    if command -v chwd >/dev/null 2>&1; then
        # Retry once (force) on failure - first-boot mirror/CDN blips are common.
        if sudo chwd -a 0300 || { sleep 3; sudo chwd -f -a 0300; }; then
            log "chwd GPU configuration complete"
            CHWD_OK=true
        else
            warn "chwd autoconfigure reported issues (see 'sudo chwd --list')."
        fi
    else
        warn "chwd unavailable from the CachyOS repo - will fall back to nouveau below."
    fi

    if [ "$GPU_VENDOR" = "nvidia" ]; then
        # nvidia-utils is pulled in ONLY by chwd's proprietary profiles, never by the
        # nouveau profile - so its presence is a reliable signal that offload will work.
        if pacman -Qq nvidia-utils >/dev/null 2>&1; then
            # Proprietary path confirmed: ensure the offload helper + switcheroo are
            # present and that nothing blacklists the NVIDIA module.
            pac_retry -S --noconfirm --needed nvidia-prime switcheroo-control \
                vulkan-icd-loader lib32-vulkan-icd-loader || true
            sudo systemctl enable switcheroo-control.service 2>/dev/null || true
            sudo rm -f /etc/modprobe.d/no-nvidia.conf /etc/modprobe.d/niix-disable-nvidia.conf \
                       /etc/modprobe.d/nouveau-blacklist.conf 2>/dev/null || true
            log "NVIDIA proprietary driver installed - dGPU available via PRIME offload"
        else
            # Fallback: proprietary never landed. Install nouveau so the desktop still
            # boots, and neutralise the offload wiring so games don't aim at a driver
            # that isn't there. (Re-run 'sudo chwd -a 0300' once online to enable it.)
            warn "NVIDIA proprietary driver not installed - falling back to nouveau."
            pac_retry -S --noconfirm --needed \
                mesa lib32-mesa vulkan-nouveau lib32-vulkan-nouveau \
                vulkan-icd-loader lib32-vulkan-icd-loader switcheroo-control \
                || warn "Some nouveau/Vulkan packages failed - desktop should still work."
            sudo systemctl enable switcheroo-control.service 2>/dev/null || true
            sudo rm -f /etc/mkinitcpio.conf.d/10-chwd.conf 2>/dev/null || true
            sudo rm -f /etc/niix/steam-env 2>/dev/null || true
            for _d in "$HOME/.local/share/applications"/*.desktop; do
                [ -f "$_d" ] || continue
                sed -i '/^PrefersNonDefaultGPU=/d; /^X-KDE-RunOnDiscreteGpu=/d' "$_d" 2>/dev/null || true
            done
            warn "Re-run 'sudo chwd -a 0300' once online to enable the NVIDIA dGPU."
        fi

        # One-time visibility so the user can confirm the dGPU was detected.
        command -v nvidia-smi >/dev/null 2>&1 \
            && log "dGPU: $(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo 'NVIDIA (loads after reboot)')"
    fi

    # Only mark this step done if chwd actually succeeded (or wasn't needed,
    # e.g. non-NVIDIA where a Mesa profile install is enough) - a hard chwd
    # failure should let the NEXT firstboot re-run try again, not skip forever.
    if [ "$CHWD_OK" = "true" ] || [ "$GPU_VENDOR" != "nvidia" ]; then
        sudo mkdir -p /etc/niix
        sudo touch "$GPU_MARKER"
    fi
else
    log "GPU drivers already configured (chwd) - skipping"
fi

# -- LAVD scheduler ----------------------------------------
if ! pacman -Q scx-scheds &>/dev/null; then
    info "Installing LAVD scheduler..."
    pac_retry -S --noconfirm --needed scx-scheds
    echo "SCX_SCHEDULER=scx_lavd" | sudo tee /etc/default/scx > /dev/null
    SCX_SVC=$(systemctl list-unit-files 2>/dev/null | grep -i "^scx" | awk '{print $1}' | head -1)
    if [ -n "$SCX_SVC" ]; then
        sudo systemctl enable --now "$SCX_SVC"
        log "LAVD scheduler enabled via $SCX_SVC"
    fi
fi

# -- Create @clean bootable fallback subvolume -------------
# Runs BEFORE the Limine update so the "clean state" menu entry is only ever
# written when the @clean subvolume really exists. All btrfs/mount operations
# need root - this script runs as the regular user, so sudo is mandatory here
# (without it the mount fails silently and @clean is never created).
info "Creating @clean Btrfs fallback snapshot..."
CLEAN_OK=false
MAPPER_DEV=$(findmnt -n --nofsroot -o SOURCE / | head -1)
TMP_MNT=$(mktemp -d)
if sudo mount -o subvol=/ "$MAPPER_DEV" "$TMP_MNT" 2>/dev/null; then
    sudo btrfs subvolume show "$TMP_MNT/@clean" &>/dev/null \
        && sudo btrfs subvolume delete "$TMP_MNT/@clean" 2>/dev/null
    if sudo btrfs subvolume snapshot "$TMP_MNT/@" "$TMP_MNT/@clean"; then
        CLEAN_OK=true
        log "@clean snapshot created - bootable from Limine as 'NiiX Arch (clean state)'"
    else
        warn "@clean snapshot failed - run later: sudo niixarch-refresh-clean"
    fi
    sudo umount "$TMP_MNT"
else
    warn "Could not mount Btrfs top-level - @clean skipped, run: sudo niixarch-refresh-clean"
fi
rmdir "$TMP_MNT" 2>/dev/null || true

# -- Verify the CachyOS initramfs BEFORE Limine is pointed at it --
# Limine is only switched to the CachyOS kernel (rd.luks.name=) once its
# initramfs provably contains systemd-cryptsetup. Otherwise nothing is rewritten
# and, if the hooks were already switched, they are reverted so the existing
# cryptdevice= entry keeps booting.
LIMINE_CONF=""
for f in /boot/EFI/limine/limine.conf /boot/limine/limine.cfg /boot/limine.cfg /boot/EFI/limine/limine.cfg; do
    [ -f "$f" ] && LIMINE_CONF="$f" && break
done
CURRENT_CMDLINE=""
[ -n "$LIMINE_CONF" ] && CURRENT_CMDLINE=$(grep -m1 "cmdline:" "$LIMINE_CONF" | sed 's/.*cmdline: //')

INITRAMFS_OK=false
if pacman -Q linux-cachyos &>/dev/null \
    && [ -f /boot/vmlinuz-linux-cachyos ] \
    && niix_sd_hooks_active \
    && niix_initrd_has_sd_crypt /boot/initramfs-linux-cachyos.img; then
    INITRAMFS_OK=true
    log "CachyOS initramfs verified (systemd-cryptsetup present)"
elif pacman -Q linux-cachyos &>/dev/null; then
    warn "Could not verify LUKS support in the CachyOS initramfs - Limine will NOT be switched to it."
fi

if [ "$INITRAMFS_OK" != "true" ] && niix_sd_hooks_active; then
    if printf '%s' "$CURRENT_CMDLINE" | grep -q 'cryptdevice='; then
        warn "Reverting to encrypt hooks so the current Limine entry (cryptdevice=) stays bootable..."
        niix_write_mkinitcpio "$NIIX_ENCRYPT_HOOKS"
        sudo mkinitcpio -P \
            || warn "Revert rebuild failed - do NOT reboot until 'sudo mkinitcpio -P' succeeds."
    else
        warn "Limine already uses rd.luks.name= but the initramfs could not be verified - run 'sudo mkinitcpio -P' and check it before rebooting."
    fi
fi

# -- Update Limine for CachyOS + @clean -------------------
LIMINE_UPDATED=false
if [ "$INITRAMFS_OK" = "true" ] && [ -n "$LIMINE_CONF" ] && [ -n "$CURRENT_CMDLINE" ]; then
    info "Updating Limine config..."
    NEW_CMDLINE=$(printf '%s\n' "$CURRENT_CMDLINE" | sed 's|cryptdevice=UUID=\([^:]*\):\([^ ]*\)|rd.luks.name=\1=\2|')
    # NOTE: 'subvol=@\b' can never match '@' followed by a space (\b needs a
    # word char after '@'). Match '@' followed by a space or EOL.
    CLEAN_CMDLINE=$(printf '%s\n' "$NEW_CMDLINE" | sed -E 's/rootflags=subvol=@( |$)/rootflags=subvol=@clean\1/')

    if [ "$IS_DUALBOOT" = "true" ]; then
        WIN_ENTRY="/Windows Boot Manager
    protocol: efi
    path: boot():/EFI/Microsoft/Boot/bootmgfw.efi

"
    else
        WIN_ENTRY=""
    fi

    # Only offer the clean-state entry if the snapshot actually exists -
    # otherwise selecting it would fail to mount root and drop to emergency.
    CLEAN_ENTRY=""
    if [ "$CLEAN_OK" = "true" ]; then
        CLEAN_ENTRY="

/NiiX Arch (clean state)
    protocol: linux
    path: boot():/vmlinuz-linux-cachyos
    cmdline: $CLEAN_CMDLINE
    module_path: boot():/initramfs-linux-cachyos.img"
    fi

    # default_entry is 1-based: with the Windows entry present, NiiX Arch is
    # entry 2; in single-boot it is entry 1. Fallback entries come after it.
    DEFAULT_ENTRY=1
    [ -n "$WIN_ENTRY" ] && DEFAULT_ENTRY=2

    # $1 = true -> keep a stock-kernel fallback entry (used until the stock
    # kernel is removed, so there is never a moment with only one bootable
    # kernel in the menu).
    niix_write_limine() {
        local stock_entry="" tmp dst copied=0
        if [ "$1" = "true" ]; then
            stock_entry="

/NiiX Arch (stock kernel fallback)
    protocol: linux
    path: boot():/vmlinuz-linux
    cmdline: $NEW_CMDLINE
    module_path: boot():/initramfs-linux.img"
        fi
        tmp=$(mktemp) || return 1
        cat > "$tmp" <<LIMEOF
timeout: 5
interface_branding: NiiX Arch
interface_branding_colour: 6
interface_help_hidden: yes
default_entry: $DEFAULT_ENTRY

${WIN_ENTRY}/NiiX Arch
    protocol: linux
    path: boot():/vmlinuz-linux-cachyos
    cmdline: $NEW_CMDLINE
    module_path: boot():/initramfs-linux-cachyos.img${stock_entry}${CLEAN_ENTRY}
LIMEOF
        for dst in /boot/EFI/limine/limine.cfg /boot/EFI/limine/limine.conf \
                   /boot/EFI/BOOT/limine.cfg   /boot/EFI/BOOT/limine.conf \
                   /boot/limine/limine.cfg     /boot/limine/limine.conf \
                   /boot/limine.cfg; do
            sudo cp "$tmp" "$dst" 2>/dev/null && copied=$(( copied + 1 ))
        done
        rm -f "$tmp"
        [ "$copied" -gt 0 ]
    }

    # The stock initramfs was rebuilt with the same sd-encrypt hooks, so it
    # takes rd.luks.name= too - offer it only once that is verified.
    STOCK_FALLBACK=false
    if [ -f /boot/vmlinuz-linux ] && niix_initrd_has_sd_crypt /boot/initramfs-linux.img; then
        STOCK_FALLBACK=true
    fi

    if niix_write_limine "$STOCK_FALLBACK"; then
        LIMINE_UPDATED=true
        log "Limine updated - CachyOS kernel$([ "$STOCK_FALLBACK" = "true" ] && echo ' + stock fallback')$([ "$CLEAN_OK" = "true" ] && echo ' + @clean fallback')"
    else
        warn "Could not write any Limine config - Limine left unchanged"
    fi
elif [ "$INITRAMFS_OK" = "true" ]; then
    warn "Could not find/read the Limine config - update Limine manually"
fi

# -- Remove stock linux kernel -----------------------------
# Only once the CachyOS kernel is installed, its initramfs is verified to
# unlock LUKS, AND Limine already boots it. The fallback entry is dropped from
# Limine only after the removal has actually succeeded.
if [ "$INITRAMFS_OK" = "true" ] && [ "$LIMINE_UPDATED" = "true" ] && pacman -Q linux &>/dev/null; then
    info "Removing stock linux kernel..."
    RM_PKGS=(linux)
    pacman -Q linux-headers &>/dev/null && RM_PKGS+=(linux-headers)
    if sudo pacman -Rns --noconfirm "${RM_PKGS[@]}"; then
        log "Stock kernel removed - CachyOS only"
        niix_write_limine false \
            && log "Limine: stock-kernel fallback entry removed" \
            || warn "Could not drop the stock fallback entry from Limine (it now points at a removed kernel)"
    else
        warn "Could not remove stock kernel - it stays as the Limine fallback. Remove later: sudo pacman -Rns ${RM_PKGS[*]}"
    fi
elif pacman -Q linux &>/dev/null; then
    warn "Keeping the stock linux kernel (CachyOS kernel, Limine update, or initramfs LUKS support not confirmed)."
fi

# -- niixarch-refresh-clean helper ------------------------
sudo tee /usr/local/bin/niixarch-refresh-clean > /dev/null <<'REFRESHEOF'
#!/bin/bash
echo "Refreshing @clean snapshot to current system state..."
MAPPER_DEV=$(findmnt -n --nofsroot -o SOURCE / | head -1)
TMP_MNT=$(mktemp -d)
mount -o subvol=/ "$MAPPER_DEV" "$TMP_MNT" || { echo "Mount failed"; exit 1; }
btrfs subvolume show "$TMP_MNT/@clean" &>/dev/null \
    && btrfs subvolume delete "$TMP_MNT/@clean"
btrfs subvolume snapshot "$TMP_MNT/@" "$TMP_MNT/@clean"
umount "$TMP_MNT"
rmdir "$TMP_MNT"
echo "Done - 'NiiX Arch (clean state)' in Limine now reflects current system."
REFRESHEOF
sudo chmod +x /usr/local/bin/niixarch-refresh-clean

# -- yay ---------------------------------------------------
# Prefer the CachyOS repo's yay: prebuilt and signed, so no Go toolchain
# download and no compile. Fall back to building it from the AUR, with
# retries, and only report success when the binary really exists.
if ! command -v yay &>/dev/null; then
    info "Installing yay (AUR helper)..."
    if grep -q '^\[cachyos\]' /etc/pacman.conf \
        && pac_retry -S --noconfirm --needed yay \
        && command -v yay &>/dev/null; then
        log "yay installed (prebuilt, signed - CachyOS repo)"
    else
        info "Prebuilt yay unavailable - building it from the AUR..."
        YAY_TMP=$(mktemp -d /tmp/niix-yay-XXXXXX)
        for attempt in 1 2 3; do
            rm -rf "$YAY_TMP/yay"
            if git clone --depth 1 https://aur.archlinux.org/yay.git "$YAY_TMP/yay" \
                && ( cd "$YAY_TMP/yay" && makepkg -si --noconfirm ) \
                && command -v yay &>/dev/null; then
                break
            fi
            warn "yay build attempt $attempt/3 failed, retrying..."
            sleep 5
        done
        rm -rf "$YAY_TMP"
        if command -v yay &>/dev/null; then
            log "yay installed (built from the AUR)"
        else
            warn "yay could not be installed - AUR packages will be skipped (retry: re-run this script)"
        fi
    fi
fi

aur_install() {
    local pkg="$1" attempt=1
    command -v yay &>/dev/null || { warn "$pkg skipped - yay is not installed"; return 1; }
    while [ "$attempt" -le 3 ]; do
        yay -S --noconfirm --needed "$pkg" 2>&1 && return 0
        warn "$pkg attempt $attempt/3 failed, retrying..."
        sleep 5; attempt=$(( attempt + 1 ))
    done
    warn "$pkg failed after 3 attempts"
    return 1
}

# -- Flatpak apps: started FIRST, in the background ---------------------------
# Flatpak downloads are network-bound and AUR builds are CPU-bound, so running
# them at the same time cuts several minutes off first boot. Flatpak never
# touches the pacman database or lock, so there is nothing to race. Output goes
# to a file and is printed in full once it finishes, so the log stays readable
# instead of interleaved.
# sudo: a system-wide flatpak install from a user session pops an interactive
# polkit password dialog, which stalls the unattended first-boot flow.
info "Starting Flatpak downloads in the background (they run while the AUR packages build)..."
sudo flatpak remote-add --if-not-exists flathub \
    https://dl.flathub.org/repo/flathub.flatpakrepo || warn "Could not add Flathub remote"

flatpak_install() { sudo flatpak install -y --noninteractive flathub "$1"; }

FLATPAK_LOG=$(mktemp /tmp/niix-flatpak-XXXXXX.log 2>/dev/null) || FLATPAK_LOG=""
FLATPAK_PID=""
flatpak_batch() {
    run_once flatpak-stremio  flatpak_install com.stremio.Stremio || true
    run_once flatpak-whatsie  flatpak_install com.ktechpit.whatsie \
        || warn "Whatsie (Flatpak) failed - retry later: flatpak install flathub com.ktechpit.whatsie"
    run_once flatpak-heroic   flatpak_install com.heroicgameslauncher.hgl \
        || warn "Heroic (Flatpak) failed - retry later: flatpak install flathub com.heroicgameslauncher.hgl"
    run_once flatpak-protonup flatpak_install net.davidotek.pupgui2 \
        || warn "ProtonUp-Qt (Flatpak) failed - retry later: flatpak install flathub net.davidotek.pupgui2"
}
# Background only while passwordless sudo is active: a background job has no
# terminal, so on a resumed run (rule already removed) sudo could not ask for
# the password and every install would fail. Then it runs in the foreground.
if [ -n "$FLATPAK_LOG" ] && sudo -n true 2>/dev/null; then
    flatpak_batch > "$FLATPAK_LOG" 2>&1 &
    FLATPAK_PID=$!
else
    [ -n "$FLATPAK_LOG" ] && rm -f "$FLATPAK_LOG"
    flatpak_batch
fi

# -- AUR packages (only what isn't in the repos or on Flathub) ----------------
# Heroic and ProtonUp-Qt are installed from Flatpak above instead of AUR -bin
# packages: signed, CDN-backed, and no third-party binary build step to break.
info "Installing AUR packages..."
run_once aur-dxvk          aur_install dxvk-mingw          || true
run_once aur-ananicy-rules aur_install ananicy-rules-git   || true
run_once aur-helium        aur_install helium-browser-bin  || true
# ddterm: Wayland-native drop-down terminal (GNOME Shell extension), replaces Yakuake.
if run_once aur-ddterm aur_install gnome-shell-extension-ddterm; then
    gnome-extensions enable ddterm@amezin.github.com 2>/dev/null || true
    # Bind the drop-down toggle to BOTH F12 and Ctrl+Alt+T.
    dconf write /com/github/amezin/ddterm/ddterm-toggle-hotkey "['F12', '<Control><Alt>t']" 2>/dev/null || true
    dconf write /com/github/amezin/ddterm/window-above true 2>/dev/null || true
    dconf write /com/github/amezin/ddterm/hide-when-focus-lost false 2>/dev/null || true
    dconf write /com/github/amezin/ddterm/use-system-font false 2>/dev/null || true
    dconf write /com/github/amezin/ddterm/custom-font "'Hack Nerd Font Bold 12'" 2>/dev/null || true
    log "ddterm drop-down terminal installed (F12 / Ctrl+Alt+T, Hack Nerd Font Bold)"
else
    warn "ddterm failed to install from the AUR - drop-down terminal unavailable."
fi
log "AUR packages done"

# -- Collect the background Flatpak downloads ---------------------------------
if [ -n "$FLATPAK_PID" ]; then
    info "Waiting for the Flatpak downloads to finish..."
    wait "$FLATPAK_PID" 2>/dev/null || true
    [ -s "$FLATPAK_LOG" ] && cat "$FLATPAK_LOG"
    rm -f "$FLATPAK_LOG"
fi
log "Flatpak apps installed"

# NVIDIA drivers (modern + legacy 580xx/470xx), Optimus/PRIME, switcheroo-control and
# nvidia-prime are all handled earlier by CachyOS hardware detection (chwd) - no custom
# NVIDIA driver logic here anymore.

# -- Retry packages deferred from the base install --------
# The ISO installer defers optional apps (Steam/Lutris/fastfetch) here instead of
# aborting when a transient DNS/mirror blip hits one of them. Now that first-boot
# has a solid network, retry from the repo; fall back to Flatpak for Lutris if
# the repo is still unreachable. Each success clears its deferral (resumable).
DEFER=/var/lib/niixarch/deferred
if [ -f "$DEFER" ]; then
    info "Retrying packages deferred from the base install..."
    while IFS= read -r pkg; do
        [ -n "$pkg" ] || continue
        if pacman -Q "$pkg" &>/dev/null; then
            sudo sed -i "\|^${pkg}\$|d" "$DEFER" 2>/dev/null || true
            continue
        fi
        if pac_retry -S --noconfirm --needed "$pkg"; then
            log "Deferred package installed from repo: $pkg"
            sudo sed -i "\|^${pkg}\$|d" "$DEFER" 2>/dev/null || true
        elif [ "$pkg" = "lutris" ] && flatpak_install net.lutris.Lutris; then
            log "Lutris installed from Flatpak (repo was unavailable)"
            sudo sed -i "\|^${pkg}\$|d" "$DEFER" 2>/dev/null || true
        else
            warn "Deferred package still unavailable: $pkg - retry later"
        fi
    done < "$DEFER"
fi

# -- Proton-CachyOS (recommended default) -----------------
# From the CachyOS repos enabled above: Proton 11 base with NTSync enabled by
# default (the ntsync kernel module is already loaded by the installer), plus
# per-game upscaler DLL upgrades (PROTON_FSR4_UPGRADE / PROTON_DLSS_UPGRADE /
# PROTON_XESS_UPGRADE) and winewayland improvements. umu-launcher lets Lutris
# and Heroic use the same Proton builds. Fails gracefully if the CachyOS repos
# never came up - GE-Proton below remains the fallback.
info "Installing Proton-CachyOS + umu-launcher..."
if run_once proton-cachyos pac_retry -S --noconfirm --needed proton-cachyos umu-launcher; then
    log "Proton-CachyOS installed - recommended default in Steam -> Settings -> Compatibility"
else
    warn "Proton-CachyOS not available (CachyOS repos missing?) - use GE-Proton instead"
fi

# -- GE-Proton (latest, sha512-verified) -------------------
# Resolve the real release-asset URLs from the GitHub API instead of guessing
# them: hand-built URLs break whenever the asset naming changes, which is what
# skipped GE-Proton before. Anonymous api.github.com is capped at 60 req/h per
# IP; export GITHUB_TOKEN (or GH_TOKEN) before running to raise it to 5000/h.
# Verification is mandatory - an unverified archive is never extracted.
info "Installing latest GE-Proton..."
install_ge_proton() {
    local api="https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest"
    local compat_dir="$HOME/.local/share/Steam/compatibilitytools.d"
    local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
    local -a hdr=(-H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28")
    [ -n "$token" ] && hdr+=(-H "Authorization: Bearer $token")

    local json
    json=$(curl -fsSL --retry 5 --retry-delay 3 --retry-connrefused \
        --connect-timeout 15 --max-time 120 -A "niixarch-installer" \
        "${hdr[@]}" "$api" 2>/dev/null) || {
        warn "GE-Proton: GitHub API unreachable or rate-limited - skipped (install later with ProtonUp-Qt)"
        return 1
    }

    local tag
    tag=$(printf '%s\n' "$json" | grep -m1 '"tag_name"' | cut -d'"' -f4)
    [ -n "$tag" ] || { warn "GE-Proton: could not read latest release tag - skipped (use ProtonUp-Qt later)"; return 1; }

    mkdir -p "$compat_dir"
    [ -d "$compat_dir/$tag" ] && { log "GE-Proton $tag already installed - skipping"; return 0; }

    # Read the actual download URLs straight from the API response, picking the
    # asset for THIS machine's architecture. Recent GE-Proton releases ship both
    # -x86_64.tar.gz and -aarch64.tar.gz, so a blind "first .tar.gz" grabs the
    # ARM build on an x86_64 box (which is what happened). Match upstream's own
    # download script: pick by uname -m, and match the checksum to that tarball.
    local tar_url sum_url arch pat urls tar_base rel_name
    arch=$(uname -m)
    case "$arch" in
        x86_64)        pat='x86_64\.tar\.gz$' ;;
        aarch64|arm64) pat='aarch64\.tar\.gz$' ;;
        *) warn "GE-Proton: unsupported architecture '$arch' - skipped"; return 1 ;;
    esac
    urls=$(printf '%s\n' "$json" | grep -oE '"browser_download_url": *"[^"]*"' | cut -d'"' -f4)
    tar_url=$(printf '%s\n' "$urls" | grep -E "$pat" | head -n1)
    # Fallback for older releases that shipped x86_64 with no arch suffix.
    if [ -z "$tar_url" ] && [ "$arch" = "x86_64" ]; then
        tar_url=$(printf '%s\n' "$urls" | grep -E '\.tar\.gz$' | grep -viE 'aarch64|arm64' | head -n1)
    fi
    tar_base=$(basename "$tar_url")
    rel_name=${tar_base%.tar.gz}
    # Checksum that matches the chosen tarball; fall back to a non-ARM .sha512sum.
    sum_url=$(printf '%s\n' "$urls" | grep -E "/${rel_name}\.sha512sum$" | head -n1)
    [ -z "$sum_url" ] && sum_url=$(printf '%s\n' "$urls" | grep -E '\.sha512sum$' | grep -viE 'aarch64|arm64' | head -n1)
    if [ -z "$tar_url" ] || [ -z "$sum_url" ]; then
        warn "GE-Proton: no ${arch} release asset found in API response - skipped (use ProtonUp-Qt later)"
        return 1
    fi

    local dir
    dir=$(mktemp -d /tmp/ge-proton.XXXXXX) || { warn "GE-Proton: could not create temp dir - skipped"; return 1; }
    local tar_file="$dir/$(basename "$tar_url")"
    local sum_file="$dir/$(basename "$sum_url")"

    # Verify sha512 BEFORE extracting - never unpack an unverified archive into
    # the Steam runtime. Note: the checksum comes from the same GitHub release,
    # so it proves the download is intact, not who published it; GE-Proton
    # publishes no signatures to check against. The .sha512sum references the bare tarball filename, so
    # the check runs from inside the download dir.
    local rc=1
    if curl -fL --retry 5 --retry-delay 3 --retry-connrefused --connect-timeout 15 --max-time 1800 -o "$tar_file" "$tar_url" \
    && curl -fL --retry 5 --retry-delay 3 --retry-connrefused --connect-timeout 15 --max-time 120  -o "$sum_file" "$sum_url" \
    && ( cd "$dir" && sha512sum -c "$(basename "$sum_file")" ) \
    && tar -xzf "$tar_file" -C "$compat_dir"; then
        log "GE-Proton $tag installed (sha512 verified)"
        rc=0
    else
        warn "GE-Proton download or checksum verification failed - skipped (use ProtonUp-Qt later)"
        rc=1
    fi
    rm -rf "$dir"
    return $rc
}
run_once ge-proton install_ge_proton || true

# -- NiiX icons for AUR/flatpak apps just installed -------
info "Generating NiiX icons for newly installed apps..."
sudo /usr/local/bin/niix-iconify --quiet 2>/dev/null || true
log "App icons reconciled"

# -- Hide everything except the requested visible app set ------
# Visible set kept in the menu: Calculator, VLC, Disks, Clocks, Calendar,
# Text Editor, Software, Settings, Image Viewer, Extensions, Document Viewer
# (Papers), Tweaks.
#
# Try the real desktop ID first (reliable, doesn't depend on wording), then
# fall back to a case-insensitive SUBSTRING match on Name (not an exact
# anchored match - upstream Name fields often differ from what shows in the
# menu, e.g. Totem's Name is "Videos" not "Video Player", gnome-music's is
# "Music" not "Audio Player", baobab's is "Disk Usage Analyzer" with a z).
# Runs un-privileged as the real user so it writes to the real $HOME - do not
# wrap this whole script in sudo, or the override lands in /root instead.
info "Hiding non-essential apps from the menu..."
niix_hide_by_id_or_name() {
    local id="$1" name="$2" found=""
    for dir in /usr/share/applications /var/lib/flatpak/exports/share/applications; do
        [ -f "${dir}/${id}.desktop" ] && { found="$id"; break; }
    done
    if [ -z "$found" ]; then
        found=$(grep -ils "^Name=.*${name}" /usr/share/applications/*.desktop \
             /var/lib/flatpak/exports/share/applications/*.desktop 2>/dev/null \
             | head -1 | xargs -r basename -s .desktop)
    fi
    if [ -n "$found" ]; then
        /usr/local/bin/niix-hide-app "$found" >/dev/null 2>&1 && echo "  hidden: $name -> $found" || echo "  FAILED to hide: $name (matched $found, niix-hide-app errored)"
    else
        echo "  NOT FOUND: $name (tried id '$id' and name match - not installed, or its .desktop ID/Name differs from what's hardcoded here)"
    fi
}

# id_guess::name_substring (id tried first, name is a case-insensitive fallback)
HIDE_COUNT=0
HIDE_TOTAL=0
for pair in \
    "org.gnome.Music::Music" \
    "bssh::Avahi SSH Server Browser" \
    "bvnc::Avahi VNC Server Browser" \
    "avahi-discover::Avahi Zeroconf Browser" \
    "org.gnome.Snapshot::Camera" \
    "org.corectrl.CoreCtrl::CoreCtrl" \
    "com.github.amezin.ddterm::ddterm" \
    "org.gnome.baobab::Disk Usage Analy" \
    "lstopo::Hardware Locality" \
    "org.gnome.Logs::Logs" \
    "org.freedesktop.MalcontentControl::Parental Controls" \
    "net.davidotek.pupgui2::ProtonUp" \
    "assistant::Qt Assistant" \
    "qdbusviewer::Qt D-Bus Viewer" \
    "linguist::Qt Linguist" \
    "qv4l2::Qt V4L2 test" \
    "qvidcap::Qt V4L2 video capture" \
    "designer::Qt Widgets Designer" \
    "org.gnome.SystemMonitor::System Monitor" \
    "org.gnome.Terminal::Terminal" \
    "org.gnome.Console::Console" \
    "org.gnome.Totem::Video" \
    "org.gnome.Weather::Weather" \
    "winetricks::Winetricks" \
    "org.gnome.Tour::Tour"
do
    HIDE_TOTAL=$((HIDE_TOTAL + 1))
    out=$(niix_hide_by_id_or_name "${pair%%::*}" "${pair##*::}")
    echo "$out"
    [[ "$out" == "  hidden:"* ]] && HIDE_COUNT=$((HIDE_COUNT + 1))
done
log "Menu cleanup: ${HIDE_COUNT}/${HIDE_TOTAL} apps hidden - see lines above for any NOT FOUND/FAILED"

# -- Pin apps to GNOME Dash ------------------------------
# Desktop IDs confirmed from live system ls /usr/share/applications/:
#   helium.desktop              (helium-browser-bin AUR)
#   com.heroicgameslauncher.hgl.desktop  (Heroic - Flatpak)
#   steam.desktop               (steam AUR)
#   com.stremio.Stremio.desktop (Stremio Flatpak)
# gsettings is unreliable from a terminal - no dconf session daemon.
# dconf system override writes to the filesystem directly, no session needed.
info "Pinning apps to GNOME Dash via dconf system override..."
sudo mkdir -p /etc/dconf/db/local.d /etc/dconf/profile
if ! grep -q "system-db:local" /etc/dconf/profile/user 2>/dev/null; then
    printf 'user-db:user\nsystem-db:local\n' | sudo tee /etc/dconf/profile/user > /dev/null
fi
sudo tee /etc/dconf/db/local.d/00-niixarch-dash > /dev/null << 'DCONFEOF'
[org/gnome/shell]
favorite-apps=['org.gnome.Nautilus.desktop', 'helium.desktop', 'org.gnome.TextEditor.desktop', 'net.lutris.Lutris.desktop', 'steam.desktop', 'com.heroicgameslauncher.hgl.desktop', 'com.ktechpit.whatsie.desktop', 'signal-desktop.desktop', 'com.stremio.Stremio.desktop']
disable-user-extensions=false
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-panel@jderose9.github.com', 'arcmenu@arcmenu.com', 'screen-rotate@shyzus.github.io', 'tiling-assistant@leleat-on-github', 'ddterm@amezin.github.com', 'system-monitor@gnome-shell-extensions.gcampax.github.com']

[org/gnome/desktop/interface]
color-scheme='prefer-dark'
accent-color='pink'
icon-theme='NiiX'

[org/gnome/desktop/media-handling]
automount=true
automount-open=false
autorun-never=false

[org/gnome/terminal/legacy/profiles:]
default='b1dcc9dd-5262-4d8d-a863-c897e6d979b9'
list=['b1dcc9dd-5262-4d8d-a863-c897e6d979b9']

[org/gnome/terminal/legacy/profiles:/:b1dcc9dd-5262-4d8d-a863-c897e6d979b9]
visible-name='NiiX'
use-system-font=false
font='Hack Nerd Font Bold 12'
audible-bell=false
scrollback-unlimited=true
DCONFEOF
sudo dconf update 2>/dev/null \
    && log "Dash pinned: Nautilus - Helium - Text Editor - Lutris - Steam - Heroic - Whatsie - Signal - Stremio" \
    || warn "dconf update failed - Dash pinning may not apply"

# -- Cleanup -----------------------------------------------
# Order matters: every privileged step runs while passwordless sudo still
# exists, and removing that rule is the LAST sudo call. Anything after it
# would stop at a password prompt - the countdown reboot included.
sudo rm -f /etc/xdg/autostart/niixarch-firstboot.desktop
sudo touch /var/lib/niixarch-firstboot-done
sudo systemctl disable --now niixarch-sudo-expiry.timer >/dev/null 2>&1 || true
sudo rm -f /etc/sudoers.d/niixarch-firstboot   # remove temporary passwordless sudo

echo ""
echo -e "  ${GREEN}+${NC}  ${WHITE}${BOLD}First-boot setup complete!${NC}"
echo -e "  ${PINK}------------------------------------------------${NC}"
echo -e "  ${CYAN}-${NC}  Steam: dGPU is automatic on hybrid systems - optional extras: ${GREEN}gamemoderun mangohud %command%${NC}"
echo -e "  ${CYAN}-${NC}  Run any app on the dGPU: ${GREEN}gpu-run <app>${NC}  (e.g. gpu-run blender)"
echo -e "  ${CYAN}-${NC}  Steam one-time: Settings -> Compatibility -> Steam Play for all titles -> ${GREEN}Proton-CachyOS${NC}"
echo -e "  ${CYAN}-${NC}  FreeSync/G-Sync monitor? Settings -> Displays -> enable ${GREEN}Variable Refresh Rate${NC}"
echo -e "  ${CYAN}-${NC}  Update everything: ${GREEN}niix-update${NC}  (checks Arch news first; ${GREEN}--firmware${NC} for firmware)"
echo -e "  ${CYAN}-${NC}  NTFS drives: auto-mounted under ${GREEN}/media${NC}  (${GREEN}niix-ntfs list${NC})"
echo -e "  ${CYAN}-${NC}  Wi-Fi hotspot: ${GREEN}niix-hotspot start${NC}  (stays on across reboots; off: ${GREEN}niix-hotspot auto off${NC})"
echo -e "  ${CYAN}-${NC}  Refresh clean snapshot: ${GREEN}sudo niixarch-refresh-clean${NC}"
echo -e "  ${CYAN}-${NC}  Log saved to: $LOG"
echo ""
warn "Rebooting in 10 seconds to load the CachyOS kernel..."
info "Press Ctrl+C to cancel."
for i in 10 9 8 7 6 5 4 3 2 1; do
    echo -ne "\r  -  Rebooting in ${i}s... "
    sleep 1
done
echo ""
# No sudo needed (and none left): logind lets the active local session reboot.
systemctl reboot || sudo reboot
FBEOF

    chmod +x /mnt/usr/local/bin/niixarch-firstboot.sh

    # -- Launcher wrapper ----------------------------------
    # A separate script handles terminal detection and the done-file guard.
    # This avoids shell quoting issues inside the .desktop Exec field.
    cat > /mnt/usr/local/bin/niixarch-firstboot-launcher.sh <<'LAUNCHEOF'
#!/bin/bash
# Exit silently if first-boot already completed
[ -f /var/lib/niixarch-firstboot-done ] && exit 0

# Give GNOME Shell a moment to fully settle
sleep 5

# Launch in best available terminal (gnome-terminal is explicitly installed)
if command -v gnome-terminal &>/dev/null; then
    exec gnome-terminal -- bash -c '/usr/local/bin/niixarch-firstboot.sh; exec bash'
elif command -v kgx &>/dev/null; then
    exec kgx -- bash -c '/usr/local/bin/niixarch-firstboot.sh; exec bash'
elif command -v xterm &>/dev/null; then
    exec xterm -e bash -c '/usr/local/bin/niixarch-firstboot.sh; exec bash'
fi
LAUNCHEOF
    chmod +x /mnt/usr/local/bin/niixarch-firstboot-launcher.sh

    # -- Autostart .desktop --------------------------------
    # Exec points directly to the launcher - no shell quoting needed.
    # NoDisplay=false  : ensures GNOME's autostart actually processes it.
    # Terminal=false   : launcher opens its own terminal; don't wrap in another.
    # OnlyShowIn left out intentionally - the restriction can silently block
    # the entry if XDG_CURRENT_DESKTOP doesn't match the exact string.
    mkdir -p /mnt/etc/xdg/autostart
    cat > /mnt/etc/xdg/autostart/niixarch-firstboot.desktop <<'DTEOF'
[Desktop Entry]
Type=Application
Name=NiiX Arch First-Boot Setup
Exec=/usr/local/bin/niixarch-firstboot-launcher.sh
NoDisplay=false
Terminal=false
X-GNOME-Autostart-enabled=true
DTEOF

    log "First-boot script installed (runs automatically on first login)"
}

# -- Step 12: Done --------------------------------------------
finish() {
    hr "Complete"
    echo
    echo
    echo -e "  ${PINK}========================================${NC}"
    echo -e "  ${GREEN}  niiXa${PINK}OS${NC}   ${WHITE}Arch Installer${NC}"
    echo -e "  ${PINK}========================================${NC}"
    echo
    echo
    echo -e "  ${GREEN}installation complete!${NC}"
    echo -e "  ${PINK}--------------------------------------------------------${NC}"
    echo
    echo -e "  ${DIM}Logs are saved next, then you'll be prompted to reboot.${NC}"
    echo -e "  ${DIM}Remove the USB when the screen goes blank after reboot.${NC}"
    echo
    echo -e "  ${PINK}-- At the Limine menu ----------------------------${NC}"
    echo
    echo -e "  ${GREEN}+${NC}  ${WHITE}NiiX Arch${NC}              -> LUKS passphrase -> GDM login"
    echo -e "  ${GREEN}+${NC}  ${WHITE}NiiX Arch (clean state)${NC} -> boots @clean fallback (after first-boot)"
    if [[ "$INSTALL_MODE" == "dualboot" ]]; then
        echo -e "  ${GREEN}+${NC}  ${WHITE}Windows Boot Manager${NC}   -> boots Windows normally"
    fi
    echo
    echo -e "  ${PINK}-- On first login --------------------------------${NC}"
    echo
    echo -e "  ${CYAN}-${NC}  A terminal opens automatically and runs the first-boot setup."
    echo -e "  ${CYAN}-${NC}  It installs: CachyOS kernel - removes stock linux - AUR packages - GE-Proton"
    echo -e "  ${CYAN}-${NC}  Creates the ${WHITE}@clean${NC} Btrfs snapshot - bootable from Limine as a fallback."
    echo -e "  ${CYAN}-${NC}  This takes 10-20 minutes. Let it complete, then reboot."
    echo -e "  ${CYAN}-${NC}  Snapper auto-snapshots before/after every pacman update (snap-pac)."
    echo -e "  ${CYAN}-${NC}  Refresh the clean state any time: ${WHITE}sudo niixarch-refresh-clean${NC}"
    echo
    if [[ "$INSTALL_MODE" == "dualboot" ]]; then
        warn "If Limine doesn't appear: go into BIOS and move"
        warn "'NiiX Arch (Limine)' above Windows in the boot order."
    fi
    echo

    # -- Anonymous install counter ----------------------------
    # Fires only here, at confirmed success - not at launch - so it counts
    # completed installs, not attempts or abandoned runs. Run synchronously
    # with a short timeout (not backgrounded) so it can't be killed by the
    # script exiting or the machine rebooting before the request lands.
    curl -sf "https://niixa.org/counter.php?action=hit&secret=e6975a19b1e7638effbba0d33f862e2ed1bb0fdc" \
        --max-time 8 --retry 2 --retry-delay 1 -o /dev/null 2>/dev/null || true
}

# -- Auto-detect install mode ---------------------------------
# Scans internal drives for Windows (NTFS partitions or EFI/Microsoft).
# Windows found  -> dual-boot (automatically, no menu)
# Nothing found  -> single-boot (automatically, no menu)
auto_detect_mode() {
    hr "Install Mode Detection"
    info "Scanning internal drives for existing operating systems..."

    local windows_found=false

    # Pass 1: look for NTFS partitions on internal (non-USB) drives
    while IFS= read -r dev; do
        [[ -b "$dev" ]] || continue
        local parent
        parent=$(lsblk -no PKNAME "$dev" 2>/dev/null)
        [[ -n "$parent" ]] && is_removable "/dev/$parent" && continue
        local fstype
        fstype=$(blkid -s TYPE -o value "$dev" 2>/dev/null)
        if [[ "$fstype" == "ntfs" ]]; then
            windows_found=true
            log "Windows NTFS partition found: $dev"
            break
        fi
    done < <(lsblk -lno PATH,TYPE 2>/dev/null | awk '$2=="part"{print $1}')

    # Pass 2: look for the Windows EFI bootloader on any internal EFI partition
    if [[ "$windows_found" == false ]]; then
        while IFS= read -r efi_dev; do
            [[ -b "$efi_dev" ]] || continue
            local parent
            parent=$(lsblk -no PKNAME "$efi_dev" 2>/dev/null)
            [[ -n "$parent" ]] && is_removable "/dev/$parent" && continue
            local tmp_mnt="/tmp/niix_efi_check_$$"
            mkdir -p "$tmp_mnt"
            if mount -o ro "$efi_dev" "$tmp_mnt" 2>/dev/null; then
                if [[ -f "$tmp_mnt/EFI/Microsoft/Boot/bootmgfw.efi" ]]; then
                    windows_found=true
                    log "Windows EFI bootloader found: $efi_dev"
                fi
                umount "$tmp_mnt" 2>/dev/null
            fi
            rmdir "$tmp_mnt" 2>/dev/null || true
            [[ "$windows_found" == true ]] && break
        done < <(blkid -t PART_ENTRY_TYPE="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" \
                      -o device 2>/dev/null)
    fi

    echo
    if [[ "$windows_found" == true ]]; then
        echo -e "  ${GREEN}+${NC}  ${WHITE}${BOLD}Windows detected on an internal drive${NC}"
        echo
        echo -e "  ${WHITE}${BOLD}How do you want to install?${NC}"
        echo -e "  ${GREEN}[1]${NC}  ${WHITE}Dual-boot${NC}    - keep Windows, install Arch alongside it  ${DIM}(recommended)${NC}"
        echo -e "  ${GREEN}[2]${NC}  ${WHITE}Single-boot${NC}  - ${RED}ERASE an entire drive${NC} (including Windows) and use it for Arch"
        echo
        echo -e "  ${DIM}Press Enter for the safe choice (dual-boot).${NC}"
        read -rp "  Choose [1/2]: " WIN_MODE_CHOICE
        if [[ "$WIN_MODE_CHOICE" == "2" ]]; then
            # Beginner-safety gate: an explicit typed phrase, not just a number,
            # so this can never be triggered by an accidental keypress.
            echo
            warn "Single-boot ERASES a whole drive. Windows and everything on it will be gone."
            warn "There is no undo. You will still choose which drive and confirm again later."
            echo
            echo -e "  ${RED}${BOLD}Type exactly:  ERASE WINDOWS  to continue (anything else keeps Windows):${NC}"
            read -rp "  > " WIN_WIPE_CONFIRM
            if [[ "$WIN_WIPE_CONFIRM" == "ERASE WINDOWS" ]]; then
                INSTALL_MODE="singleboot"
                warn "Single-boot selected - you will pick the drive to erase next."
            else
                INSTALL_MODE="dualboot"
                log "Phrase did not match - keeping Windows (dual-boot)."
            fi
        else
            INSTALL_MODE="dualboot"
            echo -e "  ${GREEN}+${NC}  ${WHITE}${BOLD}Dual-boot - Windows will be preserved${NC}"
            echo -e "  ${DIM}  Arch installs into free space. No existing data is touched.${NC}"
        fi
    else
        INSTALL_MODE="singleboot"
        echo -e "  ${CYAN}-${NC}  ${WHITE}${BOLD}No existing OS detected - installing in single-boot mode${NC}"
        echo -e "  ${DIM}  A drive of your choice will be fully wiped and used for Arch.${NC}"
    fi
    echo
}

# -- Single-boot: select target drive ------------------------
# -- Single-boot step 1: choose the target drive -------------
select_single_drive() {
    hr "Select Target Drive  (will be fully wiped)"
    warn "The ENTIRE selected drive will be repartitioned and ALL data destroyed."
    warn "This includes any existing OS, partitions, and files on that drive."
    echo
    echo -e "  ${WHITE}${BOLD}Internal drives:${NC}"
    echo

    # Collect internal (non-USB, non-virtual) drives
    local drive_list=()
    while IFS= read -r line; do
        [[ -n "$line" ]] || continue
        local dpath
        dpath=$(echo "$line" | awk '{print $1}')
        # Skip loop devices, RAM disks, device-mapper (belt-and-suspenders)
        [[ "$dpath" == /dev/loop* ]] && continue
        [[ "$dpath" == /dev/ram*  ]] && continue
        [[ "$dpath" == /dev/dm-*  ]] && continue
        # Only whole physical disks: never zram, md arrays, nbd or optical.
        [[ "$(lsblk -dno TYPE "$dpath" 2>/dev/null)" == "disk" ]] || continue
        is_removable "$dpath" && continue
        drive_list+=("$line")
    done < <(lsblk -dn -o PATH,SIZE,MODEL 2>/dev/null | tail -n +1)

    if [[ ${#drive_list[@]} -eq 0 ]]; then
        err "No internal drives found"
    fi

    local i=0
    for d in "${drive_list[@]}"; do
        local dpath dsize dmodel
        dpath=$(echo "$d"  | awk '{print $1}')
        dsize=$(echo "$d"  | awk '{print $2}')
        dmodel=$(echo "$d" | awk '{$1=$2=""; print $0}' | xargs)
        printf "  ${CYAN}[%s]${NC}  %-14s  %8s  %s\n" "$i" "$dpath" "$dsize" "$dmodel"
        i=$(( i + 1 ))
    done

    echo
    ask "Select drive to install on (will be COMPLETELY WIPED):"
    read -rp "  > " DRIVE_CHOICE
    # Strict numeric check: bash treats a non-numeric index as a variable name
    # that evaluates to 0, which would silently select the FIRST drive.
    [[ "$DRIVE_CHOICE" =~ ^[0-9]+$ ]] || err "Invalid selection: $DRIVE_CHOICE"

    local chosen="${drive_list[$DRIVE_CHOICE]:-}"
    [[ -n "$chosen" ]] || err "Invalid selection: $DRIVE_CHOICE"

    SINGLE_DRIVE=$(echo "$chosen" | awk '{print $1}')
    TARGET_DRIVE="$SINGLE_DRIVE"

    [[ -b "$SINGLE_DRIVE" ]] || err "Not a block device: $SINGLE_DRIVE"

    local drive_size
    drive_size=$(lsblk -dn -o SIZE "$SINGLE_DRIVE" 2>/dev/null | head -1 | tr -d '[:space:]')
    echo
    echo -e "  ${RED}${BOLD}You selected: $SINGLE_DRIVE ($drive_size)${NC}"
    echo -e "  ${RED}${BOLD}ALL data on this drive will be permanently destroyed.${NC}"
    echo
    echo -e "  ${RED}Type  WIPE  (uppercase) to confirm:${NC}"
    read -rp "  > " WIPE_CONFIRM
    [[ "$WIPE_CONFIRM" == "WIPE" ]] || { echo "  Aborted. Nothing was changed."; exit 0; }

    log "Target drive confirmed: $SINGLE_DRIVE ($drive_size)"
}

# -- Single-boot step 2: wipe the drive -----------------------
# Completely destroys all existing data, partition tables, and
# filesystem signatures. Run BEFORE partitioning.
wipe_single_drive() {
    hr "Drive Wipe"
    info "Wiping $SINGLE_DRIVE - destroying all existing data..."

    # Close any open LUKS containers on this drive first
    lsblk -lno NAME,TYPE "$SINGLE_DRIVE" 2>/dev/null \
        | awk '$2=="crypt"{print $1}' \
        | while read -r cname; do
            info "Closing LUKS container /dev/mapper/$cname..."
            cryptsetup close "$cname" 2>/dev/null || true
        done

    # Unmount any mounted partitions on this drive
    lsblk -lno NAME,MOUNTPOINT "$SINGLE_DRIVE" 2>/dev/null \
        | awk '$2!=""{print $2}' \
        | while read -r mnt; do
            umount "$mnt" 2>/dev/null || true
        done

    # Wipe all filesystem and partition signatures
    wipefs -a "$SINGLE_DRIVE" || err "wipefs failed on $SINGLE_DRIVE"
    log "Filesystem signatures wiped"

    # Destroy the partition table (GPT + MBR backup)
    sgdisk --zap-all "$SINGLE_DRIVE" || err "sgdisk --zap-all failed on $SINGLE_DRIVE"
    log "Partition table destroyed"

    # Zero the first and last 10 MiB to eliminate any residual metadata
    dd if=/dev/zero of="$SINGLE_DRIVE" bs=1M count=10 conv=fsync 2>/dev/null \
        || err "Failed to zero the start of $SINGLE_DRIVE"
    dd if=/dev/zero of="$SINGLE_DRIVE" bs=1M count=10 conv=fsync         seek=$(( $(blockdev --getsz "$SINGLE_DRIVE") / 2048 - 10 )) 2>/dev/null || true

    partprobe "$SINGLE_DRIVE" 2>/dev/null || true
    udevadm settle --timeout=10 2>/dev/null || true
    log "Drive $SINGLE_DRIVE wiped - ready for partitioning"
}

# -- Single-boot step 3: partition the drive -------------------
# Creates the same GPT layout dual-boot uses (EFI + LUKS),
# then sets the identical variables so all downstream functions
# (setup_luks, setup_btrfs, clean_efi, install_limine) run unchanged.
create_singleboot_partitions() {
    hr "Partition Creation  (single-boot)"
    info "Partitioning $SINGLE_DRIVE - same layout as dual-boot..."

    # -- GPT + EFI + LUKS -------------------------------------
    parted -s "$SINGLE_DRIVE" mklabel gpt || err "Failed to create GPT label on $SINGLE_DRIVE"
    log "New GPT partition table created"

    # Partition 1: EFI System Partition - 512 MiB
    # 1 MiB aligned start; large enough for kernel + initramfs + ucode + Limine
    parted -s "$SINGLE_DRIVE" mkpart "EFI" fat32 1MiB 513MiB || err "Failed to create EFI partition"
    parted -s "$SINGLE_DRIVE" set 1 esp on                    || err "Failed to set esp flag on partition 1"
    log "EFI partition: 1 MiB -> 513 MiB  (512 MiB, partition 1)"

    # Partition 2: Linux LUKS container - remainder of drive
    parted -s "$SINGLE_DRIVE" mkpart "arch" 513MiB 100% || err "Failed to create root partition"
    log "Root partition: 513 MiB -> 100%  (partition 2)"

    # Inform the kernel; the device nodes are waited for below.
    partprobe "$SINGLE_DRIVE" 2>/dev/null || true

    # Build partition paths - NVMe/MMC use 'p' suffix, SATA/USB do not
    if [[ "$SINGLE_DRIVE" == *nvme* || "$SINGLE_DRIVE" == *mmcblk* ]]; then
        EFI_PART="${SINGLE_DRIVE}p1"
        LUKS_PART="${SINGLE_DRIVE}p2"
    else
        EFI_PART="${SINGLE_DRIVE}1"
        LUKS_PART="${SINGLE_DRIVE}2"
    fi

    # Wait for udev to publish both nodes (up to ~10s), then verify.
    local _try
    for _try in $(seq 1 20); do
        [[ -b "$EFI_PART" && -b "$LUKS_PART" ]] && break
        udevadm settle --timeout=5 2>/dev/null || true
        sleep 0.5
    done
    [[ -b "$EFI_PART"  ]] || err "EFI partition $EFI_PART not found after partprobe"
    [[ -b "$LUKS_PART" ]] || err "Root partition $LUKS_PART not found after partprobe"

    # Mirror the variables dual-boot sets - everything downstream is identical
    EFI_DRIVE="$SINGLE_DRIVE"
    EFI_PART_NUM=1
    TARGET_DRIVE="$SINGLE_DRIVE"

    # Format EFI partition as FAT32 (same as Windows creates)
    mkfs.fat -F32 -n "EFI" "$EFI_PART" || err "mkfs.fat failed on $EFI_PART"
    log "EFI partition formatted: $EFI_PART  (FAT32)"
    log "LUKS partition ready:    $LUKS_PART  (encryption next)"
}

# -- Installer integrity -----------------------------------------------------
# The expected sha256 lives on GitHub, not on niixa.org, so a modified or
# damaged installer has to match a file on a second, independent host.
# What this protects against: corrupted/truncated/re-encoded downloads, stale
# caches, and niixa.org and GitHub being out of sync. Against a deliberately
# tampered installer only the verify-before-running command in the header
# helps - a tampered copy could simply delete this function.
NIIX_INSTALL_URL="https://niixa.org/install"
NIIX_HASH_URL="https://raw.githubusercontent.com/techniixdotcom/niixarch-install/main/install.sha256"

verify_installer() {
    local expected actual self="" tmp=""
    if [[ "${NIIX_SKIP_VERIFY:-0}" == "1" ]]; then
        warn "Installer verification SKIPPED (NIIX_SKIP_VERIFY=1) - only do this for development."
        return 0
    fi

    # A verified copy that re-executed itself only needs to prove it is
    # still the same file - no second download.
    if [[ -n "${NIIX_VERIFIED_SHA:-}" && -f "$0" ]]; then
        actual=$(sha256sum "$0" 2>/dev/null | awk '{print $1}')
        if [[ "$actual" == "$NIIX_VERIFIED_SHA" ]]; then
            log "Installer verified (sha256 matches GitHub)"
            return 0
        fi
    fi

    info "Verifying the installer against the checksum published on GitHub..."
    expected=$(curl -fsSL --retry 3 --max-time 20 "$NIIX_HASH_URL" 2>/dev/null \
        | awk '$2 == "install" || $2 == "*install" { print tolower($1); exit }')
    [[ "$expected" =~ ^[0-9a-f]{64}$ ]] \
        || err "Could not read the installer checksum from GitHub ($NIIX_HASH_URL). Check the connection and run the installer again."

    if [[ -f "$0" && -r "$0" ]]; then
        self=$0
    else
        # Started through a pipe (bash <(curl ...) or curl | bash): the bytes
        # being executed can't be read back, so download the installer to a
        # file, verify THAT, and carry on from the verified copy.
        tmp=$(mktemp /tmp/niix-install.XXXXXX) || err "Could not create a temporary file"
        curl -fsSL --retry 3 --max-time 120 "$NIIX_INSTALL_URL" -o "$tmp" \
            || err "Could not download the installer from $NIIX_INSTALL_URL for verification"
        self=$tmp
    fi

    actual=$(sha256sum "$self" | awk '{print $1}')
    if [[ "$actual" != "$expected" ]]; then
        [[ -n "$tmp" ]] && rm -f "$tmp"
        echo
        echo -e "  ${RED}${BOLD}INSTALLER CHECKSUM MISMATCH - nothing has been changed.${NC}"
        echo -e "  expected (GitHub): $expected"
        echo -e "  this installer:    $actual"
        echo -e "  If a new version was published in the last few minutes, GitHub may still"
        echo -e "  be serving the old checksum - wait 5 minutes and try again. Otherwise do"
        echo -e "  NOT run this file."
        err "Installer checksum does not match the one published on GitHub"
    fi
    log "Installer verified (sha256 matches GitHub)"

    if [[ -n "$tmp" ]]; then
        info "Continuing from the verified copy..."
        exec env NIIX_VERIFIED_SHA="$actual" bash "$tmp" "$@"
    fi
}

# ============================================================
#  MAIN
# ============================================================
dbg "Script started: $(date)"
dbg "Script source: $0"
dbg "Kernel: $(uname -r)"

# Unwind mounts/encryption if the install is interrupted or killed midway.
trap 'cleanup_on_fail' INT TERM

banner
check_prereqs          # brings the network up if needed
verify_installer "$@"  # before anything is touched
auto_detect_mode

# -- Hardware + drive detection (mode-dependent) --------------
if [[ "$INSTALL_MODE" == "dualboot" ]]; then
    detect_efi
fi
detect_hardware
dump_state "after hardware detection"

if [[ "$INSTALL_MODE" == "dualboot" ]]; then
    detect_free_spaces
    dump_state "after detect_free_spaces"
else
    select_single_drive   # step 1: choose drive + confirm WIPE
fi

# -- User input + confirmation --------------------------------
gather_input
confirm

# -- Disk setup -----------------------------------------------
if [[ "$INSTALL_MODE" == "dualboot" ]]; then
    # Dual-boot: carve a partition out of existing free space
    create_partition
    dump_state "after create_partition"
else
    # Single-boot: wipe -> partition -> same layout as dual-boot
    wipe_single_drive                        # step 2: destroy everything
    dump_state "after wipe"
    create_singleboot_partitions             # step 3: GPT + EFI + LUKS
    dump_state "after singleboot_partitions"
fi

# -- From here both modes are identical -----------------------
setup_luks
dump_state "after setup_luks"
setup_btrfs
clean_efi       # purges stale NVRAM entries + old Arch/Limine files
dump_state "after btrfs + clean_efi"

# -- Install + configure (same for both modes) ----------------
install_base
enable_multilib
install_gpu_drivers
install_packages

configure_system
# Dump boot-critical files to log
{
    echo ""
    echo "==== BOOT CONFIG DUMP ===="
    echo ""
    echo "--- /mnt/etc/mkinitcpio.conf ---"
    cat /mnt/etc/mkinitcpio.conf 2>&1 || true
    echo ""
    echo "--- /mnt/etc/fstab ---"
    cat /mnt/etc/fstab 2>&1 || true
    echo ""
    echo "--- /mnt/etc/environment ---"
    cat /mnt/etc/environment 2>&1 || echo "(not present)"
    echo ""
    echo "--- /mnt/etc/sysctl.d/99-niixarch.conf ---"
    cat /mnt/etc/sysctl.d/99-niixarch.conf 2>&1 || true
    echo ""
    echo "--- initramfs hooks ---"
    lsinitcpio /mnt/boot/initramfs-linux.img 2>&1 | grep -i "hooks/\|cryptsetup" || echo "(no hooks/crypt)"
    echo ""
    echo "--- EFI partition contents ---"
    find /mnt/boot -type f -name "*.img" -o -name "vmlinuz*" -o -name "*.cfg" -o -name "*.conf" -o -name "*.EFI" -o -name "*.efi" 2>&1 | head -30 || true
    echo ""
    echo "--- EFI partition space ---"
    df -h /mnt/boot 2>&1 || true
    echo ""
    echo "==== END BOOT CONFIG DUMP ===="
} >> "$LOGFILE" 2>&1

install_desktop
setup_storage
rebuild_initramfs
install_limine
setup_firstboot

# Dump final limine config to log
{
    echo ""
    echo "==== LIMINE CONFIG DUMP ===="
    echo ""
    echo "--- /mnt/boot/limine/limine.cfg ---"
    cat /mnt/boot/limine/limine.cfg 2>&1 || true
    echo ""
    echo "--- efibootmgr ---"
    efibootmgr -v 2>&1 || true
    echo ""
    echo "==== END LIMINE CONFIG DUMP ===="
} >> "$LOGFILE" 2>&1

dump_state "final state"

# Install finished - stop unwinding on Ctrl+C so the user can stay in a shell
# with /mnt mounted for inspection if they cancel the reboot prompt.
trap - INT TERM

# Copy log to the installed system so it survives reboot
cp "$LOGFILE" /mnt/var/log/niixarch-install.log 2>/dev/null || true

finish


# ============================================================================

#   If you are reading this, you found the temple.
#   It was built for four people who did what needed doing.
#   This comment does nothing. That was always the point.

#
#                              /\
#                             /  \
#                            / /\ \
#                           / /  \ \
#                          / / W  \ \
#                         / /  T   \ \
#                        / /   D    \ \
#                       / /    S     \ \
#                      //_____________\ \
#                     //               \\
#                    //  ___       ___  \\
#                   //  |   |     |   |  \\
#                  //   | A |     | T |   \\
#                 //    |   |     |   |    \\
#                //     |___|     |___|     \\
#               //       | |       | |      \\
#              //   ___  | |  ___  | |  ___  \\
#             //   |   | | | |   | | | |   |  \\
#            //    | A | | | |   | | | | T |   \\
#           //     |   | | | |   | | | |   |    \\
#          //      |___| | | |___| | | |___|     \\
#         //        | |  | |  | |  | |  | |       \\
#        //    ___  | |  | |  | |  | |  | |  ___   \\
#       //    |   | | |  | |  | |  | |  | | |   |   \\
#      //     | A | | |  | |  | |  | |  | | | N |    \\
#     //      |   | | |  | |  | |  | |  | | |   |     \\
#    //       |___| | |  | |  | |  | |  | | |___|      \\
#   //____________________________________________________\\
#   |                                                      |
#   |          T E M P L E   O F   T H E   R A M          |
#   |                                                      |
#   |______________________________________________________|
#   |||  |||  |||  |||  |||  |||  |||  |||  |||  |||  |||
#   |||  |||  |||  |||  |||  |||  |||  |||  |||  |||  |||
#
#   A = Alan Turing      T = Terry Davis
#   A = Aaron Swartz     N = Nicholas Winton

# ============================================================================

# ALAN MATHISON TURING  //  1912 - 1954

# ============================================================================

#   He asked whether machines could think before anyone
#   had built one worth asking about.
#   He broke Enigma. He shortened the war by years.
#   He saved millions of lives and was never allowed to know
#   that he had, because it was classified.

#   The country he saved chemically castrated him
#   for being gay. He died at 41, alone, an apple beside him.
#   Every computer that has ever run anything
#   is a footnote to his 1936 paper.

#   "We can only see a short distance ahead,
#    but we can see plenty there that needs to be done."

# ============================================================================

# TERRY ANDREW DAVIS  //  1969 - 2018

# ============================================================================

#   He built an entire operating system alone.
#   640x480. One process. No multitasking. Talked to God
#   in a language he wrote himself, on a machine he trusted
#   completely because he understood every layer of it.

#   TempleOS was not a joke. It was a cathedral.
#   He finished it. That is more than most people do.

#   "The compiler is proof that God loves us."

# ============================================================================

# AARON HILLEL SWARTZ  //  1986 - 2013

# ============================================================================

#   He was 14 when he helped draft RSS.
#   He helped build Creative Commons. He built Tor2web.
#   He co-founded Reddit and walked away from the money.
#   He believed that knowledge locked behind a paywall
#   was a violence done to the world, and he was right.

#   They offered him 35 years for downloading journal articles.
#   He was 26. He had already changed the internet twice.

#   "Information is power. But like all power, there are
#    those who want to keep it for themselves."

# ============================================================================

# SIR NICHOLAS WINTON  //  1909 - 2015

# ============================================================================

#   Not a builder of machines. A builder of something harder.

#   In 1938, a 29-year-old stockbroker cancelled his skiing
#   holiday and went to Prague instead.
#   He found Jewish families desperate to get their children
#   out before the Nazis arrived.

#   He organised eight trains.
#   669 children. Forged documents. Bribes. Paperwork.
#   He did it with a scrapbook and a typewriter
#   and told nobody for fifty years.

#   His wife found the scrapbook in the attic in 1988.
#   He was in the audience of a BBC programme.
#   The host asked if anyone recognised the names
#   in the book she was holding.
#   Every person in the room stood up.
#   They were his children.

#   He lived to 106. He never thought he had done
#   anything particularly remarkable.

#   "If something is not impossible, there must be
#    a way to do it."

# ============================================================================

#   Four people. None of them asked for recognition.
#   One proved the machine was possible.
#   One talked to God inside it.
#   One tried to make sure it belonged to everyone.
#   One just went to Prague and got to work.
#   The world is better for all four of them.
#   Read the source. Keep it open. Keep building.
#   And if something is not impossible - do it.

#                    +-------------------------+
#                    |   "You are loved."      |
#                    |        - Terry          |
#                    +-------------------------+





copy_logs_to_usb

# Show log locations
echo
echo -e "  ${CYAN}Log file:${NC}"
echo -e "    Live ISO:    ${YELLOW}${LOGFILE}${NC}"
echo -e "    Installed:   ${YELLOW}/var/log/niixarch-install.log${NC}"
echo

# -- Reboot on Enter ---------------------------------------
echo -e "  ${PINK}--------------------------------------------------------${NC}"
read -rp "$(echo -e "  ${GREEN}Press Enter to reboot${NC} ${DIM}(or Ctrl+C to stay at the shell)${NC}  ")" _
echo -e "  ${CYAN}Unmounting and rebooting...${NC}"
umount -R /mnt 2>/dev/null || true
swapoff -a 2>/dev/null || true
cryptsetup close "$MAPPER" 2>/dev/null || true
sync
reboot
