#!/usr/bin/env bash
# sysupdate — Arch Linux System Update TUI
# Usage: sysupdate [--AI] [--packages|-p] [--configs|-c] [--both|-b]
# Deps:  yay, pacman-contrib (checkupdates), curl, python3, dialog
#        flatpak (optional)  |  claude CLI (required for --AI)
# State: /updatestate  — ISO timestamp of last completed update

set -uo pipefail

# ═══════════════════════════════════════════════════════════════════════════════
# CONSTANTS & FLAGS
# ═══════════════════════════════════════════════════════════════════════════════

readonly STATE_FILE="/updatestate"
readonly SCRIPT_PREFIX="/updatescript"
readonly NEWS_FEED="https://archlinux.org/feeds/news/"
DOTFILES="${DOTFILES:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"

AI_MODE=false
UPDATE_MODE="both"   # packages | configs | both
for _arg in "$@"; do
    case "$_arg" in
        --AI)          AI_MODE=true ;;
        --packages|-p) UPDATE_MODE="packages" ;;
        --configs|-c)  UPDATE_MODE="configs" ;;
        --both|-b)     UPDATE_MODE="both" ;;
    esac
done

# ═══════════════════════════════════════════════════════════════════════════════
# TERMINAL / COLOR SETUP
# ═══════════════════════════════════════════════════════════════════════════════

if [[ -t 1 ]]; then
    R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[1;33m'
    B=$'\033[0;34m' C=$'\033[0;36m' M=$'\033[0;35m'
    BO=$'\033[1m' DI=$'\033[2m' RS=$'\033[0m'
else
    R='' G='' Y='' B='' C='' M='' BO='' DI='' RS=''
fi

_cols() { tput cols 2>/dev/null || echo 80; }

hline() {
    local ch="${1:-─}" col="${2:-$DI}"
    local n; n=$(_cols)
    printf '%s' "$col"
    printf '%*s' "$n" '' | tr ' ' "$ch"
    printf '%s\n' "$RS"
}

header() {
    tput clear 2>/dev/null || clear
    hline "═" "$C"
    local ai_tag=""; [[ "$AI_MODE" == true ]] && ai_tag="  ${M}[AI MODE]${C}"
    printf "  %s%s  Arch Linux System Updater%s%s\n" "$C$BO" "⟨ SYSUPDATE ⟩" "$ai_tag" "$RS"
    hline "═" "$C"
    echo
}

section() {
    echo
    local title="  ┌─ $* "
    local pad=$(( $(_cols) - ${#title} - 1 ))
    printf '%s%s%s' "$BO$Y" "$title"
    [[ $pad -gt 0 ]] && printf '─%.0s' $(seq 1 $pad)
    printf '┐%s\n' "$RS"
}

log()  { printf "  ${B}▸${RS}  %s\n" "$*"; }
ok()   { printf "  ${G}✓${RS}  %s\n" "$*"; }
warn() { printf "  ${Y}⚠${RS}  %s\n" "$*"; }
err()  { printf "  ${R}✗${RS}  %s\n" "$*" >&2; }
step() { printf "\n  ${C}${BO}[%d/%d]${RS}  %s\n" "$1" "$2" "$3"; }

ask() {
    printf "  ${Y}?${RS}  ${BO}%s${RS} ${DI}[Y/n]${RS} " "$*"
    local r; read -r r
    [[ "${r,,}" != "n" ]]
}

_spin_pid=""
spin_start() {
    local msg="$*"
    ( local f=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') i=0
      while true; do
          printf "\r  ${C}%s${RS}  %s   " "${f[$i]}" "$msg"
          (( i = (i+1) % 10 )); sleep 0.08
      done ) &
    _spin_pid=$!
    disown "$_spin_pid" 2>/dev/null
}

spin_stop() {
    [[ -n "${_spin_pid:-}" ]] && { kill "$_spin_pid" 2>/dev/null; wait "$_spin_pid" 2>/dev/null || true; _spin_pid=""; }
    printf '\r%*s\r' "$(_cols)" ''
}

trap 'spin_stop; tput cnorm 2>/dev/null || true' EXIT

# ═══════════════════════════════════════════════════════════════════════════════
# STATE FILE
# ═══════════════════════════════════════════════════════════════════════════════

read_state() {
    if [[ -f "$STATE_FILE" ]]; then
        cat "$STATE_FILE"
        return
    fi

    # No state file — derive baseline from the running kernel's install date.
    local uname_r; uname_r=$(uname -r)
    local -a kpkgs=()

    # 1. Discover kernel packages via /boot/vmlinuz ownership (covers all flavors)
    for vmlinuz in /boot/vmlinuz-linux /boot/vmlinuz-linux-*; do
        [[ -f "$vmlinuz" ]] || continue
        local qo; qo=$(pacman -Qo "$vmlinuz" 2>/dev/null | awk '{print $NF}')
        [[ -n "$qo" ]] && kpkgs+=("$qo")
    done

    # 2. Fallback: scan the pacman DB for installed linux kernel packages
    if [[ ${#kpkgs[@]} -eq 0 ]]; then
        mapfile -t kpkgs < <(
            pacman -Qq 2>/dev/null \
            | grep -E '^linux(-[a-z0-9]+(-[a-z0-9]+)*)?$' \
            | grep -vE '(-headers|-docs|-firmware|-api|-utils|-tools)$' \
            || true
        )
    fi

    # Deduplicate
    mapfile -t kpkgs < <(printf '%s\n' "${kpkgs[@]:-}" | sort -u)

    if [[ ${#kpkgs[@]} -eq 0 ]]; then
        # No kernel package found at all — hard fallback
        date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
        return
    fi

    # 3. Match the running kernel's flavor to a package.
    #    uname -r examples:
    #      6.14.5-arch1-1          → linux
    #      6.6.88-1-lts            → linux-lts
    #      6.14.4-zen1-1-zen       → linux-zen
    #      6.14.4-hardened1-1-...  → linux-hardened
    #      6.14.5-cachyos-1        → linux-cachyos / linux-cachyos-*
    local best_pkg="" best_epoch=0 found_running=false

    for pkg in "${kpkgs[@]}"; do
        # Extract flavor suffix: linux → "", linux-lts → "lts", linux-zen → "zen", etc.
        local suffix="${pkg#linux}"; suffix="${suffix#-}"

        local is_running=false
        if [[ -z "$suffix" ]]; then
            # Mainline: uname -r looks like N.N.N-arch<n>-<n>
            [[ "$uname_r" =~ ^[0-9]+\.[0-9]+\.[0-9]+-arch ]] && is_running=true
        else
            # Any other flavor: check that the suffix appears in uname -r
            [[ "${uname_r,,}" == *"${suffix,,}"* ]] && is_running=true
        fi

        local install_date; install_date=$(
            pacman -Qi "$pkg" 2>/dev/null \
            | awk -F': ' '/^Install Date/{sub(/^ +/,"",$2); print $2; exit}'
        )
        [[ -z "$install_date" ]] && continue

        local epoch; epoch=$(date -d "$install_date" +%s 2>/dev/null || echo 0)

        if $is_running; then
            best_pkg="$pkg"; best_epoch=$epoch; found_running=true; break
        fi
        # Keep track of most recently installed as fallback
        if [[ $epoch -gt $best_epoch ]]; then
            best_pkg="$pkg"; best_epoch=$epoch
        fi
    done

    if [[ -z "$best_pkg" ]]; then
        date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
        return
    fi

    local install_date; install_date=$(
        pacman -Qi "$best_pkg" 2>/dev/null \
        | awk -F': ' '/^Install Date/{sub(/^ +/,"",$2); print $2; exit}'
    )
    local iso; iso=$(date -d "$install_date" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "")

    if [[ -n "$iso" ]]; then
        local label; $found_running && label="running kernel" || label="most recently installed kernel"
        warn "No state file — using ${BO}${best_pkg}${RS} ($label) install date as baseline: $iso"
        echo "$iso"
    else
        date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
    fi
}

write_state() {
    local ts; ts=$(date "+%Y-%m-%dT%H:%M:%S")
    echo "$ts" | sudo tee "$STATE_FILE" > /dev/null
    sudo chmod 644 "$STATE_FILE" 2>/dev/null || true
    ok "State file updated  →  $STATE_FILE  ($ts)"
}

# ═══════════════════════════════════════════════════════════════════════════════
# PACKAGE COLLECTION
# ═══════════════════════════════════════════════════════════════════════════════

get_pacman_updates() { checkupdates 2>/dev/null | awk '{print $1}' || true; }
get_aur_updates()    { yay -Qua 2>/dev/null    | awk '{print $1}' || true; }
get_flatpak_updates(){ flatpak remote-ls --updates 2>/dev/null | awk 'NR>0{print $1}' || true; }

# ═══════════════════════════════════════════════════════════════════════════════
# ARCH NEWS — Python helper written to a temp file once at startup
# ═══════════════════════════════════════════════════════════════════════════════

_NEWS_PY=$(mktemp /tmp/sysupdate-news.XXXXXX.py)

cat > "$_NEWS_PY" << 'PYEOF'
import sys, xml.etree.ElementTree as ET, email.utils, re, html, datetime, time

since_str = sys.argv[1] if len(sys.argv) > 1 else "1970-01-01T00:00:00"
try:
    since_epoch = int(datetime.datetime.fromisoformat(since_str).timestamp())
except Exception:
    since_epoch = 0

data = sys.stdin.read()
if not data.strip():
    sys.exit(0)
try:
    root = ET.fromstring(data)
except ET.ParseError:
    sys.exit(0)

for item in root.findall('.//item'):
    title   = (item.findtext('title')       or '').strip()
    pubdate = (item.findtext('pubDate')     or '').strip()
    desc    = (item.findtext('description') or '').strip()
    link    = (item.findtext('link')        or '').strip()

    try:
        epoch = int(email.utils.mktime_tz(email.utils.parsedate_tz(pubdate)))
    except Exception:
        epoch = 0

    if epoch < since_epoch:
        continue

    desc_clean = html.unescape(re.sub(r'<[^>]+>', ' ', desc))
    desc_clean = re.sub(r'\s+', ' ', desc_clean).strip()[:1000]
    try:
        human = time.strftime('%Y-%m-%d', time.localtime(epoch))
    except Exception:
        human = pubdate

    print(f"TITLE:{title}")
    print(f"DATE:{human}")
    print(f"LINK:{link}")
    print(f"DESC:{desc_clean}")
    print("---")
PYEOF

fetch_news() {
    local since="$1"
    curl -s --connect-timeout 10 --max-time 20 "$NEWS_FEED" 2>/dev/null \
        | python3 "$_NEWS_PY" "$since" || true
}

# Fills global associative array PKG_NEWS[pkg]="formatted news text"
declare -A PKG_NEWS=()

parse_and_match_news() {
    local news_raw="$1"; shift
    local -a pkgs=("$@")
    [[ -z "$news_raw" || ${#pkgs[@]} -eq 0 ]] && return 0

    local title="" date="" link="" desc=""
    while IFS= read -r line; do
        if   [[ "$line" == TITLE:* ]]; then title="${line#TITLE:}"
        elif [[ "$line" == DATE:*  ]]; then date="${line#DATE:}"
        elif [[ "$line" == LINK:*  ]]; then link="${line#LINK:}"
        elif [[ "$line" == DESC:*  ]]; then desc="${line#DESC:}"
        elif [[ "$line" == "---"   ]]; then
            for pkg in "${pkgs[@]}"; do
                [[ -z "$pkg" ]] && continue
                local base="$pkg"
                base="${base#lib32-}"; base="${base#python-}"; base="${base#perl-}"
                base="${base%-git}";   base="${base%-bin}";   base="${base%-dev}"
                local hay="${title,,} ${desc,,}"
                if [[ "$hay" == *"${pkg,,}"* ]] || \
                   [[ ${#base} -gt 3 && "$hay" == *"${base,,}"* ]]; then
                    PKG_NEWS["$pkg"]+="$(printf '[%s] %s\n%s\n%s\n\n' \
                        "$date" "$title" "$desc" "$link")"
                fi
            done
            title="" date="" link="" desc=""
        fi
    done <<< "$news_raw"
}

# ═══════════════════════════════════════════════════════════════════════════════
# PACKAGE SELECTOR — dialog checklist with fallback
# ═══════════════════════════════════════════════════════════════════════════════

declare -a SELECTED_PACMAN=()
declare -a SELECTED_AUR=()

select_packages() {
    local -a p_pkgs=("${PACMAN_PKGS[@]:-}")
    local -a a_pkgs=("${AUR_PKGS[@]:-}")

    if ! command -v dialog &>/dev/null; then
        warn "'dialog' not installed — all packages will be updated"
        SELECTED_PACMAN=("${p_pkgs[@]:-}")
        SELECTED_AUR=("${a_pkgs[@]:-}")
        return
    fi

    local items=()
    for pkg in "${p_pkgs[@]:-}"; do
        [[ -n "$pkg" ]] && items+=("p:$pkg" "(pacman)" "on")
    done
    for pkg in "${a_pkgs[@]:-}"; do
        [[ -n "$pkg" ]] && items+=("a:$pkg" "(AUR)" "on")
    done
    [[ ${#items[@]} -eq 0 ]] && return

    local chosen
    chosen=$(dialog --stdout --separate-output \
        --title " Package Selection " \
        --checklist "  SPACE = toggle  |  ENTER = confirm  |  ESC = abort  " \
        0 60 0 "${items[@]}") || { warn "Cancelled."; exit 0; }

    SELECTED_PACMAN=(); SELECTED_AUR=()
    while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        case "$line" in
            p:*) SELECTED_PACMAN+=("${line#p:}") ;;
            a:*) SELECTED_AUR+=("${line#a:}")    ;;
        esac
    done <<< "$chosen"
}

# ═══════════════════════════════════════════════════════════════════════════════
# NEWS REVIEW — interactive per-package review (non-AI mode)
# ═══════════════════════════════════════════════════════════════════════════════

NEWS_DECISION=""   # ok | skip | shell | quit

show_pkg_news() {
    local pkg="$1" news_text="$2"
    local w; w=$(_cols)
    while true; do
        tput clear 2>/dev/null || clear
        hline "═" "$C"
        printf "  ${BO}NEWS FOR: ${Y}%s${RS}\n" "$pkg"
        hline "═" "$C"
        echo
        echo "$news_text" | fold -s -w $(( w - 4 )) | sed 's/^/  /'
        echo
        hline "─" "$DI"
        printf "  ${BO}[c]${RS} Continue   ${BO}[s]${RS} Skip package   ${BO}[b]${RS} Open shell   ${BO}[q]${RS} Quit all\n"
        hline "─" "$DI"
        printf "  → "
        local key; read -rn1 key; echo
        case "${key,,}" in
            c|"") NEWS_DECISION="ok";    return ;;
            s)    NEWS_DECISION="skip";  return ;;
            b)    NEWS_DECISION="shell"; return ;;
            q)    NEWS_DECISION="quit";  return ;;
        esac
    done
}

# ═══════════════════════════════════════════════════════════════════════════════
# ONE-BY-ONE UPDATERS
# ═══════════════════════════════════════════════════════════════════════════════

update_pacman_onebyone() {
    local -a pkgs=("$@")
    local total=${#pkgs[@]} n=0
    local -a failed=()
    for pkg in "${pkgs[@]}"; do
        (( n++ ))
        step "$n" "$total" "pacman: ${BO}$pkg${RS}"
        if sudo pacman -S --noconfirm --needed "$pkg"; then
            ok "$pkg"
        else
            err "$pkg FAILED"
            failed+=("$pkg")
        fi
    done
    [[ ${#failed[@]} -gt 0 ]] && warn "Pacman failures: ${failed[*]}"
    return ${#failed[@]}
}

update_aur_onebyone() {
    local -a pkgs=("$@")
    local total=${#pkgs[@]} n=0
    local -a failed=()
    for pkg in "${pkgs[@]}"; do
        (( n++ ))
        step "$n" "$total" "yay: ${BO}$pkg${RS}"
        if yay -S --noconfirm --answerdiff None --answerclean All --removemake "$pkg"; then
            ok "$pkg"
        else
            err "$pkg FAILED"
            failed+=("$pkg")
        fi
    done
    [[ ${#failed[@]} -gt 0 ]] && warn "AUR failures: ${failed[*]}"
    return ${#failed[@]}
}

update_flatpak() {
    log "Updating flatpaks..."
    if flatpak update -y; then
        ok "Flatpak update complete"
    else
        warn "Flatpak update failed"
    fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# AI MODE — generate timestamped update script via Claude
# ═══════════════════════════════════════════════════════════════════════════════

ai_generate_script() {
    local ts; ts=$(date "+%Y%m%d-%H%M%S")
    local outfile="${SCRIPT_PREFIX}-${ts}.sh"

    local pkg_list=""
    for p in "${SELECTED_PACMAN[@]:-}"; do [[ -n "$p" ]] && pkg_list+="  [pacman]  $p\n"; done
    for p in "${SELECTED_AUR[@]:-}";    do [[ -n "$p" ]] && pkg_list+="  [aur]     $p\n"; done
    for p in "${FLATPAK_PKGS[@]:-}";    do [[ -n "$p" ]] && pkg_list+="  [flatpak] $p\n"; done

    local news_section=""
    for pkg in "${!PKG_NEWS[@]}"; do
        news_section+="=== $pkg ===\n${PKG_NEWS[$pkg]}\n"
    done
    [[ -z "$news_section" ]] && news_section="(no relevant news items found for this update batch)"

    read -r -d '' prompt << PROMPT_EOF || true
You are an Arch Linux sysadmin generating a safe one-by-one system update script.

TODAY: $(date)
LAST UPDATE: $(read_state)
OUTPUT FILE: $outfile

PACKAGES TO UPDATE:
$(printf '%b' "$pkg_list")

ARCH LINUX NEWS SINCE LAST UPDATE:
$(printf '%b' "$news_section")

RULES:
- Output ONLY valid bash starting with #!/usr/bin/env bash — zero prose.
- Track failures in a failed=() array; report at the end.
- Update order: flatpak first, then pacman packages, then AUR packages.
- Pacman one-by-one:  sudo pacman -S --noconfirm --needed <pkg>
- AUR one-by-one:     yay -S --noconfirm --answerdiff None --answerclean All --removemake <pkg>
- Flatpak:            flatpak update -y
- If news mentions required manual steps for a package, insert them immediately before/after that package with a brief comment explaining WHY.
- On overall success, write the current ISO timestamp to ${STATE_FILE}:
      echo "\$(date +%Y-%m-%dT%H:%M:%S)" | sudo tee "${STATE_FILE}" >/dev/null
PROMPT_EOF

    section "AI Analysis"
    spin_start "Claude is analysing news and generating update script..."

    local result
    if result=$(claude -p "$prompt" 2>/dev/null); then
        spin_stop
        # Strip markdown fences if Claude added them
        result=$(printf '%s\n' "$result" | sed '/^```/d')
        [[ "$result" != '#!/'* ]] && result="#!/usr/bin/env bash"$'\n'"$result"
        echo "$result" | sudo tee "$outfile" > /dev/null
        sudo chmod 755 "$outfile"
        ok "Script saved  →  ${BO}$outfile${RS}"
        echo
        printf "  ${DI}─── Preview (first 35 lines) ───${RS}\n\n"
        head -35 "$outfile" | sed 's/^/  /'
        echo
        printf "  ${DI}───────────────────────────────${RS}\n"
        echo "$outfile"
    else
        spin_stop
        err "Claude CLI unavailable or returned an error."
        err "Make sure 'claude' is in PATH and authenticated."
        exit 1
    fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# CONFIG SYNC
# ═══════════════════════════════════════════════════════════════════════════════

# Items always skipped in the direct-copy fallback (managed by separate
# installers, or intentionally not auto-synced).  hypr/usr/ (device-specific)
# is handled separately inside the copy loop rather than via this list.
declare -a _CFG_SKIP=(
    config-updater  # meta: the updater script itself
    CRT eww eww-nobattery eww-touch greetd-tuigreet spicetify Vencord waybar
)

# Resolve the dotfiles DE source directory for the current machine.
# Priority: installed updater.conf SOURCE_BASE → running session detection.
_de_source_dir() {
    local conf="${XDG_CONFIG_HOME:-$HOME/.config}/config-updater/updater.conf"
    if [[ -f "$conf" ]]; then
        local src
        src=$(awk -F'[[:space:]]*=[[:space:]]*' '/^SOURCE_BASE/{print $2; exit}' "$conf")
        src="${src/#\~/$HOME}"
        [[ -d "${src:-}" ]] && printf '%s' "$src" && return
    fi
    if pgrep -x Hyprland &>/dev/null || [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
        printf '%s' "$DOTFILES/desktopenvs/hyprlua"
    elif pgrep -x niri &>/dev/null || [[ -n "${NIRI_SOCKET:-}" ]]; then
        printf '%s' "$DOTFILES/desktopenvs/niri"
    fi
}

# Detect and offer to migrate the old hyprlua flat layout:
#   old: ~/.config/hypr/monitors.lua etc.  (pre-usr/ era)
#   new: ~/.config/hypr/usr/monitors.lua
_migrate_hypr_usr() {
    local target="${XDG_CONFIG_HOME:-$HOME/.config}"
    local hypr_dir="$target/hypr"
    local usr_dir="$hypr_dir/usr"

    # Already in the right place — nothing to do
    [[ -d "$usr_dir" ]] && return 0

    # ── Case 1: old flat layout — lua files at ~/.config/hypr/ root ──────────
    if [[ -d "$hypr_dir" ]]; then
        local -a old_files=()
        for f in monitors.lua envvars.lua input.lua binds.lua windowrules.lua \
                  autostart.lua input-device-exceptions.lua; do
            [[ -f "$hypr_dir/$f" ]] && old_files+=("$f")
        done

        if [[ ${#old_files[@]} -gt 0 ]]; then
            echo
            warn "Old hyprlua layout: device-specific files at ${BO}~/.config/hypr/${RS} root."
            warn "New location: ${BO}~/.config/hypr/usr/${RS}"
            echo
            if ! ask "Migrate now?"; then
                warn "Skipped — hyprland.lua will fail to start until migrated."
                return 1
            fi
            mkdir -p "$usr_dir"
            for f in "${old_files[@]}"; do
                mv "$hypr_dir/$f" "$usr_dir/"
                ok "moved  hypr/$f  →  hypr/usr/$f"
            done
            if [[ -f "$target/wallpaper.conf" && ! -f "$usr_dir/wallpaper.conf" ]]; then
                mv "$target/wallpaper.conf" "$usr_dir/wallpaper.conf"
                ok "moved  wallpaper.conf  →  hypr/usr/wallpaper.conf"
            fi
            echo
            ok "Migration complete — device configs now at ${BO}~/.config/hypr/usr/${RS}"
            return 0
        fi
    fi

    # ── Case 2: no usr/ and no old files — copy template from dotfiles ───────
    local de_dir; de_dir=$(_de_source_dir)
    local src_usr="${de_dir:+$de_dir/hypr/usr}"
    if [[ -n "${src_usr:-}" && -d "$src_usr" ]]; then
        mkdir -p "$usr_dir"
        cp -r "$src_usr/." "$usr_dir/"
        ok "Copied  ${BO}hypr/usr/${RS} from dotfiles  ${DI}(no local config found)${RS}"
    fi
}

# Patch known stale paths inside hypr/usr/ that aren't auto-synced.
_fix_hypr_usr() {
    local usr_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr/usr"
    [[ -d "$usr_dir" ]] || return 0
    local input_lua="$usr_dir/input.lua"
    if [[ -f "$input_lua" ]] && grep -q 'require("input-device-exceptions")' "$input_lua"; then
        sed -i 's/require("input-device-exceptions")/require("usr.input-device-exceptions")/' "$input_lua"
        ok "Fixed require path in hypr/usr/input.lua  ${DI}(input-device-exceptions → usr.input-device-exceptions)${RS}"
    fi
}

sync_configs() {
    section "Config Sync"

    # ── Migration: old flat layout → hypr/usr/ ───────────────────────────────
    _migrate_hypr_usr
    _fix_hypr_usr

    # ── Preferred: use the installed update-configs.sh ───────────────────────
    local cfg_script
    for cfg_script in "$HOME/update-configs.sh" \
                      "${XDG_CONFIG_HOME:-$HOME/.config}/config-updater/update-configs.sh"; do
        [[ -x "$cfg_script" ]] && break || cfg_script=""
    done

    if [[ -n "${cfg_script:-}" ]]; then
        log "Using installed config-updater  (${DI}${cfg_script}${RS})"
        echo
        if "$cfg_script"; then
            echo
            ok "Config sync complete"
        else
            echo
            warn "Config sync completed with errors"
        fi
        return
    fi

    # ── Fallback: direct copy from dotfiles ──────────────────────────────────
    local de_dir; de_dir=$(_de_source_dir)
    if [[ -z "${de_dir:-}" || ! -d "$de_dir" ]]; then
        warn "Cannot determine DE source directory — config sync skipped"
        warn "Hint: deploy config-updater or ensure a supported DE session is running"
        return
    fi

    local target="${XDG_CONFIG_HOME:-$HOME/.config}"
    log "Source  →  ${BO}${de_dir}${RS}"
    log "Target  →  ${BO}${target}${RS}"
    warn "${BO}hypr/usr/${RS} preserved (device-specific — customise manually)"
    echo

    local synced=0 errs=0
    while IFS= read -r -d '' item; do
        local name; name="$(basename "$item")"
        local skip=false
        for s in "${_CFG_SKIP[@]}"; do [[ "$name" == "$s" ]] && skip=true && break; done
        [[ "$skip" == true ]] && continue

        if [[ "$name" == "hypr" ]]; then
            # Copy hypr/ contents but preserve ~/.config/hypr/usr/.
            # Stage each item into a temp dir on the same filesystem, then mv it
            # into place — for files this is an atomic rename(2), so Hyprland's
            # live config watcher never sees hyprland.lua momentarily missing
            # (which otherwise makes it emit its fallback hyprland.conf stub).
            mkdir -p "$target/hypr"
            local hypr_ok=true
            local stage; stage="$(mktemp -d "${target}/hypr/.sync.XXXXXX")" || hypr_ok=false
            if $hypr_ok; then
                while IFS= read -r -d '' hitem; do
                    local hname; hname="$(basename "$hitem")"
                    [[ "$hname" == "usr" ]] && continue
                    if cp -r "$hitem" "$stage/"; then
                        # mv can't atomically replace a non-empty dir, so clear
                        # an existing directory target first; files swap atomically.
                        [[ -d "$stage/$hname" && -d "${target}/hypr/${hname}" ]] \
                            && rm -rf "${target}/hypr/${hname}"
                        mv -f "$stage/$hname" "${target}/hypr/${hname}" || hypr_ok=false
                    else
                        hypr_ok=false
                    fi
                done < <(find "$item" -maxdepth 1 -mindepth 1 -print0 | sort -z)
                rm -rf "$stage"
            fi
            if $hypr_ok; then
                ok "synced  hypr  ${DI}(usr/ preserved)${RS}"
                (( synced++ )) || true
            else
                err "failed  hypr"
                (( errs++ )) || true
            fi
        elif rm -rf "${target:?}/${name}" && cp -r "$item" "$target/"; then
            ok "synced  ${name}"
            (( synced++ )) || true
        else
            err "failed  ${name}"
            (( errs++ )) || true
        fi
    done < <(find "$de_dir" -maxdepth 1 -mindepth 1 -not -name '.*' -print0 | sort -z)

    echo
    if (( errs > 0 )); then
        warn "Config sync: ${synced} synced, ${errs} error(s)"
    else
        ok "Config sync: ${BO}${synced}${RS} config(s) synced"
    fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════

declare -a PACMAN_PKGS=()
declare -a AUR_PKGS=()
declare -a FLATPAK_PKGS=()

main() {
    header

    # ── Configs-only mode ────────────────────────────────────────────────────
    if [[ "$UPDATE_MODE" == "configs" ]]; then
        if ask "Sync dotfiles configs to ~/.config?"; then
            sync_configs
        fi
        rm -f "$_NEWS_PY"
        exit 0
    fi

    # ── Read state (packages / both modes) ───────────────────────────────────
    local last_update; last_update=$(read_state)
    log "Last recorded update:  ${BO}${last_update}${RS}"
    echo

    # ── Config sync (both mode) ──────────────────────────────────────────────
    if [[ "$UPDATE_MODE" == "both" ]]; then
        if ask "Sync dotfiles configs to ~/.config?"; then
            sync_configs
        fi
    fi

    # ── Collect available updates ────────────────────────────────────────────
    section "Collecting Updates"

    spin_start "Checking pacman packages  (checkupdates)..."
    mapfile -t PACMAN_PKGS < <(get_pacman_updates)
    spin_stop; log "Pacman:   ${BO}${#PACMAN_PKGS[@]}${RS} package(s)"

    spin_start "Checking AUR packages  (yay -Qua)..."
    mapfile -t AUR_PKGS < <(get_aur_updates)
    spin_stop; log "AUR:      ${BO}${#AUR_PKGS[@]}${RS} package(s)"

    if command -v flatpak &>/dev/null; then
        spin_start "Checking flatpak packages..."
        mapfile -t FLATPAK_PKGS < <(get_flatpak_updates)
        spin_stop; log "Flatpak:  ${BO}${#FLATPAK_PKGS[@]}${RS} package(s)"
    else
        log "Flatpak:  (not installed)"
    fi

    local total=$(( ${#PACMAN_PKGS[@]} + ${#AUR_PKGS[@]} + ${#FLATPAK_PKGS[@]} ))

    if [[ $total -eq 0 ]]; then
        echo
        ok "${BO}System is up to date.${RS}"
        write_state
        rm -f "$_NEWS_PY"
        exit 0
    fi

    echo; ok "${BO}$total${RS} package(s) available"

    # ── Package selection via dialog checklist ───────────────────────────────
    section "Package Selection"
    select_packages

    local total_sel=$(( ${#SELECTED_PACMAN[@]} + ${#SELECTED_AUR[@]} + ${#FLATPAK_PKGS[@]} ))
    if [[ $total_sel -eq 0 ]]; then
        warn "No packages selected. Exiting."; exit 0
    fi
    log "Will update: ${#SELECTED_PACMAN[@]} pacman  |  ${#SELECTED_AUR[@]} AUR  |  ${#FLATPAK_PKGS[@]} flatpak"

    # ── Fetch and match Arch Linux news ─────────────────────────────────────
    section "Arch Linux News"

    local all_sel=("${SELECTED_PACMAN[@]:-}" "${SELECTED_AUR[@]:-}")
    local news_raw=""

    spin_start "Fetching $NEWS_FEED ..."
    news_raw=$(fetch_news "$last_update")
    spin_stop

    if [[ -z "$news_raw" ]]; then
        warn "No news fetched (no items in range, or network unavailable)"
    else
        parse_and_match_news "$news_raw" "${all_sel[@]:-}"
        if [[ ${#PKG_NEWS[@]} -gt 0 ]]; then
            warn "${BO}${#PKG_NEWS[@]}${RS} package(s) have relevant news:  ${!PKG_NEWS[*]}"
        else
            ok "No news matched for the selected packages"
        fi
    fi

    # ── AI mode: generate script ─────────────────────────────────────────────
    if [[ "$AI_MODE" == true ]]; then
        local script_path
        script_path=$(ai_generate_script)
        echo
        printf "  ${G}${BO}Script:${RS} ${BO}%s${RS}\n" "$script_path"
        printf "  Run it with:  ${C}sudo bash %s${RS}\n\n" "$script_path"
        if ask "Run the generated script now?"; then
            echo
            sudo bash "$script_path"
        fi
        rm -f "$_NEWS_PY"
        exit 0
    fi

    # ── Non-AI mode: per-package news review ────────────────────────────────
    if [[ ${#PKG_NEWS[@]} -gt 0 ]]; then
        section "News Review"
        log "Review news for each affected package before proceeding."
        echo

        local -a skip_pkgs=()
        for pkg in "${!PKG_NEWS[@]}"; do
            show_pkg_news "$pkg" "${PKG_NEWS[$pkg]}"
            case "$NEWS_DECISION" in
                skip)
                    skip_pkgs+=("$pkg")
                    ok "Will skip: $pkg"
                    ;;
                shell)
                    printf "\n  ${Y}Opening adjustment shell — type 'exit' to return.${RS}\n\n"
                    PS1="${Y}[sysupdate-shell \w]\$${RS} " bash --norc --noprofile
                    # Ask again after returning from shell
                    show_pkg_news "$pkg" "${PKG_NEWS[$pkg]}"
                    [[ "$NEWS_DECISION" == "skip" ]] && skip_pkgs+=("$pkg") && ok "Will skip: $pkg"
                    [[ "$NEWS_DECISION" == "quit" ]] && { warn "Aborted."; exit 0; }
                    ;;
                quit)
                    warn "Aborted by user."; exit 0 ;;
            esac
        done

        # Remove skipped packages from selection
        if [[ ${#skip_pkgs[@]} -gt 0 ]]; then
            local -a fp=() fa=()
            for pkg in "${SELECTED_PACMAN[@]:-}"; do
                printf '%s\n' "${skip_pkgs[@]}" | grep -qxF "$pkg" || fp+=("$pkg")
            done
            for pkg in "${SELECTED_AUR[@]:-}"; do
                printf '%s\n' "${skip_pkgs[@]}" | grep -qxF "$pkg" || fa+=("$pkg")
            done
            SELECTED_PACMAN=("${fp[@]:-}"); SELECTED_AUR=("${fa[@]:-}")
        fi
    fi

    # ── Final confirmation ───────────────────────────────────────────────────
    section "Ready to Update"
    echo

    if [[ ${#SELECTED_PACMAN[@]} -gt 0 ]]; then
        printf "  ${BO}pacman (%d):${RS}\n" "${#SELECTED_PACMAN[@]}"
        printf '    %s• %s%s\n' "$M" "$RS" "${SELECTED_PACMAN[@]}"
        echo
    fi
    if [[ ${#SELECTED_AUR[@]} -gt 0 ]]; then
        printf "  ${BO}AUR (%d):${RS}\n" "${#SELECTED_AUR[@]}"
        printf '    %s• %s%s\n' "$M" "$RS" "${SELECTED_AUR[@]}"
        echo
    fi
    if [[ ${#FLATPAK_PKGS[@]} -gt 0 ]]; then
        printf "  ${BO}flatpak (%d):${RS}\n" "${#FLATPAK_PKGS[@]}"
        printf '    %s• %s%s\n' "$M" "$RS" "${FLATPAK_PKGS[@]}"
        echo
    fi

    ask "Proceed with update?" || { warn "Aborted."; exit 0; }

    # ── Execute updates ──────────────────────────────────────────────────────
    section "Updating System"

    local exit_code=0

    if [[ ${#FLATPAK_PKGS[@]} -gt 0 ]]; then
        echo
        printf "  ${BO}── Flatpak ──${RS}\n"
        update_flatpak || (( exit_code++ )) || true
    fi

    if [[ ${#SELECTED_PACMAN[@]} -gt 0 ]]; then
        echo
        printf "  ${BO}── Pacman ──${RS}\n"
        update_pacman_onebyone "${SELECTED_PACMAN[@]}" || (( exit_code++ )) || true
    fi

    if [[ ${#SELECTED_AUR[@]} -gt 0 ]]; then
        echo
        printf "  ${BO}── AUR ──${RS}\n"
        update_aur_onebyone "${SELECTED_AUR[@]}" || (( exit_code++ )) || true
    fi

    # ── Write state and finish ───────────────────────────────────────────────
    echo
    if [[ $exit_code -eq 0 ]]; then
        write_state
        hline "═" "$G"
        printf "  ${G}${BO}✓  Update complete!${RS}\n"
        hline "═" "$G"
    else
        warn "Some packages failed — state file NOT updated."
        hline "═" "$Y"
        printf "  ${Y}${BO}⚠  Update finished with errors.${RS}\n"
        hline "═" "$Y"
    fi
    echo

    rm -f "$_NEWS_PY"
}

main "$@"
