#!/bin/bash
# tui-install.sh — TUI installer for the_miro's Arch dotfiles


# -u: treat unset variables as errors; -o pipefail: a pipe fails if any stage fails.
# Intentionally omits -e so individual module failures can be handled explicitly.
set -uo pipefail

# ── Paths ─────────────────────────────────────────────────────────────────────
# Resolve the installer's own directory regardless of where the script was called from.
# BASH_SOURCE[0] stays correct even when the script is sourced rather than executed directly.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Dotfiles root is one level up from setup/.
DOTFILES_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
MODULES="$DOTFILES_DIR/setup/modules"
APPS="$MODULES/optional-Modules/apps"
LOG="$HOME/dotfiles-install.log"
# Private scratch space for the custom DIALOGRC theme file and a temporary colors.conf.
# mktemp -d creates a uniquely named directory that only this process owns.
TMP_D="$(mktemp -d)"
# Cleanup trap: remove the scratch dir and restore the terminal state on any exit signal.
# tput reset issues a full terminal reset sequence; stty sane is the bare-console fallback.
# '|| true' ensures the trap never returns a non-zero code, which would mask the real exit status.
trap 'rm -rf "$TMP_D"; tput reset 2>/dev/null || stty sane 2>/dev/null || true' EXIT INT TERM HUP

# Allow the caller to inject a different answerfile path via the ANSWERFILE env var.
# The default /answerfile.json is a conventional location for CI/PXE boot images.
ANSWERFILE="${ANSWERFILE:-/answerfile.json}"
ANSWERFILE_MODE=false
# Enable unattended mode only when the answerfile is actually present on disk.
[[ -f "$ANSWERFILE" ]] && ANSWERFILE_MODE=true
# Signal unattended mode to child module scripts so they skip interactive prompts
# (e.g. hyprlua's EWW form-factor question) and fall back to a sensible default.
# Exported so it reaches modules launched via `bash "$script"` in run_module().
$ANSWERFILE_MODE && export MARCHY_UNATTENDED=1

# ── Cyberqueer CLI palette ────────────────────────────────────────────────────
# Plain ANSI escape codes used by the prompt helpers below.  These keep the
# magenta/cyan "cyberqueer" look without depending on the `dialog` binary, so
# the installer runs on a bare console with nothing but bash + coreutils.
# tput is not used here because it may be unavailable on a minimal TTY.
C_RESET=$'\033[0m'
C_TITLE=$'\033[1;35m'   # bold magenta — section headers
C_ACCENT=$'\033[36m'    # cyan — prompts and selected marks
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'

# ── Plain-CLI prompt helpers ──────────────────────────────────────────────────
# These reimplement the subset of `dialog` widgets the installer needs using
# only `read`.  Widgets that return a value (input/menu/checklist/form) print
# their UI to stderr and the chosen value(s) to stdout, mirroring dialog's
# `3>&1 1>&2 2>&3` fd-swap convention so existing `$(...)` capture sites work
# unchanged.  All interactive reads come from /dev/tty so the helpers keep
# working even when stdin is redirected.

ui_header() {
    # Styled section header printed to the given fd (default stdout).
    printf '\n%s──── %s ────%s\n\n' "$C_TITLE" "$1" "$C_RESET"
}

ui_msgbox() {
    # Informational box: print title + body, wait for Enter to acknowledge.
    local title="$1" body="$2"
    ui_header "$title"
    printf '%b\n' "$body"
    printf '\n%s  Press Enter to continue…%s' "$C_DIM" "$C_RESET"
    read -r _ </dev/tty || true
    printf '\n'
}

ui_yesno() {
    # Confirmation prompt. Returns 0 for yes, 1 for no.
    # $3 sets the default applied on a bare Enter ("yes" or "no").
    local title="$1" body="$2" def="${3:-yes}" ans hint="[Y/n]"
    [[ "$def" == no ]] && hint="[y/N]"
    ui_header "$title"
    printf '%b\n' "$body"
    while true; do
        printf '\n%s  %s %s%s ' "$C_ACCENT" "Confirm?" "$hint" "$C_RESET"
        read -r ans </dev/tty || ans=""
        ans="${ans,,}"; [[ -z "$ans" ]] && ans="$def"
        case "$ans" in
            y|yes) return 0 ;;
            n|no)  return 1 ;;
        esac
    done
}

ui_input() {
    # Single-line text entry. Echoes the entered value (or default) to stdout.
    local title="$1" prompt="$2" def="${3:-}" val
    {
        ui_header "$title"
        printf '%b\n' "$prompt"
        [[ -n "$def" ]] && printf '%s  (default: %s)%s\n' "$C_DIM" "$def" "$C_RESET"
        printf '%s  > %s' "$C_ACCENT" "$C_RESET"
    } >&2
    read -r val </dev/tty || val=""
    [[ -z "$val" ]] && val="$def"
    printf '%s' "$val"
}

ui_menu() {
    # Single-choice list. Args: title prompt tag1 desc1 tag2 desc2 …
    # Echoes the chosen tag to stdout; returns 1 (no selection) if the user
    # enters 'q' or a blank line, so callers can fall back to a default.
    local title="$1" prompt="$2"; shift 2
    local -a tags=() descs=()
    while [[ $# -gt 0 ]]; do tags+=("$1"); descs+=("$2"); shift 2; done
    local i choice
    while true; do
        {
            ui_header "$title"
            printf '%b\n\n' "$prompt"
            for i in "${!tags[@]}"; do
                printf '   %s%2d%s) %s%-12s%s %s\n' \
                    "$C_ACCENT" $((i + 1)) "$C_RESET" "$C_BOLD" "${tags[$i]}" "$C_RESET" "${descs[$i]}"
            done
            printf '\n%s  Enter number (q to skip): %s' "$C_ACCENT" "$C_RESET"
        } >&2
        read -r choice </dev/tty || choice=""
        [[ "$choice" == q || -z "$choice" ]] && return 1
        if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#tags[@]} )); then
            printf '%s' "${tags[$((choice - 1))]}"
            return 0
        fi
    done
}

ui_checklist() {
    # Multi-select list. Args: title prompt tag1 desc1 state1 tag2 desc2 state2 …
    # state is "on"/"off" for the initial selection. Long lists are paginated to
    # the terminal height so they never scroll off-screen: the screen is cleared
    # and redrawn each round, with n/p moving between pages. The user toggles
    # items by number and/or range (e.g. "1 3 5-8") — numbering is global, so any
    # item can be toggled from any page — then presses Enter to confirm. Entering
    # 'q' aborts (returns 1). The space-separated selected tags go to stdout.
    local title="$1" prompt="$2"; shift 2
    local -a tags=() descs=() state=()
    while [[ $# -gt 0 ]]; do tags+=("$1"); descs+=("$2"); state+=("$3"); shift 3; done
    local n=${#tags[@]} i mark line tok lo hi j
    # Reserve rows for the header, prompt and footer; the remainder hold items.
    # tput may be absent on a bare TTY, so fall back to a safe 24-row assumption.
    local rows per pg=0 pages start end
    rows=$(tput lines 2>/dev/null || echo 24)
    [[ "$rows" =~ ^[0-9]+$ ]] || rows=24
    per=$(( rows - 9 )); (( per < 1 )) && per=1
    pages=$(( (n + per - 1) / per )); (( pages < 1 )) && pages=1
    while true; do
        (( pg < 0 )) && pg=0
        (( pg >= pages )) && pg=$(( pages - 1 ))
        start=$(( pg * per )); end=$(( start + per )); (( end > n )) && end=$n
        {
            # \033[2J\033[H clears the screen and homes the cursor so each page
            # paints in place rather than scrolling — a stable, scrollable view.
            printf '\033[2J\033[H'
            ui_header "$title"
            printf '%b' "$prompt"
            (( pages > 1 )) && printf '   %s(page %d/%d)%s' "$C_DIM" $((pg + 1)) "$pages" "$C_RESET"
            printf '\n\n'
            for (( i = start; i < end; i++ )); do
                if [[ "${state[$i]}" == on ]]; then mark="${C_ACCENT}[x]${C_RESET}"; else mark='[ ]'; fi
                printf '  %s %s%3d%s  %s\n' "$mark" "$C_ACCENT" $((i + 1)) "$C_RESET" "${descs[$i]}"
            done
            if (( pages > 1 )); then
                printf '\n%s  number/range toggles (1 3 5-8) · n/p next/prev page · Enter confirms · q aborts%s\n' "$C_DIM" "$C_RESET"
            else
                printf '\n%s  Toggle items by number/range (e.g. 1 3 5-8); Enter confirms, q aborts.%s\n' "$C_DIM" "$C_RESET"
            fi
            printf '%s  > %s' "$C_ACCENT" "$C_RESET"
        } >&2
        read -r line </dev/tty || line=""
        case "$line" in
            q)   return 1 ;;
            "")  break ;;
            n|N) pg=$(( pg + 1 )); continue ;;
            p|P) pg=$(( pg - 1 )); continue ;;
        esac
        for tok in $line; do
            if [[ "$tok" =~ ^([0-9]+)-([0-9]+)$ ]]; then
                lo=${BASH_REMATCH[1]}; hi=${BASH_REMATCH[2]}
            elif [[ "$tok" =~ ^[0-9]+$ ]]; then
                lo=$tok; hi=$tok
            else
                continue
            fi
            for (( j = lo; j <= hi; j++ )); do
                (( j >= 1 && j <= n )) || continue
                if [[ "${state[$((j - 1))]}" == on ]]; then state[$((j - 1))]=off; else state[$((j - 1))]=on; fi
            done
        done
    done
    local out=""
    for i in "${!tags[@]}"; do
        [[ "${state[$i]}" == on ]] && out+="${tags[$i]} "
    done
    printf '%s' "${out% }"
}

ui_form() {
    # Sequential field entry. Args: title prompt label1 default1 label2 default2 …
    # Prints one entered value per line to stdout (blank field → its default),
    # matching the newline-separated output of `dialog --form`.
    local title="$1" prompt="$2"; shift 2
    {
        ui_header "$title"
        printf '%b\n' "$prompt"
    } >&2
    local label def v out=""
    while [[ $# -gt 0 ]]; do
        label="$1"; def="$2"; shift 2
        printf '%s  %s%s%s [%s]: ' "$C_ACCENT" "$C_BOLD" "$label" "$C_RESET$C_ACCENT" "$def" >&2
        printf '%s' "$C_RESET" >&2
        read -r v </dev/tty || v=""
        [[ -z "$v" ]] && v="$def"
        out+="$v"$'\n'
    done
    printf '%s' "$out"
}

# ── State ─────────────────────────────────────────────────────────────────────
# STEP counts completed modules; TOTAL is pre-computed by count_steps() before
# installation begins so run_module() can print accurate [N/TOTAL] progress.
STEP=0
TOTAL=0

# ── Helpers ───────────────────────────────────────────────────────────────────
require_jq() {
    # jq is only needed in answerfile mode, so we install it on demand rather
    # than making it a hard dependency for interactive use.
    command -v jq &>/dev/null && return
    echo "jq not found — installing..."
    sudo pacman -S --noconfirm jq || { echo "Failed to install jq."; exit 1; }
}

die() {
    # Fatal error helper: clear the screen before printing so the message
    # is readable, then exit 1.  Uses stderr to keep it out of any $() capture.
    clear
    printf "\n  Error: %s\n\n" "$1" >&2
    exit 1
}

warn() {
    # Non-fatal notice: in answerfile mode just log it; interactively show a
    # message box that blocks until the user acknowledges with Enter.
    if $ANSWERFILE_MODE; then
        printf "\n  Warning: %s\n" "$1" | tee -a "$LOG"
    else
        ui_msgbox " Module Conflict " "  $1"
    fi
}

log_sep() {
    # Write a visible separator plus a timestamp to the log before each module run.
    # Appended with >> so earlier log entries are never overwritten mid-install.
    printf "\n══════════════════════════════════\n  %s\n  %s\n" "$1" "$(date)" >> "$LOG"
}

flush_tty_input() {
    # Discard any keystrokes queued on the terminal before the first prompt.
    # This script normally runs right after the long, mostly-unattended base
    # install (partitioning, pacstrap, …); impatient Enter presses or other
    # stray input typed during that wait sit in the tty's input buffer and
    # would otherwise be consumed by the very first `read` here — making the
    # first checklist appear to auto-confirm its defaults before the user can
    # toggle anything. A short non-blocking drain loop clears the queue.
    local junk
    while read -r -t 0.05 junk </dev/tty 2>/dev/null; do :; done
}

run_module() {
    local label="$1" script="$2"
    STEP=$(( STEP + 1 ))
    log_sep "[$STEP/$TOTAL] $label"

    # Clear the selection screen so module output scrolls cleanly on the raw terminal.
    clear
    printf "\n\033[1;35m  [$STEP/$TOTAL] %s\033[0m\n" "$label"
    printf "\033[35m  ─────────────────────────────────────────────\033[0m\n\n"

    # Run the module in the FOREGROUND so its stdin stays connected to the
    # terminal: some modules prompt interactively (e.g. hyprlua's EWW form-factor
    # selection). Backgrounding the module would make any such read trigger
    # SIGTTIN and stop the job. Output (stdout+stderr) is appended to the log
    # FILE rather than piped through 'tee'. A 'bash "$script" | tee' pipeline
    # blocks AFTER the module finishes whenever a daemon it spawned (flatpak's
    # system helper, gpg-agent, dbus, …) inherits the pipe's write end and holds
    # it open — the "module done but it pauses" symptom. A plain file write has no
    # such write end to inherit, so lingering daemons can't stall us. A background
    # 'tail -f' mirrors freshly appended lines to the console for live output.
    local rc=0
    tail -n0 -f "$LOG" 2>/dev/null &
    local _tailpid=$!
    bash "$script" >>"$LOG" 2>&1
    rc=$?
    # Stop the mirror; give it a moment to flush the module's final lines.
    sleep 0.2
    kill "$_tailpid" 2>/dev/null || true
    wait "$_tailpid" 2>/dev/null || true

    if [[ $rc -ne 0 ]]; then
        if [[ $ANSWERFILE_MODE == true ]]; then
            # Unattended mode: log the failure and keep going; aborting mid-CI
            # would require a full manual re-run.
            printf "\n  Warning: %s exited with code %d — continuing.\n" "$label" "$rc" | tee -a "$LOG"
        else
            # Interactive mode: ask the user whether to abort or continue.
            # ui_yesno returns 0 for Yes and 1 for No; the '|| { ...; exit 1; }'
            # fires on No, aborting the install.
            ui_yesno " Module Failed " "$label exited with code $rc.\n\nContinue anyway?" no \
                || { clear; exit 1; }
        fi
    fi
}

count_steps() {
    # Pre-count the total number of modules that will actually run so that
    # run_module() can display accurate [N/TOTAL] progress from the first step.
    # Each glob test mirrors the corresponding run_module() call below; they
    # must stay in sync whenever a new module is added or removed.
    local c=" $1 " de="$2" a="${3:-}"
    TOTAL=0
    # Base components: each keyword maps to exactly one module script.
    # Padded with spaces so "shell" doesn't false-match the "shellrc" tag.
    [[ "$c" == *" pkg "*      ]] && TOTAL=$(( TOTAL + 1 ))
    [[ "$c" == *" core "*     ]] && TOTAL=$(( TOTAL + 1 ))
    [[ "$c" == *" svc "*      ]] && TOTAL=$(( TOTAL + 1 ))
    [[ "$c" == *" shell "*    ]] && TOTAL=$(( TOTAL + 1 ))
    # A non-"none" DE selection always installs exactly one DE module.
    [[ "$de" != "none" ]] && TOTAL=$(( TOTAL + 1 ))
    # Optional app modules: one word-boundary check per app, one increment per match.
    # Padding $a with spaces and matching " tag " prevents partial-name false matches
    # (e.g. "plymouth" must not match against "plymouth-custom").
# BEGIN GENERATED MODULES: module-counters
    [[ " $a " == *" ollama "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" llama-cpp "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" open-webui "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" claude "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" networking-cli "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" disk-recovery "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" himalaya "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" mail-notmuch "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" caldav-sync "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" ssh-server "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" wireshark "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" anti-malware "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" gnuplot "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" blender-povray "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" toot "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" db-clients "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" mysql "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" productivity "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" python "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" k8s "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" docker "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" podman "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" cockpit "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" tlp "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" butter "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" localsend "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" croc "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" opendeck "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" localtunnel "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" timeshift "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" zfs "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" wprs "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" plymouth "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" plymouth-custom "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" greetd-regreet "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" lightdm "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" ly "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" steam "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" vesktop "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" spotify "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" prism "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" vintagestory "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" openarena "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" tetris "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" doom "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" sauerbraten "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" stuntrally "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" onlyoffice "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" xournal "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" rnote "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" obsidian "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" tangent-notes "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" ffmpeg "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" sox "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" imagemagick "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" yt-dlp "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" gimp "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" inkscape "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" krita "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" kdenlive "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" openshot "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" shotcut "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" ardour "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" audacity "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" lmms "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" mixxx "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" cecilia "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" chromium "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" firefox-browser "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" zen-browser "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" nyxt "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" librewolf "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" min-browser "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" vscodium "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" zed-ide "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" geany "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" codeblocks "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" kate "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" rdp-client "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" lamco-rdp-server "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" qemu "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" freeipa-client "* ]] && TOTAL=$(( TOTAL + 1 ))
    [[ " $a " == *" freeipa-server "* ]] && TOTAL=$(( TOTAL + 1 ))
# END GENERATED MODULES: module-counters
}

# ── Answerfile ────────────────────────────────────────────────────────────────
# AF_* variables hold values parsed from the JSON answerfile.
# They are initialised to safe defaults so the rest of the script can reference
# them unconditionally whether or not answerfile mode is active.
AF_COMPONENTS=""
AF_DE="none"
AF_APPS=""
AF_COLOR_TEXT=""
AF_COLOR_BG=""
AF_COLOR_HIGHLIGHT=""
AF_COLOR_DARK=""
AF_COLOR_RED=""

load_answerfile() {
    require_jq
    # -r outputs raw strings without JSON quoting.
    # '// ""' / '// "none"' are jq's null-coalescing operator: if the key is
    # absent from the file the expression yields the fallback instead.
    # JSON arrays are flattened to a space-separated string to match the format
    # that COMPONENTS and SELECTED_APPS expect throughout the rest of the script.
    AF_COMPONENTS=$(jq -r '(.components // []) | join(" ")' "$ANSWERFILE")
    AF_DE=$(jq -r '.desktop_environment // "none"' "$ANSWERFILE")
    AF_APPS=$(jq -r '(.apps // []) | join(" ")' "$ANSWERFILE")
    # Color values are optional; an empty string means "keep the repo default".
    AF_COLOR_TEXT=$(jq -r '.colors.COLOR_TEXT // ""' "$ANSWERFILE")
    AF_COLOR_BG=$(jq -r '.colors.COLOR_BG // ""' "$ANSWERFILE")
    AF_COLOR_HIGHLIGHT=$(jq -r '.colors.COLOR_HIGHLIGHT // ""' "$ANSWERFILE")
    AF_COLOR_DARK=$(jq -r '.colors.COLOR_DARK // ""' "$ANSWERFILE")
    AF_COLOR_RED=$(jq -r '.colors.COLOR_RED // ""' "$ANSWERFILE")
}

# ── Preflight ─────────────────────────────────────────────────────────────────
if [[ $EUID -eq 0 ]]; then
    # Root context (e.g. archiso chroot): shim sudo as a passthrough
    # Module scripts call 'sudo' for privilege operations; when we are already root
    # there is no sudo binary (or it may be absent), so inject a fake one that is
    # just 'exec "$@"' — transparently forwarding all arguments.
    mkdir -p "$TMP_D/bin"
    printf '#!/bin/bash\nexec "$@"\n' > "$TMP_D/bin/sudo"
    chmod +x "$TMP_D/bin/sudo"
    # Prepend to PATH so this shim takes precedence over any real sudo.
    export PATH="$TMP_D/bin:$PATH"
fi
# Hard guard: pacman is the package manager used by every module; without it
# the installer cannot function on this OS.
command -v pacman &>/dev/null || die "pacman not found — Arch Linux required."

if $ANSWERFILE_MODE; then
    load_answerfile
    printf "Answerfile mode: %s\n" "$ANSWERFILE" | tee -a "$LOG"
fi

# Network check: -c1 sends one ICMP echo, -W3 waits up to 3 seconds for reply.
# We test against archlinux.org because that's where pacman will fetch packages.
if ! ping -c1 -W3 archlinux.org &>/dev/null; then
    if $ANSWERFILE_MODE; then
        # In unattended mode just warn; the caller decides whether the image
        # is expected to be offline (e.g. local mirror pre-seeded in pacman).
        printf "Warning: no internet connection detected.\n" | tee -a "$LOG"
    else
        # Give the user a chance to plug in a cable or run iwctl before we
        # re-test.  A second failed ping offers a soft-abort rather than a
        # hard failure, in case the user wants to proceed with a local mirror.
        ui_msgbox " No Network Detected " "  No internet connection found.\n\n  Wired: ensure the cable is plugged in.\n  WiFi:  switch to another TTY (Alt+F2)\n         and run: iwctl\n\n  Reconnect, then press Enter."
        if ! ping -c1 -W3 archlinux.org &>/dev/null; then
            ui_yesno " Still Offline " "  Still no internet connection.\n\n  Packages cannot be downloaded without network access.\n\n  Continue anyway?" no \
                || { clear; echo "Aborted — no network."; exit 1; }
        fi
    fi
fi

# Truncate the log file now that preflight passed; all prior output was to stdout.
# Entries appended from this point on are the authoritative install log.
# ':' is the no-op command so the redirection has an explicit target.
: > "$LOG"
printf "Dotfiles install: %s\nDotfiles dir:    %s\n" "$(date)" "$DOTFILES_DIR" >> "$LOG"

# ── Welcome ───────────────────────────────────────────────────────────────────
if ! $ANSWERFILE_MODE; then
    flush_tty_input
    ui_msgbox " Welcome " "\
  the_miro's Arch dotfiles installer\n\
  Cyberqueer  ·  Wayland  ·  Hyprland\n\
  ─────────────────────────────────────────\n\
\n\
  Arch Linux — network admin, development & gaming\n\
\n\
  Source:  $DOTFILES_DIR\n\
  Log:     $LOG"
fi

# Note: the hostname is set by the base installer (archbaseos-guided-install.sh),
# so this script intentionally does not prompt for or change it.

# ── Component selection ───────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
    COMPONENTS="$AF_COMPONENTS"
else
    # ui_checklist takes tag/desc/state triplets and echoes the selected tags.
    # All five components are pre-selected ("on") because they form the expected base.
    # "shellrc" is split out from "shell" so the shell tools/plugins/programs can be
    # installed without symlinking the personal .bashrc/.zshrc/.vimrc over them —
    # it only has an effect when "shell" is also selected.
    # Entering 'q' returns exit code 1; the '|| { ...; exit 0; }' treats that as a clean abort.
    COMPONENTS=$(ui_checklist " Select Components " "Optional base components — all enabled by default:" \
        "pkg"      "pkg      Package managers    yay · nvm · rust"                     on  \
        "core"     "core     Core packages       100+ base system packages"            on  \
        "svc"      "svc      Core services       NetworkManager · cronie · fail2ban"   on  \
        "shell"    "shell    Shell setup         zsh · nvim · yazi · micro · starship" on  \
        "shellrc"  "shellrc  Personal shell rc's .bashrc/.zshrc/.vimrc (needs shell)"  on) \
        || { clear; echo "Aborted."; exit 0; }
fi

# ── DE selection ──────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
    DE="$AF_DE"
else
    # ui_menu is a single-choice list; it outputs the selected tag.
    # 'q'/blank returns exit code 1 — '|| DE="none"' defaults to skipping the DE,
    # preserving whatever DE the user already has installed.
    DE=$(ui_menu " Desktop Environment " "Select a desktop environment  ·  q / none to skip:" \
        "hyprlua"    "HyprLua    — Hyprland with Lua config (recommended)" \
        "niri"       "Niri       — scrollable-tiling Wayland compositor" \
        "hyprland"   "Hyprland   — Wayland WM, hyprlang config (legacy)" \
        "sway"       "Sway       — Wayland tiling WM" \
        "kde-plasma" "KDE Plasma — feature-rich Wayland/X11 DE" \
        "gnome"      "GNOME      — modern Wayland DE" \
        "cosmic"     "COSMIC     — Rust-built Wayland DE (System76)" \
        "xfce"       "XFCE       — lightweight X11 DE" \
        "lxqt"       "LXQt       — lightweight Qt X11 DE" \
        "none"       "Skip DE installation") || DE="none"
fi

# ── Apps selection ────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
    SELECTED_APPS="$AF_APPS"
else
# BEGIN GENERATED MODULES: module-checklist
    SELECTED_APPS=$(ui_checklist " Applications " "Optional applications — installed after base components:" \
        "ollama"  "ollama                local LLM runner and API server"  off \
        "llama-cpp"  "llama-cpp             standalone LLM inference CLI and server"  off \
        "open-webui"  "open-webui            browser UI for Ollama and LLM backends"  off \
        "claude"  "claude                Anthropic Claude Code CLI via npm"  off \
        "networking-cli"  "networking-cli        nmap, nethogs, mitmproxy, httpie"  off \
        "disk-recovery"  "disk-recovery         ddrescue and f3 disk recovery tools"  off \
        "himalaya"  "himalaya              terminal email client (AUR)"  off \
        "mail-notmuch"  "mail-notmuch          isync, msmtp, notmuch, alot mail stack"  off \
        "caldav-sync"  "caldav-sync           vdirsyncer and khal CalDAV calendar sync"  off \
        "ssh-server"  "ssh-server            openssh with key-auth and systemd unit enabled"  off \
        "wireshark"  "wireshark             network packet analyser GUI"  off \
        "anti-malware"  "anti-malware          ClamAV, rkhunter, chkrootkit"  off \
        "gnuplot"  "gnuplot               scientific plotting tool"  off \
        "blender-povray"  "blender-povray        3D modelling and ray-tracing (Blender + POV-Ray)"  off \
        "toot"  "toot                  Mastodon CLI client (AUR)"  off \
        "db-clients"  "db-clients            pgcli and mycli interactive database CLIs"  off \
        "mysql"  "mysql                 MariaDB server with initial setup"  off \
        "productivity"  "productivity          taskwarrior, watson, jrnl — task management and time tracking"  off \
        "python"  "python                pyright, pipx, pynvim Python tooling"  on \
        "k8s"  "k8s                   kubectl and podman-desktop Kubernetes tools"  off \
        "docker"  "docker                docker and docker-compose"  off \
        "podman"  "podman                rootless containers with buildah"  off \
        "cockpit"  "cockpit               web UI for machines and containers"  off \
        "tlp"  "tlp                   laptop battery optimisation"  off \
        "butter"  "butter                btrfs snapshot backup manager (AUR)"  off \
        "localsend"  "localsend             LAN file transfer, AirDrop-like (AUR)"  off \
        "croc"  "croc                  cross-platform encrypted file transfer"  off \
        "opendeck"  "opendeck              Stream Deck controller — ydotool + OpenDeck (Flatpak)"  off \
        "localtunnel"  "localtunnel           expose localhost over a public URL"  off \
        "timeshift"  "timeshift             system snapshot and backup with autosnap"  on \
        "zfs"  "zfs                   zfs-dkms kernel module"  off \
        "wprs"  "wprs                  Wayland proxy for remote sessions (wprs-git, AUR)"  off \
        "plymouth"  "plymouth              boot splash — bundled skull logo + boot-log tail"  on \
        "plymouth-custom"  "plymouth-custom       boot splash with a user-supplied image"  off \
        "greetd-regreet"  "greetd-regreet        greetd + ReGreet greeter, Wayland-native, cyberqueer-themed"  off \
        "lightdm"  "lightdm               LightDM GTK greeter, cyberqueer-themed (Xorg)"  off \
        "ly"  "ly                    ly TUI display manager, cyberqueer-themed"  off \
        "steam"  "steam                 Steam gaming platform"  off \
        "vesktop"  "vesktop               Discord client with Vencord theme"  off \
        "spotify"  "spotify               Spotify launcher with Spicetify theming"  off \
        "prism"  "prism                 PrismLauncher Minecraft launcher (Flatpak)"  off \
        "vintagestory"  "vintagestory          Vintage Story survival game (AUR)"  off \
        "openarena"  "openarena             open-source Quake III Arena"  off \
        "tetris"  "tetris                bastet and vitetris terminal Tetris"  off \
        "doom"  "doom                  Chocolate Doom with Freedoom data"  off \
        "sauerbraten"  "sauerbraten           Sauerbraten open-source FPS (Cube 2)"  off \
        "stuntrally"  "stuntrally            Stunt Rally racing game (Flatpak)"  off \
        "onlyoffice"  "onlyoffice            office suite — Docs, Sheets, Slides (AUR)"  on \
        "xournal"  "xournal               note-taking and PDF annotator"  off \
        "rnote"  "rnote                 handwriting and note-taking with stylus support (Flatpak)"  off \
        "obsidian"  "obsidian              knowledge base and Markdown note-taking (Flatpak)"  off \
        "tangent-notes"  "tangent-notes         networked Markdown note-taking (Flatpak)"  off \
        "ffmpeg"  "ffmpeg                GStreamer codecs and ffmpegthumbnailer"  on \
        "sox"  "sox                   command-line audio processing toolkit"  on \
        "imagemagick"  "imagemagick           image manipulation suite"  on \
        "yt-dlp"  "yt-dlp                YouTube and media downloader"  on \
        "gimp"  "gimp                  GNU Image Manipulation Program"  off \
        "inkscape"  "inkscape              vector graphics editor"  on \
        "krita"  "krita                 digital painting and illustration"  off \
        "kdenlive"  "kdenlive              KDE non-linear video editor"  off \
        "openshot"  "openshot              cross-platform video editor"  off \
        "shotcut"  "shotcut               cross-platform video editor"  off \
        "ardour"  "ardour                professional DAW"  off \
        "audacity"  "audacity              multi-track audio editor"  off \
        "lmms"  "lmms                  Linux MultiMedia Studio music production"  off \
        "mixxx"  "mixxx                 DJ mixing software"  off \
        "cecilia"  "cecilia               audio synthesis and signal processing (AUR)"  off \
        "chromium"  "chromium              open-source Chromium browser (official)"  off \
        "firefox-browser"  "firefox-browser       Mozilla Firefox (official)"  on \
        "zen-browser"  "zen-browser           privacy-focused Firefox fork (AUR)"  off \
        "nyxt"  "nyxt                  keyboard-driven hackable browser (AUR)"  off \
        "librewolf"  "librewolf             hardened Firefox fork (AUR)"  off \
        "min-browser"  "min-browser           minimal Electron browser (AUR)"  off \
        "vscodium"  "vscodium              telemetry-free VS Code build (AUR)"  off \
        "zed-ide"  "zed-ide               high-performance Rust IDE (official)"  off \
        "geany"  "geany                 lightweight IDE with plugins (official)"  off \
        "codeblocks"  "codeblocks            C/C++ IDE (official)"  off \
        "kate"  "kate                  KDE advanced text editor (official)"  off \
        "rdp-client"  "rdp-client            Remmina with FreeRDP and VNC plugins"  off \
        "lamco-rdp-server"  "lamco-rdp-server      native Wayland RDP server (AUR, Rust)"  off \
        "qemu"  "qemu                  full QEMU/KVM stack with virt-manager"  off \
        "freeipa-client"  "freeipa-client        sssd and ipa-client-install with auto-enrollment"  off \
        "freeipa-server"  "freeipa-server        interactive FreeIPA server setup with client generator"  off \
        ) || SELECTED_APPS=""
# END GENERATED MODULES: module-checklist
fi

# ── Shell RC preference ───────────────────────────────────────────────────────
# Derived from the "shellrc" component rather than asked separately: it only takes
# effect when "shell" also runs, and whether new users (via /etc/skel) inherit the
# rc files should follow the same choice as the current user's deployment.
if [[ " $COMPONENTS " == *" shell "* && " $COMPONENTS " == *" shellrc "* ]]; then
    SHELL_RC="dotfiles"
else
    SHELL_RC="defaults"
fi

# ── Confirmation (interactive mode only) ──────────────────────────────────────
if ! $ANSWERFILE_MODE; then
    # Build a human-readable summary of everything that will be installed so the
    # user can review the full list before any changes are made to the system.
    SUMMARY=""
    [[ " $COMPONENTS " == *" pkg "*      ]] && SUMMARY+="  ✦ Package managers (yay, nvm, rust)\n"
    [[ " $COMPONENTS " == *" core "*     ]] && SUMMARY+="  ✦ Core packages\n"
    [[ " $COMPONENTS " == *" svc "*      ]] && SUMMARY+="  ✦ Core services\n"
    [[ " $COMPONENTS " == *" shell "*    ]] && SUMMARY+="  ✦ Shell setup (plugins & programs)\n"
    [[ "$SHELL_RC" == "dotfiles" ]] && SUMMARY+="  ✦ Personal shell rc's (.bashrc/.zshrc/.vimrc)\n"
    [[ "$DE" != "none" && "$DE" != "" ]] && SUMMARY+="  ✦ Desktop environment: $DE\n"
    [[ "$SHELL_RC" == "dotfiles" ]] && SUMMARY+="  ✦ Shell rc's → /etc/skel for new users (dotfiles)\n" \
        || SUMMARY+="  ✦ Shell rc's → /etc/skel for new users (system defaults)\n"

    if [[ -n "$SELECTED_APPS" ]]; then
        SUMMARY+="\n  Applications:\n"
# BEGIN GENERATED MODULES: module-summary
        [[ " $SELECTED_APPS " == *" ollama "* ]] && SUMMARY+="  ✦ ollama\n"
        [[ " $SELECTED_APPS " == *" llama-cpp "* ]] && SUMMARY+="  ✦ llama-cpp\n"
        [[ " $SELECTED_APPS " == *" open-webui "* ]] && SUMMARY+="  ✦ open-webui\n"
        [[ " $SELECTED_APPS " == *" claude "* ]] && SUMMARY+="  ✦ claude\n"
        [[ " $SELECTED_APPS " == *" networking-cli "* ]] && SUMMARY+="  ✦ networking-cli\n"
        [[ " $SELECTED_APPS " == *" disk-recovery "* ]] && SUMMARY+="  ✦ disk-recovery\n"
        [[ " $SELECTED_APPS " == *" himalaya "* ]] && SUMMARY+="  ✦ himalaya\n"
        [[ " $SELECTED_APPS " == *" mail-notmuch "* ]] && SUMMARY+="  ✦ mail-notmuch\n"
        [[ " $SELECTED_APPS " == *" caldav-sync "* ]] && SUMMARY+="  ✦ caldav-sync\n"
        [[ " $SELECTED_APPS " == *" ssh-server "* ]] && SUMMARY+="  ✦ ssh-server\n"
        [[ " $SELECTED_APPS " == *" wireshark "* ]] && SUMMARY+="  ✦ wireshark\n"
        [[ " $SELECTED_APPS " == *" anti-malware "* ]] && SUMMARY+="  ✦ anti-malware\n"
        [[ " $SELECTED_APPS " == *" gnuplot "* ]] && SUMMARY+="  ✦ gnuplot\n"
        [[ " $SELECTED_APPS " == *" blender-povray "* ]] && SUMMARY+="  ✦ blender-povray\n"
        [[ " $SELECTED_APPS " == *" toot "* ]] && SUMMARY+="  ✦ toot\n"
        [[ " $SELECTED_APPS " == *" db-clients "* ]] && SUMMARY+="  ✦ db-clients\n"
        [[ " $SELECTED_APPS " == *" mysql "* ]] && SUMMARY+="  ✦ mysql\n"
        [[ " $SELECTED_APPS " == *" productivity "* ]] && SUMMARY+="  ✦ productivity\n"
        [[ " $SELECTED_APPS " == *" python "* ]] && SUMMARY+="  ✦ python\n"
        [[ " $SELECTED_APPS " == *" k8s "* ]] && SUMMARY+="  ✦ k8s\n"
        [[ " $SELECTED_APPS " == *" docker "* ]] && SUMMARY+="  ✦ docker\n"
        [[ " $SELECTED_APPS " == *" podman "* ]] && SUMMARY+="  ✦ podman\n"
        [[ " $SELECTED_APPS " == *" cockpit "* ]] && SUMMARY+="  ✦ cockpit\n"
        [[ " $SELECTED_APPS " == *" tlp "* ]] && SUMMARY+="  ✦ tlp\n"
        [[ " $SELECTED_APPS " == *" butter "* ]] && SUMMARY+="  ✦ butter\n"
        [[ " $SELECTED_APPS " == *" localsend "* ]] && SUMMARY+="  ✦ localsend\n"
        [[ " $SELECTED_APPS " == *" croc "* ]] && SUMMARY+="  ✦ croc\n"
        [[ " $SELECTED_APPS " == *" opendeck "* ]] && SUMMARY+="  ✦ opendeck\n"
        [[ " $SELECTED_APPS " == *" localtunnel "* ]] && SUMMARY+="  ✦ localtunnel\n"
        [[ " $SELECTED_APPS " == *" timeshift "* ]] && SUMMARY+="  ✦ timeshift\n"
        [[ " $SELECTED_APPS " == *" zfs "* ]] && SUMMARY+="  ✦ zfs\n"
        [[ " $SELECTED_APPS " == *" wprs "* ]] && SUMMARY+="  ✦ wprs\n"
        [[ " $SELECTED_APPS " == *" plymouth "* ]] && SUMMARY+="  ✦ plymouth\n"
        [[ " $SELECTED_APPS " == *" plymouth-custom "* ]] && SUMMARY+="  ✦ plymouth-custom\n"
        [[ " $SELECTED_APPS " == *" greetd-regreet "* ]] && SUMMARY+="  ✦ greetd-regreet\n"
        [[ " $SELECTED_APPS " == *" lightdm "* ]] && SUMMARY+="  ✦ lightdm\n"
        [[ " $SELECTED_APPS " == *" ly "* ]] && SUMMARY+="  ✦ ly\n"
        [[ " $SELECTED_APPS " == *" steam "* ]] && SUMMARY+="  ✦ steam\n"
        [[ " $SELECTED_APPS " == *" vesktop "* ]] && SUMMARY+="  ✦ vesktop\n"
        [[ " $SELECTED_APPS " == *" spotify "* ]] && SUMMARY+="  ✦ spotify\n"
        [[ " $SELECTED_APPS " == *" prism "* ]] && SUMMARY+="  ✦ prism\n"
        [[ " $SELECTED_APPS " == *" vintagestory "* ]] && SUMMARY+="  ✦ vintagestory\n"
        [[ " $SELECTED_APPS " == *" openarena "* ]] && SUMMARY+="  ✦ openarena\n"
        [[ " $SELECTED_APPS " == *" tetris "* ]] && SUMMARY+="  ✦ tetris\n"
        [[ " $SELECTED_APPS " == *" doom "* ]] && SUMMARY+="  ✦ doom\n"
        [[ " $SELECTED_APPS " == *" sauerbraten "* ]] && SUMMARY+="  ✦ sauerbraten\n"
        [[ " $SELECTED_APPS " == *" stuntrally "* ]] && SUMMARY+="  ✦ stuntrally\n"
        [[ " $SELECTED_APPS " == *" onlyoffice "* ]] && SUMMARY+="  ✦ onlyoffice\n"
        [[ " $SELECTED_APPS " == *" xournal "* ]] && SUMMARY+="  ✦ xournal\n"
        [[ " $SELECTED_APPS " == *" rnote "* ]] && SUMMARY+="  ✦ rnote\n"
        [[ " $SELECTED_APPS " == *" obsidian "* ]] && SUMMARY+="  ✦ obsidian\n"
        [[ " $SELECTED_APPS " == *" tangent-notes "* ]] && SUMMARY+="  ✦ tangent-notes\n"
        [[ " $SELECTED_APPS " == *" ffmpeg "* ]] && SUMMARY+="  ✦ ffmpeg\n"
        [[ " $SELECTED_APPS " == *" sox "* ]] && SUMMARY+="  ✦ sox\n"
        [[ " $SELECTED_APPS " == *" imagemagick "* ]] && SUMMARY+="  ✦ imagemagick\n"
        [[ " $SELECTED_APPS " == *" yt-dlp "* ]] && SUMMARY+="  ✦ yt-dlp\n"
        [[ " $SELECTED_APPS " == *" gimp "* ]] && SUMMARY+="  ✦ gimp\n"
        [[ " $SELECTED_APPS " == *" inkscape "* ]] && SUMMARY+="  ✦ inkscape\n"
        [[ " $SELECTED_APPS " == *" krita "* ]] && SUMMARY+="  ✦ krita\n"
        [[ " $SELECTED_APPS " == *" kdenlive "* ]] && SUMMARY+="  ✦ kdenlive\n"
        [[ " $SELECTED_APPS " == *" openshot "* ]] && SUMMARY+="  ✦ openshot\n"
        [[ " $SELECTED_APPS " == *" shotcut "* ]] && SUMMARY+="  ✦ shotcut\n"
        [[ " $SELECTED_APPS " == *" ardour "* ]] && SUMMARY+="  ✦ ardour\n"
        [[ " $SELECTED_APPS " == *" audacity "* ]] && SUMMARY+="  ✦ audacity\n"
        [[ " $SELECTED_APPS " == *" lmms "* ]] && SUMMARY+="  ✦ lmms\n"
        [[ " $SELECTED_APPS " == *" mixxx "* ]] && SUMMARY+="  ✦ mixxx\n"
        [[ " $SELECTED_APPS " == *" cecilia "* ]] && SUMMARY+="  ✦ cecilia\n"
        [[ " $SELECTED_APPS " == *" chromium "* ]] && SUMMARY+="  ✦ chromium\n"
        [[ " $SELECTED_APPS " == *" firefox-browser "* ]] && SUMMARY+="  ✦ firefox-browser\n"
        [[ " $SELECTED_APPS " == *" zen-browser "* ]] && SUMMARY+="  ✦ zen-browser\n"
        [[ " $SELECTED_APPS " == *" nyxt "* ]] && SUMMARY+="  ✦ nyxt\n"
        [[ " $SELECTED_APPS " == *" librewolf "* ]] && SUMMARY+="  ✦ librewolf\n"
        [[ " $SELECTED_APPS " == *" min-browser "* ]] && SUMMARY+="  ✦ min-browser\n"
        [[ " $SELECTED_APPS " == *" vscodium "* ]] && SUMMARY+="  ✦ vscodium\n"
        [[ " $SELECTED_APPS " == *" zed-ide "* ]] && SUMMARY+="  ✦ zed-ide\n"
        [[ " $SELECTED_APPS " == *" geany "* ]] && SUMMARY+="  ✦ geany\n"
        [[ " $SELECTED_APPS " == *" codeblocks "* ]] && SUMMARY+="  ✦ codeblocks\n"
        [[ " $SELECTED_APPS " == *" kate "* ]] && SUMMARY+="  ✦ kate\n"
        [[ " $SELECTED_APPS " == *" rdp-client "* ]] && SUMMARY+="  ✦ rdp-client\n"
        [[ " $SELECTED_APPS " == *" lamco-rdp-server "* ]] && SUMMARY+="  ✦ lamco-rdp-server\n"
        [[ " $SELECTED_APPS " == *" qemu "* ]] && SUMMARY+="  ✦ qemu\n"
        [[ " $SELECTED_APPS " == *" freeipa-client "* ]] && SUMMARY+="  ✦ freeipa-client\n"
        [[ " $SELECTED_APPS " == *" freeipa-server "* ]] && SUMMARY+="  ✦ freeipa-server\n"
# END GENERATED MODULES: module-summary
    fi

    ui_yesno " Confirm Installation " " Components to install:\n\n${SUMMARY}\n Log: $LOG" \
        || { clear; echo "Aborted."; exit 0; }
fi

# Pre-count all selected steps before installation starts so run_module() can
# display [N/TOTAL] with an accurate denominator from the very first module.
count_steps "$COMPONENTS" "$DE" "$SELECTED_APPS"

# ── Installation: base components ─────────────────────────────────────────────
# Each guard uses padded-space glob matching against the COMPONENTS string so
# "shell" doesn't false-match the "shellrc" tag.
# Order matters: package managers must be installed before packages that need yay/rust.
[[ " $COMPONENTS " == *" pkg "*      ]] && run_module "Package Managers" "$MODULES/package-managers.sh"
[[ " $COMPONENTS " == *" core "*     ]] && run_module "Core Packages"    "$MODULES/core-packages.sh"
[[ " $COMPONENTS " == *" svc "*      ]] && run_module "Core Services"    "$MODULES/core.sh"
# MARCHY_DEPLOY_SHELL_RC tells shell-setup.sh whether to symlink the personal
# .bashrc/.zshrc/.vimrc, independently of installing the shell tools/plugins themselves.
[[ " $COMPONENTS " == *" shellrc "* ]] && export MARCHY_DEPLOY_SHELL_RC=1 || export MARCHY_DEPLOY_SHELL_RC=0
[[ " $COMPONENTS " == *" shell "*    ]] && run_module "Shell Setup"      "$MODULES/shell-setup.sh"

# Route the single selected DE value to its corresponding install script.
# "none" is the skip sentinel — no case branch matches it intentionally.
if [[ "$DE" != "none" ]]; then
    case "$DE" in
        hyprlua)    run_module "HyprLua"     "$MODULES/Desktop-Environments/hyprlua.sh" ;;
        niri)       run_module "Niri"        "$MODULES/Desktop-Environments/niri.sh" ;;
        hyprland)   run_module "Hyprland"    "$MODULES/Desktop-Environments/hyprland.sh" ;;
        sway)       run_module "Sway"        "$MODULES/Desktop-Environments/sway.sh" ;;
        kde-plasma) run_module "KDE Plasma"  "$MODULES/Desktop-Environments/kde-plasma.sh" ;;
        gnome)      run_module "GNOME"       "$MODULES/Desktop-Environments/gnome.sh" ;;
        cosmic)     run_module "COSMIC"      "$MODULES/Desktop-Environments/cosmic.sh" ;;
        xfce)       run_module "XFCE"        "$MODULES/Desktop-Environments/xfce.sh" ;;
        lxqt)       run_module "LXQt"        "$MODULES/Desktop-Environments/lxqt.sh" ;;
    esac
fi

# ── Installation: applications ────────────────────────────────────────────────
# Same guard pattern as base components.  Each line is independent; a missing
# module script will be caught by run_module()'s error handling rather than
# silently skipped — 'bash "$script"' will exit non-zero if the file is absent.
# BEGIN GENERATED MODULES: module-conflicts
if [[ " $SELECTED_APPS " == *" plymouth "* && " $SELECTED_APPS " == *" plymouth-custom "* ]]; then
    warn "'plymouth' and 'plymouth-custom' are mutually exclusive — deselecting 'plymouth-custom'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ plymouth-custom / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" plymouth-custom "* && " $SELECTED_APPS " == *" plymouth "* ]]; then
    warn "'plymouth-custom' and 'plymouth' are mutually exclusive — deselecting 'plymouth'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ plymouth / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" greetd-regreet "* && " $SELECTED_APPS " == *" lightdm "* ]]; then
    warn "'greetd-regreet' and 'lightdm' are mutually exclusive — deselecting 'lightdm'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ lightdm / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" greetd-regreet "* && " $SELECTED_APPS " == *" ly "* ]]; then
    warn "'greetd-regreet' and 'ly' are mutually exclusive — deselecting 'ly'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ ly / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" lightdm "* && " $SELECTED_APPS " == *" greetd-regreet "* ]]; then
    warn "'lightdm' and 'greetd-regreet' are mutually exclusive — deselecting 'greetd-regreet'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ greetd-regreet / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" lightdm "* && " $SELECTED_APPS " == *" ly "* ]]; then
    warn "'lightdm' and 'ly' are mutually exclusive — deselecting 'ly'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ ly / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" ly "* && " $SELECTED_APPS " == *" greetd-regreet "* ]]; then
    warn "'ly' and 'greetd-regreet' are mutually exclusive — deselecting 'greetd-regreet'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ greetd-regreet / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" ly "* && " $SELECTED_APPS " == *" lightdm "* ]]; then
    warn "'ly' and 'lightdm' are mutually exclusive — deselecting 'lightdm'"
    SELECTED_APPS=" $SELECTED_APPS "
    SELECTED_APPS="${SELECTED_APPS/ lightdm / }"
    SELECTED_APPS="${SELECTED_APPS# }"
    SELECTED_APPS="${SELECTED_APPS% }"
fi
# END GENERATED MODULES: module-conflicts
# BEGIN GENERATED MODULES: module-dispatch
[[ " $SELECTED_APPS " == *" ollama "* ]] && run_module "ollama"  "$APPS/ollama.sh"
[[ " $SELECTED_APPS " == *" llama-cpp "* ]] && run_module "llama-cpp"  "$APPS/llama-cpp.sh"
[[ " $SELECTED_APPS " == *" open-webui "* ]] && run_module "open-webui"  "$APPS/open-webui.sh"
[[ " $SELECTED_APPS " == *" claude "* ]] && run_module "claude"  "$APPS/claude.sh"
[[ " $SELECTED_APPS " == *" networking-cli "* ]] && run_module "networking-cli"  "$APPS/networking-cli.sh"
[[ " $SELECTED_APPS " == *" disk-recovery "* ]] && run_module "disk-recovery"  "$APPS/disk-recovery.sh"
[[ " $SELECTED_APPS " == *" himalaya "* ]] && run_module "himalaya"  "$APPS/himalaya.sh"
[[ " $SELECTED_APPS " == *" mail-notmuch "* ]] && run_module "mail-notmuch"  "$APPS/mail-notmuch.sh"
[[ " $SELECTED_APPS " == *" caldav-sync "* ]] && run_module "caldav-sync"  "$APPS/caldav-sync.sh"
[[ " $SELECTED_APPS " == *" ssh-server "* ]] && run_module "ssh-server"  "$APPS/ssh-server.sh"
[[ " $SELECTED_APPS " == *" wireshark "* ]] && run_module "wireshark"  "$APPS/wireshark.sh"
[[ " $SELECTED_APPS " == *" anti-malware "* ]] && run_module "anti-malware"  "$APPS/anti-malware.sh"
[[ " $SELECTED_APPS " == *" gnuplot "* ]] && run_module "gnuplot"  "$APPS/gnuplot.sh"
[[ " $SELECTED_APPS " == *" blender-povray "* ]] && run_module "blender-povray"  "$APPS/blender-povray.sh"
[[ " $SELECTED_APPS " == *" toot "* ]] && run_module "toot"  "$APPS/toot.sh"
[[ " $SELECTED_APPS " == *" db-clients "* ]] && run_module "db-clients"  "$APPS/db-clients.sh"
[[ " $SELECTED_APPS " == *" mysql "* ]] && run_module "mysql"  "$APPS/mysql.sh"
[[ " $SELECTED_APPS " == *" productivity "* ]] && run_module "productivity"  "$APPS/productivity.sh"
[[ " $SELECTED_APPS " == *" python "* ]] && run_module "python"  "$APPS/python.sh"
[[ " $SELECTED_APPS " == *" k8s "* ]] && run_module "k8s"  "$APPS/k8s.sh"
[[ " $SELECTED_APPS " == *" docker "* ]] && run_module "docker"  "$APPS/docker.sh"
[[ " $SELECTED_APPS " == *" podman "* ]] && run_module "podman"  "$APPS/podman.sh"
[[ " $SELECTED_APPS " == *" cockpit "* ]] && run_module "cockpit"  "$APPS/cockpit.sh"
[[ " $SELECTED_APPS " == *" tlp "* ]] && run_module "tlp"  "$APPS/tlp.sh"
[[ " $SELECTED_APPS " == *" butter "* ]] && run_module "butter"  "$APPS/butter.sh"
[[ " $SELECTED_APPS " == *" localsend "* ]] && run_module "localsend"  "$APPS/localsend.sh"
[[ " $SELECTED_APPS " == *" croc "* ]] && run_module "croc"  "$APPS/croc.sh"
[[ " $SELECTED_APPS " == *" opendeck "* ]] && run_module "opendeck"  "$APPS/opendeck.sh"
[[ " $SELECTED_APPS " == *" localtunnel "* ]] && run_module "localtunnel"  "$APPS/localtunnel.sh"
[[ " $SELECTED_APPS " == *" timeshift "* ]] && run_module "timeshift"  "$APPS/timeshift.sh"
[[ " $SELECTED_APPS " == *" zfs "* ]] && run_module "zfs"  "$APPS/zfs.sh"
[[ " $SELECTED_APPS " == *" wprs "* ]] && run_module "wprs"  "$APPS/wprs.sh"
[[ " $SELECTED_APPS " == *" plymouth "* ]] && run_module "plymouth"  "$APPS/plymouth.sh"
[[ " $SELECTED_APPS " == *" plymouth-custom "* ]] && run_module "plymouth-custom"  "$APPS/plymouth-custom.sh"
[[ " $SELECTED_APPS " == *" greetd-regreet "* ]] && run_module "greetd-regreet"  "$APPS/greetd-regreet.sh"
[[ " $SELECTED_APPS " == *" lightdm "* ]] && run_module "lightdm"  "$APPS/lightdm.sh"
[[ " $SELECTED_APPS " == *" ly "* ]] && run_module "ly"  "$APPS/ly.sh"
[[ " $SELECTED_APPS " == *" steam "* ]] && run_module "steam"  "$APPS/steam.sh"
[[ " $SELECTED_APPS " == *" vesktop "* ]] && run_module "vesktop"  "$APPS/vesktop.sh"
[[ " $SELECTED_APPS " == *" spotify "* ]] && run_module "spotify"  "$APPS/spotify.sh"
[[ " $SELECTED_APPS " == *" prism "* ]] && run_module "prism"  "$APPS/prism.sh"
[[ " $SELECTED_APPS " == *" vintagestory "* ]] && run_module "vintagestory"  "$APPS/vintagestory.sh"
[[ " $SELECTED_APPS " == *" openarena "* ]] && run_module "openarena"  "$APPS/openarena.sh"
[[ " $SELECTED_APPS " == *" tetris "* ]] && run_module "tetris"  "$APPS/tetris.sh"
[[ " $SELECTED_APPS " == *" doom "* ]] && run_module "doom"  "$APPS/doom.sh"
[[ " $SELECTED_APPS " == *" sauerbraten "* ]] && run_module "sauerbraten"  "$APPS/sauerbraten.sh"
[[ " $SELECTED_APPS " == *" stuntrally "* ]] && run_module "stuntrally"  "$APPS/stuntrally.sh"
[[ " $SELECTED_APPS " == *" onlyoffice "* ]] && run_module "onlyoffice"  "$APPS/onlyoffice.sh"
[[ " $SELECTED_APPS " == *" xournal "* ]] && run_module "xournal"  "$APPS/xournal.sh"
[[ " $SELECTED_APPS " == *" rnote "* ]] && run_module "rnote"  "$APPS/rnote.sh"
[[ " $SELECTED_APPS " == *" obsidian "* ]] && run_module "obsidian"  "$APPS/obsidian.sh"
[[ " $SELECTED_APPS " == *" tangent-notes "* ]] && run_module "tangent-notes"  "$APPS/tangent-notes.sh"
[[ " $SELECTED_APPS " == *" ffmpeg "* ]] && run_module "ffmpeg"  "$APPS/ffmpeg.sh"
[[ " $SELECTED_APPS " == *" sox "* ]] && run_module "sox"  "$APPS/sox.sh"
[[ " $SELECTED_APPS " == *" imagemagick "* ]] && run_module "imagemagick"  "$APPS/imagemagick.sh"
[[ " $SELECTED_APPS " == *" yt-dlp "* ]] && run_module "yt-dlp"  "$APPS/yt-dlp.sh"
[[ " $SELECTED_APPS " == *" gimp "* ]] && run_module "gimp"  "$APPS/gimp.sh"
[[ " $SELECTED_APPS " == *" inkscape "* ]] && run_module "inkscape"  "$APPS/inkscape.sh"
[[ " $SELECTED_APPS " == *" krita "* ]] && run_module "krita"  "$APPS/krita.sh"
[[ " $SELECTED_APPS " == *" kdenlive "* ]] && run_module "kdenlive"  "$APPS/kdenlive.sh"
[[ " $SELECTED_APPS " == *" openshot "* ]] && run_module "openshot"  "$APPS/openshot.sh"
[[ " $SELECTED_APPS " == *" shotcut "* ]] && run_module "shotcut"  "$APPS/shotcut.sh"
[[ " $SELECTED_APPS " == *" ardour "* ]] && run_module "ardour"  "$APPS/ardour.sh"
[[ " $SELECTED_APPS " == *" audacity "* ]] && run_module "audacity"  "$APPS/audacity.sh"
[[ " $SELECTED_APPS " == *" lmms "* ]] && run_module "lmms"  "$APPS/lmms.sh"
[[ " $SELECTED_APPS " == *" mixxx "* ]] && run_module "mixxx"  "$APPS/mixxx.sh"
[[ " $SELECTED_APPS " == *" cecilia "* ]] && run_module "cecilia"  "$APPS/cecilia.sh"
[[ " $SELECTED_APPS " == *" chromium "* ]] && run_module "chromium"  "$APPS/chromium.sh"
[[ " $SELECTED_APPS " == *" firefox-browser "* ]] && run_module "firefox-browser"  "$APPS/firefox-browser.sh"
[[ " $SELECTED_APPS " == *" zen-browser "* ]] && run_module "zen-browser"  "$APPS/zen-browser.sh"
[[ " $SELECTED_APPS " == *" nyxt "* ]] && run_module "nyxt"  "$APPS/nyxt.sh"
[[ " $SELECTED_APPS " == *" librewolf "* ]] && run_module "librewolf"  "$APPS/librewolf.sh"
[[ " $SELECTED_APPS " == *" min-browser "* ]] && run_module "min-browser"  "$APPS/min-browser.sh"
[[ " $SELECTED_APPS " == *" vscodium "* ]] && run_module "vscodium"  "$APPS/vscodium.sh"
[[ " $SELECTED_APPS " == *" zed-ide "* ]] && run_module "zed-ide"  "$APPS/zed-ide.sh"
[[ " $SELECTED_APPS " == *" geany "* ]] && run_module "geany"  "$APPS/geany.sh"
[[ " $SELECTED_APPS " == *" codeblocks "* ]] && run_module "codeblocks"  "$APPS/codeblocks.sh"
[[ " $SELECTED_APPS " == *" kate "* ]] && run_module "kate"  "$APPS/kate.sh"
[[ " $SELECTED_APPS " == *" rdp-client "* ]] && run_module "rdp-client"  "$APPS/rdp-client.sh"
[[ " $SELECTED_APPS " == *" lamco-rdp-server "* ]] && run_module "lamco-rdp-server"  "$APPS/lamco-rdp-server.sh"
[[ " $SELECTED_APPS " == *" qemu "* ]] && run_module "qemu"  "$APPS/qemu.sh"
[[ " $SELECTED_APPS " == *" freeipa-client "* ]] && run_module "freeipa-client"  "$APPS/freeipa-client.sh"
[[ " $SELECTED_APPS " == *" freeipa-server "* ]] && run_module "freeipa-server"  "$APPS/freeipa-server.sh"
# END GENERATED MODULES: module-dispatch

# ── Colorway (final step) ─────────────────────────────────────────────────────
# Read defaults from repo colors.conf for pre-population.
# Running after all modules ensures apply-theme.sh can re-process configs
# that were just symlinked or written by the DE/app modules.
declare -A _cdef
if [[ -f "$DOTFILES_DIR/colors.conf" ]]; then
    # Parse 'KEY=VALUE' lines, skipping comments and blank lines.
    # k="${k%%[[:space:]]*}" strips any trailing whitespace from the key.
    # v="${v%%#*}" strips inline comments; v="${v//[[:space:]]/}" removes all spaces;
    # v="${v^^}" uppercases the hex value so comparisons are case-insensitive.
    while IFS='=' read -r k v; do
        k="${k%%[[:space:]]*}"
        [[ "$k" =~ ^[[:space:]]*# || -z "$k" ]] && continue
        v="${v%%#*}"; v="${v//[[:space:]]/}"; v="${v^^}"
        _cdef[$k]="$v"
    done < "$DOTFILES_DIR/colors.conf"
fi
# Fall back to hard-coded defaults if colors.conf is absent or missing a key.
DEF_TEXT="${_cdef[COLOR_TEXT]:-D6ABAB}"
DEF_BG="${_cdef[COLOR_BG]:-1A1A1A}"
DEF_HIGHLIGHT="${_cdef[COLOR_HIGHLIGHT]:-E40046}"
DEF_DARK="${_cdef[COLOR_DARK]:-5018DD}"
DEF_RED="${_cdef[COLOR_RED]:-F50505}"

_write_colors_conf() {
    # Write a normalized colors.conf to a temporary path.
    # ${t^^} uppercases each hex value so apply-theme.sh always sees consistent casing.
    # The file is written to TMP_D so it never clobbers the repo's own colors.conf.
    local out="$1" t="$2" b="$3" h="$4" d="$5" r="$6"
    printf 'COLOR_TEXT=%s\nCOLOR_BG=%s\nCOLOR_HIGHLIGHT=%s\nCOLOR_DARK=%s\nCOLOR_RED=%s\n' \
        "${t^^}" "${b^^}" "${h^^}" "${d^^}" "${r^^}" > "$out"
}

if $ANSWERFILE_MODE; then
    # Apply colors from answerfile if any are set.
    # The concatenated string test is a concise way to check if at least one
    # color field is non-empty without needing five separate [[ -n ]] guards.
    if [[ -n "$AF_COLOR_TEXT$AF_COLOR_BG$AF_COLOR_HIGHLIGHT$AF_COLOR_DARK$AF_COLOR_RED" ]]; then
        TMP_COLORS="$TMP_D/colors.conf"
        # Per-field fallback: if the answerfile omits a color, use the repo default
        # so apply-theme.sh always receives a complete, five-color config file.
        _write_colors_conf "$TMP_COLORS" \
            "${AF_COLOR_TEXT:-$DEF_TEXT}" \
            "${AF_COLOR_BG:-$DEF_BG}" \
            "${AF_COLOR_HIGHLIGHT:-$DEF_HIGHLIGHT}" \
            "${AF_COLOR_DARK:-$DEF_DARK}" \
            "${AF_COLOR_RED:-$DEF_RED}"
        printf "Applying colorway from answerfile...\n" | tee -a "$LOG"
        # '|| true' suppresses a non-zero exit from apply-theme.sh so a colorway
        # failure never aborts an otherwise successful unattended install.
        bash "$DOTFILES_DIR/apply-theme.sh" "$TMP_COLORS" 2>&1 | tee -a "$LOG" || true
    fi
else
    # Interactive: prompt for each color field in turn.
    # ui_form emits one value per line (blank → default), captured via $().
    # Pressing Enter on every field leaves COLORWAY_RAW at the defaults, and the
    # comparison below then skips the colorway step entirely.
    COLORWAY_RAW=$(ui_form " Colorway (optional) " \
        "  Customize theme colors — bare 6-digit hex, no #.\n  Leave each field unchanged to skip colorway setup." \
        "COLOR_TEXT"      "$DEF_TEXT" \
        "COLOR_BG"        "$DEF_BG" \
        "COLOR_HIGHLIGHT" "$DEF_HIGHLIGHT" \
        "COLOR_DARK"      "$DEF_DARK" \
        "COLOR_RED"       "$DEF_RED") || COLORWAY_RAW=""

    if [[ -n "$COLORWAY_RAW" ]]; then
        # mapfile reads the newline-separated form output into an array.
        # _cv[0] = COLOR_TEXT line, _cv[1] = COLOR_BG, ... through _cv[4].
        mapfile -t _cv <<< "$COLORWAY_RAW"
        N_TEXT="${_cv[0]:-$DEF_TEXT}"
        N_BG="${_cv[1]:-$DEF_BG}"
        N_HIGHLIGHT="${_cv[2]:-$DEF_HIGHLIGHT}"
        N_DARK="${_cv[3]:-$DEF_DARK}"
        N_RED="${_cv[4]:-$DEF_RED}"

        # Only invoke apply-theme.sh if at least one color actually changed.
        # ${VAR^^} uppercases before comparison to treat 'e40046' == 'E40046'.
        # This avoids a redundant theme rebuild when the user just pressed Enter
        # on every field without changing anything.
        if [[ "${N_TEXT^^}"      != "$DEF_TEXT"      || \
              "${N_BG^^}"        != "$DEF_BG"         || \
              "${N_HIGHLIGHT^^}"  != "$DEF_HIGHLIGHT"  || \
              "${N_DARK^^}"      != "$DEF_DARK"        || \
              "${N_RED^^}"       != "$DEF_RED" ]]; then
            TMP_COLORS="$TMP_D/colors.conf"
            _write_colors_conf "$TMP_COLORS" "$N_TEXT" "$N_BG" "$N_HIGHLIGHT" "$N_DARK" "$N_RED"
            clear
            printf "\n\033[1;35m  Applying colorway...\033[0m\n\n"
            bash "$DOTFILES_DIR/apply-theme.sh" "$TMP_COLORS" 2>&1 | tee -a "$LOG" || true
        fi
    fi
fi

# ── Sync user config to /etc/skel ─────────────────────────────────────────────
# /etc/skel is the skeleton directory Linux copies to new user home dirs on creation.
# Propagating the post-install config here means any new user added to this machine
# automatically gets the full dotfiles environment without a manual copy step.
if [[ -d "$HOME/.config" ]]; then
    printf "\n  Syncing ~/.config to /etc/skel...\n"
    sudo mkdir -p /etc/skel/.config
    # '.' suffix on the source copies directory contents rather than the directory
    # itself, making the merge non-destructive if /etc/skel/.config already exists.
    sudo cp -r "$HOME/.config/." /etc/skel/.config/
fi
[[ -d "$HOME/.themes" ]] && { sudo mkdir -p /etc/skel/.themes; sudo cp -r "$HOME/.themes/." /etc/skel/.themes/; }
# Copy shell rc files to skel only if the user opted in to the dotfiles configs.
if [[ "$SHELL_RC" == "dotfiles" ]]; then
    [[ -f "$HOME/.zshrc"  ]] && sudo cp "$HOME/.zshrc"  /etc/skel/.zshrc
    [[ -f "$HOME/.bashrc" ]] && sudo cp "$HOME/.bashrc" /etc/skel/.bashrc
    [[ -f "$HOME/.vimrc"  ]] && sudo cp "$HOME/.vimrc"  /etc/skel/.vimrc
fi

# ── Done ──────────────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
    printf "\nDone. Log: %s\n" "$LOG"
else
    ui_msgbox " Done " "  All selected components installed.\n\n  Log: $LOG\n\n  A reboot may be required for all changes to take effect."

    clear
    printf "\n  Done. Log: %s\n\n" "$LOG"
fi
