#!/usr/bin/env bash
# nomarchy-install — guided installer for the Nomarchy live ISO.
#
# Partitions one disk (GPT + 1 GiB ESP + BTRFS, optional LUKS2) with disko,
# generates a downstream machine flake at /home/<user>/.nomarchy (one
# nomarchy.lib.mkFlake call — the flake the user never hand-edits), and
# runs nixos-install. Works offline: the ISO pins every flake input, and
# the target flake.lock is composed from the rev the ISO was built from.
#
# Ported from the previous iteration's installer/install.sh (3bdfc35) —
# the pre-wipe and ordering comments carry its hard-won fixes. v1 scope:
# single disk, UEFI only. Multi-disk RAID and impermanence live in git
# history when they're wanted back.
#
# Unattended mode (for CI/VM tests):
#   NOMARCHY_UNATTENDED=1 NOMARCHY_DISK=/dev/vda NOMARCHY_USERNAME=me \
#   NOMARCHY_PASSWORD=secret [NOMARCHY_HOSTNAME=nomarchy] \
#   [NOMARCHY_TIMEZONE=UTC] [NOMARCHY_LUKS_PASSPHRASE=...] \
#   [NOMARCHY_LOCALE=en_US.UTF-8] [NOMARCHY_KB_LAYOUT=us] [NOMARCHY_KB_VARIANT=] \
#   [NOMARCHY_SWAP_GB=N (default: RAM size; 0 = none)] \
#   [NOMARCHY_LUKS_PASSPHRASE=... | NOMARCHY_NO_LUKS=1] \
#   [NOMARCHY_HW="auto"|"none"|"mod1 mod2"] [NOMARCHY_FINISH=none|reboot|poweroff]
#   nomarchy-install
# Unattended encryption is fail-closed: set a passphrase, or explicit
# NOMARCHY_NO_LUKS=1 — never silently install cleartext.

set -euo pipefail

keyboard_layout_catalog() {
    local rules
    rules=$(keyboard_rules_file) || return 1
    awk '
        /^! layout/ { in_section=1; next }
        /^!/ && in_section { exit }
        in_section && NF { print $1 }
    ' "$rules"
}

keyboard_variant_catalog() {
    local layout="$1" rules
    rules=$(keyboard_rules_file) || return 1
    awk -v wanted_layout="$layout:" '
        /^! variant/ { in_section=1; next }
        /^!/ && in_section { exit }
        in_section && $2 == wanted_layout { print $1 }
    ' "$rules"
}

keyboard_rules_file() {
    local rules
    for rules in "${NOMARCHY_XKB_RULES:-}" \
        /run/current-system/sw/share/X11/xkb/rules/base.lst \
        /usr/share/X11/xkb/rules/base.lst; do
        [[ -n "$rules" && -r "$rules" ]] && { echo "$rules"; return 0; }
    done
    return 1
}

keyboard_layout_name() {
    local layout="$1" rules
    rules=$(keyboard_rules_file) || { echo "layout $layout"; return; }
    awk -v wanted="$layout" '
        /^! layout/ { in_section=1; next }
        /^!/ && in_section { exit }
        in_section && $1 == wanted {
            $1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
        }
        END { if (!found) print "layout " wanted }
    ' "$rules"
}

keyboard_variant_name() {
    local layout="$1" variant="$2" rules
    rules=$(keyboard_rules_file) || { echo "$variant key behaviour"; return; }
    awk -v wanted_layout="$layout:" -v wanted_variant="$variant" '
        /^! variant/ { in_section=1; next }
        /^!/ && in_section { exit }
        in_section && $1 == wanted_variant && $2 == wanted_layout {
            $1=""; $2=""; sub(/^[[:space:]]+/, ""); print; found=1; exit
        }
        END { if (!found) print wanted_variant " key behaviour" }
    ' "$rules"
}

keyboard_layout_choices() {
    local layout
    while IFS= read -r layout; do
        [[ -n "$layout" ]] || continue
        printf '%s\t%s\n' "$layout" "$(keyboard_layout_name "$layout")"
    done < <(keyboard_layout_catalog)
}

keyboard_variant_choices() {
    local layout="$1" variant
    printf '(none)\tStandard keys (no special variant)\n'
    while IFS= read -r variant; do
        [[ -n "$variant" ]] || continue
        printf '%s\t%s\n' "$variant" "$(keyboard_variant_name "$layout" "$variant")"
    done < <(keyboard_variant_catalog "$layout")
}

keyboard_catalog_has() {
    local wanted="$1" candidate
    while IFS= read -r candidate; do
        [[ "$candidate" == "$wanted" ]] && return 0
    done
    return 1
}

# Common trust boundary for both gum and unattended input.  The picker is a
# search over these catalogs, never a text field; this second check means even
# surprising gum behaviour or a mistyped NOMARCHY_KB_* value cannot become
# generated Nix and fail much later during installation.
validate_keyboard_choice() {
    local layout="$1" variant="$2" layouts variants
    layouts=$(keyboard_layout_catalog) || {
        echo "Could not read the installed keyboard-layout catalog." >&2
        return 2
    }
    if [[ -z "$layouts" ]] || ! keyboard_catalog_has "$layout" <<< "$layouts"; then
        echo "Unknown keyboard layout '$layout'. Choose an installed layout (NOMARCHY_KB_LAYOUT)." >&2
        return 2
    fi
    [[ -z "$variant" ]] && return 0
    variants=$(keyboard_variant_catalog "$layout") || true
    if [[ -z "$variants" ]] || ! keyboard_catalog_has "$variant" <<< "$variants"; then
        echo "Unknown keyboard variant '$variant' for layout '$layout'. Choose an installed variant or no variant (NOMARCHY_KB_VARIANT)." >&2
        return 2
    fi
}

# Small, side-effect-free interface used by the deterministic installer guard.
# It deliberately runs before root/live-ISO checks and uses the exact same
# validation function as a real install.
if [[ "${1:-}" == "--validate-keyboard" ]]; then
    [[ $# -eq 3 ]] || {
        echo "usage: nomarchy-install --validate-keyboard <layout> <variant>" >&2
        exit 64
    }
    validate_keyboard_choice "$2" "$3"
    exit $?
fi

# Baked in by the package wrapper:
#   NOMARCHY_INSTALL_SHARE  — disko-config.nix, hardware-db.sh,
#                             compose-lock.py, flake.lock, template/,
#                             hardware-modules.txt
#   NOMARCHY_FLAKE_URL      — flake URL written into the generated flake.nix
#   NOMARCHY_REV            — nomarchy rev this ISO was built from ("" if dirty)
#   NOMARCHY_LOCKED_JSON / NOMARCHY_ORIGINAL_JSON — flake.lock node fields
SHARE="${NOMARCHY_INSTALL_SHARE:?not run via the packaged wrapper}"

UNATTENDED="${NOMARCHY_UNATTENDED:-0}"
LUKS_KEY_PATH="/tmp/nomarchy-luks.key"

# ─── UI helpers ─────────────────────────────────────────────────────────
header()  { gum style --border rounded --padding "0 2" --margin "1 0" \
              --border-foreground 212 "$@"; }
section() { gum style --foreground 212 --bold "── $* ──"; }
info()    { gum style --foreground 245 "  $*"; }
success() { gum style --foreground 42 "  ✓ $*"; }
warn()    { gum style --foreground 214 "  ⚠ $*"; }
fail()    { gum style --foreground 9 "  ✗ $*"; exit 1; }

confirm() { # confirm <prompt> — auto-yes when unattended
    [[ "$UNATTENDED" == "1" ]] && return 0
    gum confirm "$1"
}

# ─── Environment checks ─────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
    exec sudo --preserve-env "$0" "$@"
fi

# `sudo --preserve-env` (needed to carry the NOMARCHY_* vars) also drags in
# the live session user's HOME=/home/nomarchy. Root-run `nix` calls below
# would then scribble an eval cache + .nix-defexpr into /home/nomarchy —
# and the in-chroot one lands on the TARGET disk as a stray, orphaned
# /home/nomarchy (no such user on the installed system). Pin root's own
# HOME so every root nix invocation stays in /root; the user activation
# sets HOME=/home/$USERNAME explicitly and is unaffected.
export HOME=/root

header "Nomarchy installer" "NixOS, themed and ready to go."

[[ -d /sys/firmware/efi ]] \
    || fail "No UEFI firmware detected. v1 installs systemd-boot and needs UEFI (BIOS/legacy: see roadmap)."
command -v nixos-install >/dev/null \
    || fail "nixos-install not found — run this from the Nomarchy live ISO."
grep -q nomarchy-live /etc/hostname 2>/dev/null \
    || warn "This doesn't look like the Nomarchy live ISO; proceeding anyway."

# Offline-proof nix: disko's eval resolves <nixpkgs> (a dead channel on
# the live ISO) and flake commands may consult the global registry —
# point both at what the ISO already carries.
export NIX_PATH="nixpkgs=$NOMARCHY_NIXPKGS"
export NIX_CONFIG="flake-registry = $SHARE/registry.json"

# With no network, every substituter query is a DNS timeout + retry storm
# (and tickles a nix goal.cc assertion crash). Everything an install needs
# is pinned into the ISO — drop the binary caches and substitute from the
# live store's daemon instead. The explicit daemon substituter matters:
# nixos-install builds with --store /mnt and its own "auto?trusted=1"
# substituter resolves to the TARGET store (i.e. itself), so without
# this nothing flows from the ISO and nix bootstraps gcc from source.
NIXOS_INSTALL_OPTS=()
OFFLINE=false
if ! timeout 3 bash -c '</dev/tcp/cache.nixos.org/443' 2>/dev/null; then
    OFFLINE=true
    info "No network — substituting from the ISO store only."
    NIX_CONFIG+=$'\nsubstituters =\nextra-substituters = daemon?trusted=1\nbuilders ='
    # Must ALSO go through nixos-install as a flag: it passes its own
    # --extra-substituters "auto?trusted=1", and flags override the env
    # config for the same setting — without this the in-target build
    # substitutes from itself (= nothing) and bootstraps gcc from source.
    NIXOS_INSTALL_OPTS+=(--substituters "daemon?trusted=1")
fi

# ─── Disk selection ─────────────────────────────────────────────────────
section "Target disk"

# Never offer the medium we're running from.
live_disk=""
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)

# Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
# OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
# list — a blind Enter would erase nothing useful and break the install).
# Bytes via lsblk -b; 8 GiB floor catches fd0/USB crumbs; REQUIREMENTS still
# recommend ≥40 GiB for a real install.
# NOMARCHY_MIN_DISK_BYTES overrides the floor (tests / expert installs).
MIN_DISK_BYTES="${NOMARCHY_MIN_DISK_BYTES:-$((8 * 1024 * 1024 * 1024))}"
list_installable_disks() {
    local skip="/dev/${live_disk:-NONE}"
    local name size type model hsize
    # NAME SIZE TYPE in bytes (-b); MODEL looked up separately (may contain spaces).
    {
        while read -r name size type; do
            [[ "$type" == "disk" ]] || continue
            [[ "$name" == "$skip" ]] && continue
            case "$name" in
                /dev/loop*|/dev/zram*|/dev/sr*|/dev/fd*|/dev/ram*|/dev/nbd*|/dev/md*)
                    continue ;;
            esac
            [[ "$size" =~ ^[0-9]+$ ]] || continue
            (( size >= MIN_DISK_BYTES )) || continue
            hsize=$(lsblk -dno SIZE "$name" 2>/dev/null || echo "?")
            model=$(lsblk -dno MODEL "$name" 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
            printf '%s %s %s\n' "$name" "$hsize" "${model:-}"
        done < <(lsblk -dpbno NAME,SIZE,TYPE 2>/dev/null)
    } | sort -k2 -h -r   # largest first — never default to a tiny leftover
}

mapfile -t disks < <(list_installable_disks)
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found (need a whole disk ≥ $(( MIN_DISK_BYTES / 1024 / 1024 / 1024 )) GiB; floppy/loop/optical excluded)."

if [[ "$UNATTENDED" == "1" ]]; then
    TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
    # Unattended still rejects non-installable targets (CI footgun).
    if ! printf '%s\n' "${disks[@]}" | grep -q "^${TARGET_DISK} "; then
        fail "$TARGET_DISK is not an installable disk (missing, live medium, or below size floor)."
    fi
else
    choice=$(printf '%s\n' "${disks[@]}" \
        | gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")
    TARGET_DISK="${choice%% *}"
fi
[[ -b "$TARGET_DISK" ]] || fail "$TARGET_DISK is not a block device."
info "Target: $TARGET_DISK"

# Single whole-disk install only (no dual-boot path) — if the chosen disk
# already carries a recognizable OS/filesystem signature, call it out
# explicitly before the pre-wipe below destroys it.
existing_sig="$(lsblk -no FSTYPE,LABEL "$TARGET_DISK" 2>/dev/null || true
                blkid "$TARGET_DISK"* 2>/dev/null || true)"
if grep -qiE 'ntfs|bitlocker|microsoft|crypto_luks' <<< "$existing_sig"; then
    warn "$TARGET_DISK has existing data (Windows/BitLocker/NTFS or LUKS) — it will be destroyed."
fi

# ─── Encryption ─────────────────────────────────────────────────────────
section "Disk encryption"

# LUKS is the default: full-disk encryption, and in exchange the machine
# logs you straight into the desktop (the passphrase already gates access).
LUKS_PASSPHRASE=""
if [[ "$UNATTENDED" == "1" ]]; then
    # Fail-closed: unattended without a passphrase used to install
    # cleartext (easy CI footgun). Require an explicit opt-out.
    if [[ "${NOMARCHY_NO_LUKS:-}" == "1" ]]; then
        LUKS_PASSPHRASE=""
    elif [[ -n "${NOMARCHY_LUKS_PASSPHRASE:-}" ]]; then
        LUKS_PASSPHRASE="$NOMARCHY_LUKS_PASSPHRASE"
    else
        fail "Unattended install needs NOMARCHY_LUKS_PASSPHRASE or NOMARCHY_NO_LUKS=1"
    fi
elif gum confirm --default=yes "Encrypt the disk with LUKS? (default — also enables passwordless desktop login)"; then
    while true; do
        p1=$(gum input --password --placeholder "LUKS passphrase (min 8 chars)")
        [[ ${#p1} -ge 8 ]] || { warn "Too short."; continue; }
        p2=$(gum input --password --placeholder "Repeat passphrase")
        [[ "$p1" == "$p2" ]] && { LUKS_PASSPHRASE="$p1"; break; }
        warn "Passphrases don't match."
    done
fi
WITH_LUKS=false; [[ -n "$LUKS_PASSPHRASE" ]] && WITH_LUKS=true
info "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 (desktop auto-login)" || echo none)"

# ─── Swap / hibernation ─────────────────────────────────────────────────
# A swapfile ≥ RAM on its own BTRFS subvolume makes hibernation possible;
# the resume offset is wired into the config below.
section "Swap & hibernation"

ram_gb=$(awk '/MemTotal/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)
if [[ "$UNATTENDED" == "1" ]]; then
    SWAP_GB="${NOMARCHY_SWAP_GB:-$ram_gb}"
else
    info "A swapfile sized ≥ RAM lets this machine hibernate (suspend to disk)."
    info "Default = ${ram_gb} GiB (this machine's RAM). Enter 0 for no swap (disables hibernation)."
    SWAP_GB=$(gum input --value "$ram_gb" \
        --header "Swap size in GiB (default ${ram_gb} = RAM, 0 = no swap)" \
        --placeholder "swap size in GiB (≥ RAM enables hibernation, 0 = none)")
fi
[[ "$SWAP_GB" =~ ^[0-9]+$ ]] || fail "Swap size must be a whole number of GiB."
info "Swap: $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (hibernation-ready)")"

# ─── User account ───────────────────────────────────────────────────────
section "Your account"

if [[ "$UNATTENDED" == "1" ]]; then
    USERNAME="${NOMARCHY_USERNAME:?}"
    PASSWORD="${NOMARCHY_PASSWORD:?}"
    HOSTNAME_="${NOMARCHY_HOSTNAME:-nomarchy}"
    TIMEZONE="${NOMARCHY_TIMEZONE:-UTC}"
    LOCALE="${NOMARCHY_LOCALE:-en_US.UTF-8}"
    KB_LAYOUT="${NOMARCHY_KB_LAYOUT:-us}"
    KB_VARIANT="${NOMARCHY_KB_VARIANT:-}"
else
    while true; do
        USERNAME=$(gum input --placeholder "username (lowercase, e.g. ada)")
        [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] && break
        warn "Invalid username (lowercase letters, digits, - and _)."
    done
    while true; do
        PASSWORD=$(gum input --password --placeholder "password for $USERNAME (min 8 chars)")
        [[ ${#PASSWORD} -ge 8 ]] || { warn "Too short (min 8 chars)."; continue; }
        p2=$(gum input --password --placeholder "repeat password")
        [[ "$PASSWORD" == "$p2" ]] && break
        warn "Passwords don't match."
    done
    while true; do
        HOSTNAME_=$(gum input --value "nomarchy" --placeholder "hostname")
        [[ "$HOSTNAME_" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$ ]] && break
        warn "Invalid hostname."
    done
    TIMEZONE=$(timedatectl list-timezones | gum filter --placeholder "timezone (type to search)" || echo UTC)
    # Curated UTF-8 locales: the live system only generates en_US, so
    # `locale -a` can't enumerate what the TARGET could use.
    LOCALE=$(printf '%s\n' \
        en_US en_GB de_DE fr_FR es_ES es_MX pt_PT pt_BR it_IT nl_NL \
        pl_PL ru_RU uk_UA cs_CZ sk_SK sv_SE nb_NO da_DK fi_FI tr_TR \
        el_GR hu_HU ro_RO bg_BG hr_HR sl_SI lt_LT lv_LV et_EE ja_JP \
        ko_KR zh_CN zh_TW ar_EG he_IL hi_IN th_TH vi_VN id_ID \
        | sed 's/$/.UTF-8/' \
        | gum filter --placeholder "language / locale (type to search)" \
        || echo en_US.UTF-8)
    keyboard_layouts=$(keyboard_layout_catalog) || \
        fail "Could not read the installed keyboard-layout catalog."
    [[ -n "$keyboard_layouts" ]] || \
        fail "The installed keyboard-layout catalog is empty."
    if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then
        KB_LAYOUT="us"
        KB_VARIANT=""
    else
        while true; do
            if keyboard_pick=$(keyboard_layout_choices \
                | gum filter --strict \
                    --placeholder "keyboard layout — type to search installed choices"); then
                KB_LAYOUT="${keyboard_pick%%$'\t'*}"
                if validate_keyboard_choice "$KB_LAYOUT" "" 2>/dev/null; then
                    break
                fi
            fi
            warn "Choose one of the listed keyboard layouts; typed text is search only."
        done
        while true; do
            if keyboard_pick=$( \
                keyboard_variant_choices "$KB_LAYOUT" \
                | gum filter --strict \
                    --placeholder "key behaviour for $KB_LAYOUT — '(none)' means the standard keys" \
            ); then
                KB_VARIANT="${keyboard_pick%%$'\t'*}"
                if [[ "$KB_VARIANT" == "(none)" ]] \
                   || validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>/dev/null; then
                    break
                fi
            fi
            warn "Choose a listed key behaviour, or '(none)' for the standard keys; typed text is search only."
        done
    fi
fi
# `(none)` is gum's display-only sentinel, never an XKB variant.  Keep the
# normalization at the common boundary so interactive and unattended installs
# cannot write it into either generated Nix file.
if [[ "$KB_VARIANT" == "(none)" ]]; then
    KB_VARIANT=""
fi
keyboard_error=""
if ! keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>&1); then
    fail "$keyboard_error"
fi
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
    || timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
    || warn "Timezone '$TIMEZONE' not verifiable; continuing."
HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s)
unset PASSWORD
info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)"
if [[ -n "$KB_VARIANT" ]]; then
    KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — $(keyboard_variant_name "$KB_LAYOUT" "$KB_VARIANT") [$KB_VARIANT]"
else
    KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — standard keys (no special variant)"
fi
info "Locale: $LOCALE"
info "Keyboard: $KEYBOARD_SUMMARY"

# ─── Hardware profile ───────────────────────────────────────────────────
section "Hardware detection"

# shellcheck source=hardware-db.sh
source "$SHARE/hardware-db.sh"

HW_PROFILES=()
HW_NOMARCHY=()   # NOMARCHY hardware.* assignments from detection
NPU_VENDOR=""    # "intel" | "amd" if an NPU was detected (commented opt-in)
hw_mode="${NOMARCHY_HW:-auto}"
if [[ "$hw_mode" == "none" ]]; then
    info "Hardware profiles skipped."
elif [[ "$hw_mode" != "auto" && "$UNATTENDED" == "1" ]]; then
    read -ra HW_PROFILES <<< "$hw_mode"
else
    detection=$(nomarchy_detect_hw || true)
    if [[ -n "$detection" ]]; then
        while IFS= read -r line; do
            case "$line" in
                MODULE\ *)        HW_PROFILES+=("${line#MODULE }") ;;
                NOMARCHY-NPU\ *)  NPU_VENDOR="${line#NOMARCHY-NPU }" ;;
                NOMARCHY\ *)      HW_NOMARCHY+=("${line#NOMARCHY }") ;;
                DETAIL\ *)        info "→ ${line#DETAIL }" ;;
            esac
        done <<< "$detection"
    fi
    if [[ "$UNATTENDED" != "1" ]]; then
        if [[ ${#HW_PROFILES[@]} -gt 0 ]] \
           && ! gum confirm "Use these nixos-hardware profiles: ${HW_PROFILES[*]}?"; then
            HW_PROFILES=()
            picked=$(gum filter --no-limit \
                       --placeholder "pick profiles manually (tab to select, enter to finish, esc for none)" \
                       < "$SHARE/hardware-modules.txt" || true)
            [[ -n "$picked" ]] && mapfile -t HW_PROFILES <<< "$picked"
        fi
    fi
fi
info "Profiles: ${HW_PROFILES[*]:-(none)}"

# ─── Review & point of no return ────────────────────────────────────────
section "Review"

# Match the Source line to the same cache.nixos.org probe that sets OFFLINE
# (above): offline = ISO store only; online may still hit substituters.
if [[ "$OFFLINE" == true ]]; then
    SOURCE_NET="pinned into the ISO, no network needed"
else
    SOURCE_NET="pinned into the ISO; may use network binary caches"
fi

gum style --border normal --padding "0 2" \
    "Disk:       $TARGET_DISK  (WILL BE ERASED)" \
    "Encryption: $([[ $WITH_LUKS == true ]] && echo "LUKS2 + desktop auto-login" || echo none)" \
    "Swap:       $([[ "$SWAP_GB" == "0" ]] && echo "none (hibernation disabled)" || echo "${SWAP_GB} GiB swapfile (enables hibernation)")" \
    "User:       $USERNAME" \
    "Hostname:   $HOSTNAME_" \
    "Timezone:   $TIMEZONE" \
    "Keyboard:   $KEYBOARD_SUMMARY" \
    "Hardware:   ${HW_PROFILES[*]:-none}" \
    "Snapshots:  snapper timeline on /" \
    "Source:     nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— $SOURCE_NET"

if [[ "$UNATTENDED" != "1" ]]; then
    typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
    [[ "$typed" == "$(basename "$TARGET_DISK")" ]] || fail "Confirmation mismatch — aborting, nothing touched."
fi

# ─── Partitioning ───────────────────────────────────────────────────────
section "Partitioning"

# Pre-wipe: disko gates destructive steps on blkid — on a previously
# installed disk it would overlay the old GPT and skip mkfs on a stale
# ESP ("wrong fs type, bad superblock" at mount). Wipe first. Teardown
# order matters: mounts → swap → LUKS mappings → signatures.
prewipe() {
    local drive="$1" name backing part
    info "Pre-wiping $drive..."
    umount -R /mnt 2>/dev/null || true
    swapoff -a 2>/dev/null || true
    if command -v dmsetup >/dev/null 2>&1; then
        while read -r name _; do
            [[ -n "$name" && "$name" != "No" ]] || continue
            backing=$(cryptsetup status "$name" 2>/dev/null \
                      | awk '/^[[:space:]]*device:/ { print $2; exit }') || continue
            [[ "$backing" == "$drive"* ]] || continue
            info "closing stale LUKS mapping $name"
            cryptsetup close "$name"
        done < <(dmsetup ls --target crypt 2>/dev/null)
    fi
    for part in "${drive}"?*; do
        # unmatched glob stays literal; -b filters it out
        [[ -b "$part" ]] || continue
        wipefs -af "$part" >/dev/null
    done
    wipefs -af "$drive" >/dev/null
    sgdisk --zap-all "$drive" >/dev/null
    # 16 MiB covers LUKS2 headers and the first BTRFS superblock —
    # wipefs alone misses damaged variants.
    dd if=/dev/zero of="$drive" bs=1M count=16 conv=fsync status=none
    partprobe "$drive" 2>/dev/null || true
    udevadm settle --timeout=30 || info "udevadm settle timed out; continuing."
    if lsblk -no MOUNTPOINTS "$drive" 2>/dev/null | grep -qE '\S'; then
        fail "$drive still has active mountpoints after pre-wipe."
    fi
}
prewipe "$TARGET_DISK"

if [[ $WITH_LUKS == true ]]; then
    install -m 600 /dev/null "$LUKS_KEY_PATH"
    trap 'rm -f "$LUKS_KEY_PATH" 2>/dev/null || true' EXIT
    printf '%s' "$LUKS_PASSPHRASE" > "$LUKS_KEY_PATH"
    unset LUKS_PASSPHRASE
fi

# disko-config treats exact "0" as no-swap; "${SWAP_GB}G" would pass "0G"
# and still create a useless @swap subvolume (layout vs resume disagreed).
if [[ "$SWAP_GB" == "0" ]]; then
    DISKO_SWAP_SIZE="0"
else
    DISKO_SWAP_SIZE="${SWAP_GB}G"
fi

disko_log=$(mktemp --suffix=.disko.log)
if ! disko --mode destroy,format,mount --yes-wipe-all-disks \
        --argstr mainDrive "$TARGET_DISK" \
        --arg withLuks "$WITH_LUKS" \
        --argstr swapSize "$DISKO_SWAP_SIZE" \
        "$SHARE/disko-config.nix" >"$disko_log" 2>&1; then
    tail -n 30 "$disko_log"
    fail "disko failed — full log: $disko_log"
fi
rm -f "$LUKS_KEY_PATH" "$disko_log"
success "Disk partitioned and mounted at /mnt"

# Hibernation plumbing: the swapfile's physical offset goes into the
# kernel cmdline (patched into system.nix). Deactivate swap first so
# nixos-generate-config doesn't also emit a swapDevices entry.
resume_offset=""
root_uuid=""
if [[ "$SWAP_GB" != "0" ]]; then
    swapoff -a 2>/dev/null || true
    resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
    root_uuid=$(findmnt -no UUID /mnt)
    success "Swapfile created (resume offset $resume_offset)"
fi

# ─── Configuration generation ───────────────────────────────────────────
section "Generating configuration"

FLAKE_DIR="/mnt/home/$USERNAME/.nomarchy"
mkdir -p "$FLAKE_DIR"

nixos-generate-config --root /mnt
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
rm -rf /mnt/etc/nixos

# templates/downstream is the single source of truth (same files as
# `nix flake init -t`). Copy, then patch install-time values only.
cp "$SHARE/template/flake.nix" \
   "$SHARE/template/system.nix" \
   "$SHARE/template/home.nix" \
   "$SHARE/template/state.json" \
   "$FLAKE_DIR/"

# Detected hardware → flags for the patcher (safe defaults active).
has_intel=false; has_amd=false; has_fp=false
intel_guc_off=false; has_camera_ir=false
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
    for nm in "${HW_NOMARCHY[@]}"; do
        case "$nm" in
            hardware.intel.enable=true)        has_intel=true ;;
            hardware.intel.guc=false)          intel_guc_off=true ;;
            hardware.amd.enable=true)          has_amd=true ;;
            hardware.fingerprint.enable=true)  has_fp=true ;;
            hardware.camera.hideIrSensor=true) has_camera_ir=true ;;
        esac
    done
fi
# NVIDIA is a nixos-hardware MODULE only (no nomarchy.hardware.nvidia.*) —
# still emit commented plain-NixOS guidance in system.nix (BACKLOG #59).
has_nvidia=false
[[ " ${HW_PROFILES[*]:-} " == *" common-gpu-nvidia "* ]] && has_nvidia=true
is_laptop=false
[[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]] && is_laptop=true
thermald=false
[[ $is_laptop == true ]] && grep -q GenuineIntel /proc/cpuinfo 2>/dev/null && thermald=true

# JSON for patch-template.py (stdin). Hardware profiles as a JSON array.
hw_json="["
first=1
for p in "${HW_PROFILES[@]:-}"; do
    [[ -z "$p" ]] && continue
    if [[ $first -eq 1 ]]; then first=0; else hw_json+=","; fi
    hw_json+=$(printf '%s' "$p" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
done
hw_json+="]"

resume_json="null"
root_uuid_json="null"
if [[ -n "$resume_offset" && "$SWAP_GB" != "0" ]]; then
    resume_json=$(printf '%s' "$resume_offset" | python3 -c 'import json,sys; print(json.dumps(int(sys.stdin.read().strip())))')
    root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
fi

python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
{
  "hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "username": $(printf '%s' "$USERNAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "timezone": $(printf '%s' "$TIMEZONE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "keyboardVariant": $(printf '%s' "$KB_VARIANT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "hashedPassword": $(printf '%s' "$HASHED_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
  "autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
  "laptop": $is_laptop,
  "thermald": $thermald,
  "hardwareProfiles": $hw_json,
  "hardware": {
    "intel": $has_intel,
    "intelGucOff": $intel_guc_off,
    "amd": $has_amd,
    "fingerprint": $has_fp,
    "cameraIr": $has_camera_ir,
    "nvidia": $has_nvidia,
    "npu": $(if [[ -n "$NPU_VENDOR" ]]; then printf '%s' "$NPU_VENDOR" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'; else echo null; fi)
  },
  "resumeOffset": $resume_json,
  "rootUuid": $root_uuid_json,
  "flakeUrl": $(printf '%s' "${NOMARCHY_FLAKE_URL:?NOMARCHY_FLAKE_URL unset}" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
}
PYJSON

# The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later
# `nix flake update` on the installed machine re-resolves normally).
# NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 — unattended harness only (#54 V2):
# pretends compose-lock failed so the offline fail-closed arm is exercised
# without poisoning the ISO store.
compose_ok=0
if [[ "${NOMARCHY_TEST_FORCE_COMPOSE_FAIL:-}" == 1 ]]; then
    compose_ok=1
elif python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
        "$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
    compose_ok=0
else
    compose_ok=1
fi
if (( compose_ok != 0 )); then
    if [[ "$OFFLINE" == true ]]; then
        fail "Offline lock composition failed and there is no network to fall back to — cannot finish an offline install."
    fi
    warn "Offline lock composition failed — resolving over the network."
    (cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi

# A flake worktree must be git-tracked (state.json especially).
(
    cd "$FLAKE_DIR"
    git init -q
    git add -A
    git -c user.name="Nomarchy Installer" -c user.email="installer@nomarchy" \
        commit -qm "Initial Nomarchy configuration"
)

# The templates come out of the nix store mode 0444 and cp preserves
# that — without this the user can't edit home.nix after they own it.
# Ownership is applied after nixos-install (real uid/gid; see below).
chmod -R u+w "$FLAKE_DIR"

# /etc/nixos on the installed system points at the user-owned flake.
mkdir -p /mnt/etc
ln -sfn "/home/$USERNAME/.nomarchy" /mnt/etc/nixos
success "Configuration written to ~$USERNAME/.nomarchy"

# ─── Install ────────────────────────────────────────────────────────────
section "Installing (this takes a while)"

# Seed the target store with the flake source + all inputs so the first
# `nomarchy-state-sync apply` (and the HM pre-activation below) work
# before the machine has ever seen a network. Two steps because
# `flake archive --to` enforces signatures and locally-evaluated source
# paths have none; plain `nix copy` accepts --no-check-sigs.
info "Seeding flake inputs into the target store..."
# path: (not git+file) — the flake dir is owned by the target user and
# root's libgit2 refuses repositories owned by someone else.
flake_paths=$(nix --extra-experimental-features "nix-command flakes" \
    flake archive --json "path:$FLAKE_DIR" \
    | python3 -c '
import json, sys
def walk(node):
    yield node["path"]
    for child in node.get("inputs", {}).values():
        yield from walk(child)
print("\n".join(walk(json.load(sys.stdin))))' || true)
if [[ -n "$flake_paths" ]]; then
    # shellcheck disable=SC2086
    nix --extra-experimental-features "nix-command flakes" \
        copy --no-check-sigs --to "local?root=/mnt" $flake_paths \
        || warn "input seeding failed — first rebuild will need network."
else
    warn "flake archive failed — first rebuild will need network."
fi

# Offline: sidestep substituter plumbing entirely — make every ISO store
# path valid in the target store up front. nixos-install's in-target
# build then finds all build tools locally and only the per-machine
# config derivations are built. (Two earlier attempts to route this
# through substituters — env config and the forwarded --substituters
# flag — still left the plan building gcc from source.)
if [[ ${#NIXOS_INSTALL_OPTS[@]} -gt 0 ]]; then
    info "Copying the ISO store into the target (offline install)..."
    nix --extra-experimental-features nix-command \
        copy --all --no-check-sigs --to "local?root=/mnt"
fi

nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
success "System installed (bootloader in place)"

# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. Resolve real uid/gid from the target after nixos-install
# created the account (do not hard-code 1000:100 — first free uid or
# primary group can differ).
USER_UID=$(nixos-enter --root /mnt -- id -u "$USERNAME") \
    || fail "Could not resolve uid for install user '$USERNAME' on target"
USER_GID=$(nixos-enter --root /mnt -- id -g "$USERNAME") \
    || fail "Could not resolve gid for install user '$USERNAME' on target"
chown -R "$USER_UID:$USER_GID" "$FLAKE_DIR"

# Pre-activate the Home Manager generation so the FIRST boot lands in the
# fully themed desktop, not bare Hyprland. Best-effort: a failure here
# only costs the user one `home-manager switch` after logging in.
section "Baking the desktop"

# Build the generation HERE in the live environment, not in the chroot:
# the live store always carries the offline pin set, while the target
# store only gets the full ISO store on OFFLINE installs — an online
# install skips that copy, and the old in-chroot build (substituters
# deliberately empty) then tried to compile the world from source.
# Building against the live store is the exact path the live session's
# own theme switching exercises, online or off.
info "Building the desktop generation..."
hm_out=""
if hm_out=$(nix --extra-experimental-features "nix-command flakes" \
        build --no-link --print-out-paths \
        --option substituters "" \
        "path:$FLAKE_DIR#homeConfigurations.$USERNAME.activationPackage"); then
    nix --extra-experimental-features "nix-command flakes" \
        copy --no-check-sigs --to "local?root=/mnt" "$hm_out" \
        || hm_out=""
fi
[[ -n "$hm_out" ]] || warn "live-side desktop build failed — retrying inside the chroot"
# NOT /mnt/tmp: nixos-enter mounts a fresh tmpfs over /tmp inside the
# chroot, which silently vaporizes any script staged there (cost us a
# full verification round to find). /root persists into the chroot.
cat > /mnt/root/nomarchy-hm-activate.sh <<EOF
set -ex
exec > /var/log/nomarchy-hm-preactivate.log 2>&1
export PATH=/run/current-system/sw/bin:\$PATH
# Keep root's nix state in /root, not a stray /home/nomarchy on the target.
export HOME=/root
# Normally pre-built in the live env and copied over; the in-chroot
# build (default substituters — the live-side build already proved the
# no-network case) is a last-resort fallback.
out="$hm_out"
if [ -z "\$out" ]; then
    out=\$(nix --extra-experimental-features "nix-command flakes" \
        build --no-link --print-out-paths \
        "path:/home/$USERNAME/.nomarchy#homeConfigurations.$USERNAME.activationPackage")
fi
install -d -o "$USERNAME" -g users /nix/var/nix/profiles/per-user/$USERNAME
install -d -o "$USERNAME" -g users /nix/var/nix/gcroots/per-user/$USERNAME
# activate's profile ops need store access; as the user that means a
# daemon, and the chroot has none — run one for the duration.
nix-daemon &
daemon_pid=\$!
trap 'kill \$daemon_pid 2>/dev/null || true' EXIT
sleep 2
# Pre-activate has no graphical session: without XDG_RUNTIME_DIR, HM's
# dconfSettings step dies ("Unable to create directory /run/user/UID/dconf:
# Permission denied") and the rest of activation never runs — first boot
# then lands half-themed (#123). Create a private runtime dir the target
# user owns, and wrap activate in a session bus (dconf needs one).
uid=$USER_UID
install -d -o "$USERNAME" -g users -m 700 "/run/user/\$uid"
# BACKUP_EXT: collisions can't abort the activation (a stray
# autogenerated config gets moved aside instead).
if command -v dbus-run-session >/dev/null 2>&1; then
  runuser -u "$USERNAME" -- env \
    USER="$USERNAME" HOME="/home/$USERNAME" \
    XDG_RUNTIME_DIR="/run/user/\$uid" \
    NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
    dbus-run-session -- "\$out/activate"
else
  runuser -u "$USERNAME" -- env \
    USER="$USERNAME" HOME="/home/$USERNAME" \
    XDG_RUNTIME_DIR="/run/user/\$uid" \
    NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \
    "\$out/activate"
fi
EOF
# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take
# the failure arm so the durable recovery hint is exercised without a
# real activation breakage.
hm_activate_ok=0
if [[ "${NOMARCHY_TEST_FORCE_HM_FAIL:-}" == 1 ]]; then
    hm_activate_ok=1
    warn "NOMARCHY_TEST_FORCE_HM_FAIL=1 — skipping real pre-activate (test harness)"
elif nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then
    hm_activate_ok=0
else
    hm_activate_ok=1
fi
if (( hm_activate_ok == 0 )); then
    success "Desktop pre-activated — first boot is fully themed"
else
    warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log"
    warn "on the installed system); after first login run:"
    warn "  home-manager switch --flake ~/.nomarchy -b bak"
    tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
    # The live session (and this warning) ends with this install — drop a
    # durable hint on the TARGET so the fix still surfaces on first login.
    # Numeric ids from the target account (USER_UID/USER_GID above) — the
    # name does not exist in the live ISO's passwd.
    hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"
    cat > "$hint_file" <<HINT
Desktop pre-activation failed during install — see
/var/log/nomarchy-hm-preactivate.log for details. Finish it with:

    home-manager switch --flake ~/.nomarchy -b bak

(delete this file once done)
HINT
    chown "$USER_UID:$USER_GID" "$hint_file"
fi
rm -f /mnt/root/nomarchy-hm-activate.sh

header "Nomarchy installed on $TARGET_DISK" \
       "User: $USERNAME @ $HOSTNAME_" \
       "Remove the USB stick when the machine is off."

finish="${NOMARCHY_FINISH:-ask}"
case "$finish" in
    reboot)   systemctl reboot ;;
    poweroff) systemctl poweroff ;;
    none)     : ;;
    *)        confirm "Reboot into Nomarchy now?" && systemctl reboot || true ;;
esac
