#!/bin/bash

# mkhint - Manage hint files for slackrepo scripts
#
# Usage:
#   ./mkhint --set-version VERSION --hintfile FILE    Update existing hint file
#   ./mkhint --set-version VERSION --new FILE         Create new hint file
#   ./mkhint --new FILE                           Create new hint file (no version)
#   ./mkhint --hintfile FILE                      Update hint, suggest latest version via nvchecker
#   ./mkhint --check [FILE...]                     Check all (or named) hints for upstream updates
#   ./mkhint --list                               List hint files
#   ./mkhint --clean                              Remove .bak files from HINT_DIR
#   ./mkhint --no-dl --hintfile FILE              Update hint, skip downloads, add NODOWNLOAD=yes
#   ./mkhint --no-dl --new FILE                   Create hint with NODOWNLOAD=yes
#   ./mkhint --help                               Show this help

set -e

# Default configuration
REPO_DIR="/var/lib/sbopkg/SBo-danix/"
HINT_DIR="/etc/slackrepo/SBo-danix/hintfiles/"
TMP_DIR="/tmp/mkhint"
NVCHECKER_CONFIG="$HOME/.config/nvchecker/nvchecker.toml"
# Deps that exist on Slackware stable but are unneeded on -current (system
# package or newer already present). Listed one per line, '#' comments allowed.
# Missing file = empty list = phantom-dep handling is a no-op.
PHANTOM_DEPS_FILE="$HOME/.config/mkhint/phantom-deps"

# Packages whose extra DOWNLOAD lines are driven by an upstream deps manifest
# (e.g. neovim's cmake.deps/deps.txt). One entry per line:
#   <pkgname> <deps-url-template-with-{VERSION}>
# Missing file = empty list = feature is a no-op.
BUNDLE_MANIFEST_FILE="$HOME/.config/mkhint/bundle-manifests"

# Built-package repository (slackrepo output tree: <cat>/<pkg>/<pkg>-*.txz).
PACKAGES_DIR="/repo/"

# ponytail: sourced config, defaults above win when file absent. A syntax-broken
# config aborts under `set -e` — acceptable for a single-user tool.
MKHINT_CONFIG="$HOME/.config/mkhint/config"
[[ -f "$MKHINT_CONFIG" ]] && source "$MKHINT_CONFIG"

# create the temp dir if not existing
if [[ ! -d $TMP_DIR ]]; then
    mkdir $TMP_DIR
fi

readonly MKHINT_VERSION="1.2.0"

# Variables
VERSION=""
HINT_FILE=""
NEW_HINT_FILE=""
DELETE_HINT_FILES=()
MATCHED_PKGS=()
REVIEW_PKGS=()
LIST_PKGS=()
SHOW_LIST=""
RUN_REVIEW=""
COMMAND=""
NO_DL=0
FORCE=0

# Show help message
show_help() {
    cat <<EOF
mkhint $MKHINT_VERSION - Manage hint files for slackrepo scripts

Usage: mkhint [OPTION] [FILE...]

Options:
  --version, -v            Print version and exit
  --set-version, -V VER    New version string (required for --hintfile)
  --hintfile, -f FILE      Path to existing hint file (required with --set-version)
  --new, -n FILE           Create new hint file (required with --set-version or standalone)
  --list, -l [FILE...]     List all hint files; FILE... = side-by-side hint vs .info
  --review, -R [FILE...]   Review hints; no args = matched only, FILE... = named hints (any version)
  --clean, -c              Remove all .bak files from HINT_DIR
  --check, -C [FILE...]    Check hints for upstream updates via nvchecker, update interactively
  --fix-current, -F        Add/merge DELREQUIRES for -current phantom deps across the whole repo
  --delete, -d FILE        Delete a hint file (and .bak if present)
  --no-dl, -N              Skip downloads; add NODOWNLOAD=yes to hint file (use with -f or -n)
  --force                  With --check: reconcile bundled deps even if the primary version is unchanged
  --help, -h               Show this help message

Config file: ${MKHINT_CONFIG}$([[ -f "$MKHINT_CONFIG" ]] && echo " (present, sourced)" || echo " (not present, using defaults)")
Hint files are stored in: $HINT_DIR
Temporary files are stored in: $TMP_DIR
Phantom-dep list (for --fix-current / --new): $PHANTOM_DEPS_FILE
Bundle manifests (for --check bundled-dep reconcile): $BUNDLE_MANIFEST_FILE

See 'man mkhint' for usage examples, configuration, and exit codes.
EOF
}

# Pad a possibly-multibyte glyph (0 or 1 display column) to a fixed width.
_pad_glyph() {
    local g="$1" w="$2"
    if [[ -n "$g" ]]; then printf "%s%*s" "$g" $((w - 1)) ""; else printf "%*s" "$w" ""; fi
}

# List hint files
list_hint_files() {
    if [[ ! -d "$HINT_DIR" ]]; then
        echo "Error: Hint directory does not exist: $HINT_DIR" >&2
        exit 2
    fi

    # Color only on a TTY, or when forced for tests. tput optional.
    local c_on="" c_off="" g_on=""
    if [[ -n "$MKHINT_FORCE_COLOR" || -t 1 ]]; then
        if command -v tput &>/dev/null && tput setaf 3 &>/dev/null; then
            c_on=$(tput setaf 3); g_on=$(tput setaf 2); c_off=$(tput sgr0)
        else
            c_on=$'\033[33m'; g_on=$'\033[32m'; c_off=$'\033[0m'
        fi
    fi

    echo "Hint files in: $HINT_DIR"
    echo "======================================================="
    printf "%-40s  %22s  %22s  %-20s  %-6s  %s\n" "File" "HintVer" "SBOVer" "Category" "DelReq" "Created"
    echo "-------------------------------------------------------"

    MATCHED_PKGS=()
    local count=0 matched=0
    for file in "$HINT_DIR"/*.hint; do
        if [[ -f "$file" ]]; then
            local VER; VER=$(grep "^VERSION" "$file" |cut -d '"' -f2)
            # Skip hints with no VERSION set.
            [[ -z "$VER" ]] && continue
            local delreq=""
            grep -q '^DELREQUIRES="..*"' "$file" && delreq="✓"
            local name; name=$(basename "$file")
            local pkg="${name%.hint}"
            local info_file
            info_file=$(find "$REPO_DIR" -mindepth 2 -name "${pkg}.info" 2>/dev/null | head -1)
            local SBO_VER=""
            local category=""
            if [[ -f "$info_file" ]]; then
                SBO_VER=$(grep "^VERSION" "$info_file" | cut -d '"' -f2)
                category=$(basename "$(dirname "$(dirname "$info_file")")")
            fi
            local date; date=$(stat -c "%y" "$file" | cut -d'.' -f1)
            local dr; dr=$(_pad_glyph "$delreq" 6)
            local hv sv
            hv=$(printf "%22s" "$VER")
            sv=$(printf "%22s" "$SBO_VER")
            if [[ -n "$SBO_VER" && "$VER" == "$SBO_VER" ]]; then
                # equal → whole row yellow
                local row
                row=$(printf "%-40s  %s  %s  %-20s  %s  %s" "$name" "$hv" "$sv" "$category" "$dr" "$date")
                printf "%s%s%s\n" "$c_on" "$row" "$c_off"
                MATCHED_PKGS+=("$pkg")
                matched=$((matched + 1))
            else
                # differ (or no SBO_VER) → green the newer cell, row plain
                if [[ -n "$SBO_VER" ]]; then
                    local newer
                    newer=$(printf '%s\n%s\n' "$VER" "$SBO_VER" | sort -V | tail -1)
                    if [[ "$newer" == "$VER" ]]; then
                        hv="${g_on}${hv}${c_off}"
                    else
                        sv="${g_on}${sv}${c_off}"
                    fi
                fi
                printf "%-40s  %s  %s  %-20s  %s  %s\n" "$name" "$hv" "$sv" "$category" "$dr" "$date"
            fi
            count=$((count + 1))
        fi
    done

    if [[ $count -eq 0 ]]; then
        echo "  (no hint files found)"
    fi

    echo "======================================================="
    echo "Total: $count file(s)"
    if [[ $matched -gt 0 && -n "$c_on" ]]; then
        echo "(yellow row = versions match; green = newer side)"
    fi
}

# Show hint side-by-side with its .info. Output to the given fd (default 1).
_show_hint_diff() {
    local pkg="$1"
    local fd="${2:-1}"
    local hint="${HINT_DIR%/}/${pkg}.hint"
    local info
    info=$(find "$REPO_DIR" -mindepth 2 -name "${pkg}.info" 2>/dev/null | head -1)

    echo "" >&"$fd"
    echo "=== $pkg ===" >&"$fd"
    if command -v git &>/dev/null; then
        git diff --no-index --color=auto "$hint" "$info" >&"$fd" || true
    else
        diff -y --width="${COLUMNS:-160}" "$hint" "$info" >&"$fd" || true
    fi
}

# Diff one hint against its .info and prompt Keep/Delete/Skip.
# Human-facing output goes to stderr; echoes only "deleted" or "kept" on stdout
# so the caller can capture the result.
_review_one_hint() {
    local pkg="$1"
    local hint="${HINT_DIR%/}/${pkg}.hint"

    _show_hint_diff "$pkg" 2

    local ans
    read -r -p "Review $pkg: [K]eep / [D]elete / [S]kip (default Keep): " ans
    case "$ans" in
        [Dd])
            _remove_hint "$hint" >&2
            echo deleted
            ;;
        [Ss]|[Kk]|"")
            echo "Kept: $pkg" >&2
            echo kept
            ;;
        *)
            echo "Unrecognised answer; keeping $pkg" >&2
            echo kept
            ;;
    esac
}

# Review hints. With package args: review each named hint (any version),
# validating existence first (exit 2 on a missing hint). With no args: review
# MATCHED_PKGS (populated by list_hint_files).
review_hint_files() {
    local -a pkgs
    if [[ $# -gt 0 ]]; then
        pkgs=("$@")
        local p
        for p in "${pkgs[@]}"; do
            if [[ ! -f "${HINT_DIR%/}/${p}.hint" ]]; then
                echo "Error: hint file not found: ${HINT_DIR%/}/${p}.hint" >&2
                exit 2
            fi
        done
    else
        if [[ ${#MATCHED_PKGS[@]} -eq 0 ]]; then
            echo "No hints match their SBo version; nothing to review."
            return 0
        fi
        pkgs=("${MATCHED_PKGS[@]}")
    fi

    local deleted=0 kept=0 pkg result
    for pkg in "${pkgs[@]}"; do
        result=$(_review_one_hint "$pkg")
        case "$result" in
            deleted) deleted=$((deleted + 1)) ;;
            *)       kept=$((kept + 1)) ;;
        esac
    done

    echo ""
    echo "Reviewed ${#pkgs[@]} hint(s): deleted $deleted, kept $kept."
}

# Validate wget availability
check_wget() {
    if ! command -v wget &> /dev/null; then
        echo "Error: wget is not installed. Please install wget first." >&2
        exit 4
    fi
}

# Validate nvchecker toolchain availability
check_nvchecker() {
    local missing=()
    command -v nvchecker &> /dev/null || missing+=("nvchecker")
    command -v nvtake   &> /dev/null || missing+=("nvtake")
    command -v jq       &> /dev/null || missing+=("jq")
    if [[ ${#missing[@]} -gt 0 ]]; then
        echo "Error: required tool(s) not installed: ${missing[*]}" >&2
        echo "Install nvchecker (provides nvchecker + nvtake) and jq." >&2
        exit 4
    fi
    if [[ ! -f "$NVCHECKER_CONFIG" ]]; then
        echo "Error: nvchecker config not found: $NVCHECKER_CONFIG" >&2
        exit 2
    fi
}

# Echo the newver-keyfile path declared in [__config__] of NVCHECKER_CONFIG
_nvchecker_newver_path() {
    # Grab the `newver = "..."` value; tolerate spaces around =
    local line
    line=$(grep -E '^[[:space:]]*newver[[:space:]]*=' "$NVCHECKER_CONFIG" | head -1)
    [[ -z "$line" ]] && return 1
    # extract the quoted path
    local path
    path=$(printf '%s\n' "$line" | sed -E 's/^[^"]*"([^"]*)".*/\1/')
    [[ -z "$path" ]] && return 1
    # expand a leading ~ to $HOME
    path="${path/#\~/$HOME}"
    # nvchecker resolves a relative keyfile path against the config file's
    # directory (not the CWD), so do the same here.
    if [[ "$path" != /* ]]; then
        path="$(dirname "$NVCHECKER_CONFIG")/$path"
    fi
    printf '%s\n' "$path"
}

# Echo the latest version nvchecker found for a package, or return non-zero
# Normalize an upstream version to our packaging convention: '-' is illegal in
# a SlackBuild version (breaks PRGNAM parsing), so we store it as '_'. Compare
# the normalized forms so e.g. upstream 2026-06-02 == packaged 2026_06_02.
_normalize_version() {
    printf '%s\n' "${1//-/_}"
}

# Usage: latest=$(nvchecker_latest pkg) || handle "no version"
nvchecker_latest() {
    local pkg="$1"
    local keyfile
    keyfile=$(_nvchecker_newver_path) || return 1
    [[ -f "$keyfile" ]] || return 1
    local ver
    ver=$(jq -r --arg p "$pkg" '.data[$p].version // empty' "$keyfile" 2>/dev/null)
    [[ -z "$ver" ]] && return 1
    printf '%s\n' "$ver"
}

# download files
download_file() {
    local url="$1"
    local dlfile="${TMP_DIR}/download"

    if [[ -f $dlfile ]]; then
        rm "$dlfile"
    fi

    # Download the file
    if [[ ! -z $1 ]]; then
        wget -O "$dlfile" "$url" || return 1
    fi

    # calculate md5
    local md5; md5=$(md5sum "$dlfile" | awk '{print $1}')
    rm "$dlfile"
    echo "$md5"
}

# ── phantom-dep handling (deps unneeded on -current) ──────────────────────────

# Load PHANTOM_DEPS_FILE into the PHANTOM_DEPS array (one dep per line, '#'
# comments and blank lines ignored). Missing file => empty array.
PHANTOM_DEPS=()
load_phantom_deps() {
    PHANTOM_DEPS=()
    [[ -f "$PHANTOM_DEPS_FILE" ]] || return 0
    local line
    while IFS= read -r line; do
        line="${line%%#*}"                 # strip comment
        line="${line//[[:space:]]/}"       # strip whitespace
        [[ -n "$line" ]] && PHANTOM_DEPS+=("$line")
    done < "$PHANTOM_DEPS_FILE"
}

# Read REQUIRES="..." from an .info file and echo the phantom deps it contains,
# space-separated. Empty output if none.
phantom_deps_in_info() {
    local info="$1" requires dep
    [[ -f "$info" ]] || return 0
    requires=$(. "$info" >/dev/null 2>&1; echo "${REQUIRES:-}")
    local hits=()
    for dep in "${PHANTOM_DEPS[@]}"; do
        [[ " $requires " == *" $dep "* ]] && hits+=("$dep")
    done
    echo "${hits[*]}"
}

# Merge a set of deps into a hint file's DELREQUIRES (union, dedup), preserving
# all other content. Creates a minimal hint if the file is absent. Backs up an
# existing file to .bak first (mkhint convention). Args: hintpath dep...
merge_delrequires() {
    local hint="$1"; shift
    local new_deps=("$@")
    [[ ${#new_deps[@]} -eq 0 ]] && return 0

    local existing="" combined
    if [[ -f "$hint" ]]; then
        existing=$(. "$hint" >/dev/null 2>&1; echo "${DELREQUIRES:-}")
    fi
    # union existing + new, dedup, first-seen order
    combined=$(printf '%s\n' $existing "${new_deps[@]}" | awk 'NF && !seen[$0]++' | tr '\n' ' ')
    combined="${combined% }"

    if [[ -f "$hint" ]]; then
        # no change needed? (all new deps already present) → skip, don't churn .bak
        local cur_norm new_norm
        cur_norm=$(printf '%s\n' $existing | awk 'NF && !seen[$0]++' | sort | tr '\n' ' ')
        new_norm=$(printf '%s\n' $existing "${new_deps[@]}" | awk 'NF && !seen[$0]++' | sort | tr '\n' ' ')
        [[ "$cur_norm" == "$new_norm" ]] && return 0

        cp "$hint" "${hint}.bak"
        if grep -q '^DELREQUIRES=' "$hint"; then
            sed -i "s#^DELREQUIRES=.*#DELREQUIRES=\"$combined\"#" "$hint"
        else
            printf 'DELREQUIRES="%s"\n' "$combined" >> "$hint"
        fi
    else
        printf 'DELREQUIRES="%s"\n' "$combined" > "$hint"
    fi
}

# Bulk sweep: for every package in REPO_DIR whose REQUIRES contains a phantom
# dep, ensure its hint carries the matching DELREQUIRES. Idempotent.
fix_current() {
    load_phantom_deps
    if [[ ${#PHANTOM_DEPS[@]} -eq 0 ]]; then
        echo "No phantom deps configured ($PHANTOM_DEPS_FILE). Nothing to do."
        return 0
    fi
    echo "Sweeping for phantom deps: ${PHANTOM_DEPS[*]}"
    local info prgnam deps count=0
    while IFS= read -r info; do
        prgnam=$(basename "$info" .info)
        deps=$(phantom_deps_in_info "$info")
        [[ -z "$deps" ]] && continue
        merge_delrequires "$HINT_DIR/$prgnam.hint" $deps
        echo "  $prgnam <- DELREQUIRES: $deps"
        (( count++ )) || true
    done < <(find "$REPO_DIR" -mindepth 2 -maxdepth 3 -name '*.info' | sort)
    echo "Done. $count package(s) with phantom deps."
}

# ── bundled-dep manifest handling ─────────────────────────────────────────────
# Reconcile a bundle package's extra DOWNLOAD lines against an upstream deps
# manifest (opt-in via BUNDLE_MANIFEST_FILE). See docs spec 2026-07-07.

# parse_manifest <file> — print one download URL per line for each NAME_URL
# entry. SHA256 lines ignored (hints use MD5SUM; we re-download and md5).
parse_manifest() {
    local file="$1"
    [[ -f "$file" ]] || return 0
    awk '/_URL[[:space:]]/ { print $2 }' "$file"
}

# _url_stem <url> — reduce a download URL's basename to a comparison stem:
# drop a trailing archive extension, then a trailing -<version> or the version
# is the whole tail after the name. Version = [vV]?[0-9]... or a 7+ hex sha.
_url_stem() {
    local base="${1##*/}"
    base="${base%.tar.gz}"; base="${base%.tar.xz}"; base="${base%.tar.bz2}"
    base="${base%.zip}"; base="${base%.tgz}"
    # strip a trailing -<version> (version starts with optional v then a digit,
    # or is a 7+ char hex sha)
    if [[ "$base" =~ ^(.+)-[vV]?[0-9].*$ ]]; then
        base="${BASH_REMATCH[1]}"
    elif [[ "$base" =~ ^(.+)-[0-9a-f]{7,}$ ]]; then
        base="${BASH_REMATCH[1]}"
    fi
    printf '%s\n' "$base"
}

# _url_repo <url> — echo owner/repo for a github URL, empty otherwise.
_url_repo() {
    [[ "$1" =~ github\.com/([^/]+)/([^/]+) ]] && printf '%s/%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]%.git}"
}

# match_dep_url <hint_url> <manifest_url_list> — echo the manifest URL that
# corresponds to hint_url, or nothing. Repo-path match first, then exact
# basename-stem fallback (for blob hosts where the repo path is ambiguous).
match_dep_url() {
    local hint_url="$1" list="$2"
    local hrepo; hrepo=$(_url_repo "$hint_url")
    local m mrepo
    # pass 1: repo-path (skip the shared-blob-host repo neovim/deps so a blob
    # URL never wins on repo path; it must match on stem instead)
    if [[ -n "$hrepo" && "$hrepo" != */deps ]]; then
        while IFS= read -r m; do
            [[ -z "$m" ]] && continue
            mrepo=$(_url_repo "$m")
            if [[ -n "$mrepo" && "$mrepo" != */deps && "$mrepo" == "$hrepo" ]]; then
                printf '%s\n' "$m"; return 0
            fi
        done <<< "$list"
    fi
    # pass 2: exact stem
    local hstem; hstem=$(_url_stem "$hint_url")
    while IFS= read -r m; do
        [[ -z "$m" ]] && continue
        [[ "$(_url_stem "$m")" == "$hstem" ]] && { printf '%s\n' "$m"; return 0; }
    done <<< "$list"
    return 1
}

# Loaded map: pkg name -> URL template. Populated by load_bundle_manifests.
declare -A BUNDLE_MANIFESTS=()
load_bundle_manifests() {
    BUNDLE_MANIFESTS=()
    [[ -f "$BUNDLE_MANIFEST_FILE" ]] || return 0
    local line pkg url
    while IFS= read -r line; do
        line="${line%%#*}"
        [[ -z "${line// }" ]] && continue
        read -r pkg url <<< "$line"
        [[ -n "$pkg" && -n "$url" ]] && BUNDLE_MANIFESTS["$pkg"]="$url"
    done < "$BUNDLE_MANIFEST_FILE"
}

# manifest_url_for <pkg> <version> — echo the manifest URL for pkg with
# {VERSION} substituted. Non-zero if pkg is not listed.
manifest_url_for() {
    local pkg="$1" ver="$2"
    local tmpl="${BUNDLE_MANIFESTS[$pkg]:-}"
    [[ -z "$tmpl" ]] && return 1
    printf '%s\n' "${tmpl//\{VERSION\}/$ver}"
}

# True if pkg has a bundle manifest configured.
pkg_has_manifest() {
    [[ -n "${BUNDLE_MANIFESTS[$1]:-}" ]]
}

# fetch_manifest <url> — download the manifest to a temp file, echo its path.
# Non-zero on wget failure. Caller cleans up (path is under TMP_DIR).
fetch_manifest() {
    local url="$1"
    [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR"
    local out="${TMP_DIR}/manifest"
    rm -f "$out"
    wget -O "$out" "$url" >&2 || return 1
    [[ -s "$out" ]] || return 1
    printf '%s\n' "$out"
}

# reconcile_bundle_deps <pkg> <hintfile> <manifest_url> <mode>
# mode = report (print only) | apply (rewrite matched changed lines + md5).
# Reconciles the DOWNLOAD line's extra URLs (index >= 1) against the manifest.
# The primary URL (index 0) is never touched here. Guarded for set -e.
reconcile_bundle_deps() {
    local pkg="$1" hint="$2" murl="$3" mode="$4"

    # Display label for a dep URL: _url_stem is basename-only, so for a github
    # "archive/vX.Y.Z.tar.gz" tarball (no repo name in the filename) it just
    # returns the bare version — fall back to the repo name in that case.
    local _dep_label
    _dep_label() {
        local s; s=$(_url_stem "$1")
        if [[ "$s" =~ ^[vV]?[0-9] ]]; then
            local r; r=$(_url_repo "$1")
            [[ -n "$r" ]] && { printf '%s\n' "${r#*/}"; return; }
        fi
        printf '%s\n' "$s"
    }

    local mfile
    if ! mfile=$(fetch_manifest "$murl"); then
        echo "  $pkg: manifest unavailable ($murl) — bundled deps left as-is (retry with --force)"
        return 0
    fi
    local -a manifest_urls
    mapfile -t manifest_urls < <(parse_manifest "$mfile")
    rm -f "$mfile"
    if [[ ${#manifest_urls[@]} -eq 0 ]]; then
        echo "  $pkg: manifest empty/unrecognized — bundled deps left as-is"
        return 0
    fi
    local mlist; printf -v mlist '%s\n' "${manifest_urls[@]}"

    local -a urls
    mapfile -t urls < <(parse_multiline_var "DOWNLOAD" "$hint")
    (( ${#urls[@]} <= 1 )) && { echo "  $pkg: no bundled deps in DOWNLOAD"; return 0; }
    local -a md5s
    mapfile -t md5s < <(parse_multiline_var "MD5SUM" "$hint")

    # Track which manifest URLs got matched (for the FYI of unmatched ones).
    local -A matched_manifest=()
    local -a new_urls=("${urls[@]}")
    local -a changed_idx=() changed_from=() changed_to=()
    local i m

    # The primary hint URL (index 0) is never reconciled, but its manifest
    # counterpart must count as "matched" so the FYI doesn't always flag it.
    local _pm; _pm=$(match_dep_url "${urls[0]}" "$mlist") || _pm=""
    [[ -n "$_pm" ]] && matched_manifest["$_pm"]=1

    for (( i=1; i<${#urls[@]}; i++ )); do
        m=$(match_dep_url "${urls[$i]}" "$mlist") || m=""
        if [[ -z "$m" ]]; then
            echo "  $pkg: $(_dep_label "${urls[$i]}") — no manifest match (left as-is)"
            continue
        fi
        matched_manifest["$m"]=1
        if [[ "$m" != "${urls[$i]}" ]]; then
            changed_idx+=("$i"); changed_from+=("${urls[$i]}"); changed_to+=("$m")
            new_urls[$i]="$m"
        fi
    done

    # Manifest-only deps FYI (manifest URLs never matched by any hint line).
    local -a extra=()
    for m in "${manifest_urls[@]}"; do
        [[ -z "${matched_manifest[$m]:-}" ]] && extra+=("$(_dep_label "$m")")
    done
    if [[ ${#extra[@]} -gt 0 ]]; then
        echo "$pkg: manifest has ${#extra[@]} deps not in hint:"
        printf '%s\n' "${extra[@]}"
    fi

    if [[ ${#changed_idx[@]} -eq 0 ]]; then
        echo "  $pkg: bundled deps all current"
        return 0
    fi

    echo ""
    echo "$pkg bundled deps changed upstream:"
    for (( i=0; i<${#changed_idx[@]}; i++ )); do
        printf '  %s  ->  %s\n' "$(_dep_label "${changed_from[$i]}")" "$(_dep_label "${changed_to[$i]}")"
    done

    [[ "$mode" == report ]] && return 0

    # apply mode: recompute md5 only for changed lines, rewrite DOWNLOAD+MD5SUM.
    local -a new_md5s=("${md5s[@]}")
    local idx md5
    for idx in "${changed_idx[@]}"; do
        echo "Downloading (bundled): ${new_urls[$idx]}"
        if md5=$(download_file "${new_urls[$idx]}"); then
            new_md5s[$idx]="$md5"
        else
            echo "  download failed for ${new_urls[$idx]} — left as-is"
            new_urls[$idx]="${urls[$idx]}"   # revert this line
        fi
    done

    local new_dl new_md5v
    new_dl=$(build_multiline_value new_urls);  new_dl="${new_dl#\"}";  new_dl="${new_dl%\"}"
    new_md5v=$(build_multiline_value new_md5s); new_md5v="${new_md5v#\"}"; new_md5v="${new_md5v%\"}"
    perl -i -0pe 'BEGIN{$v=shift} s|^DOWNLOAD="[^"]*(?:\\\n[^"]*)*"|DOWNLOAD="$v"|m' "$new_dl" "$hint"
    perl -i -0pe 'BEGIN{$v=shift} s|^MD5SUM="[^"]*(?:\\\n[^"]*)*"|MD5SUM="$v"|m' "$new_md5v" "$hint"
}
# ── end bundled-dep manifest handling ─────────────────────────────────────────

# Create new hint file
create_new_hint_file() {
    cd "$HINT_DIR"
    local file="$1"
    local normalized_file="${file}"

    if [[ "$file" != *.hint ]]; then
        normalized_file="${file}.hint"
    fi

    # search repository for .info file
    info=$(find "$REPO_DIR" -mindepth 2 -name "${normalized_file%.hint}.info")

    # Check if file exists
    if [[ ! -f "$normalized_file" ]]; then
        # the hint file we want to create doesn't exists, so we can check
        # the sbo repository for a .info file and use that as hint
        if [[ -n $info ]]; then
            cp "$info" "$normalized_file"
            # remove unwanted lines from hint file
            sed -i -e "/^PRGNAM=/d" \
            -e "/^HOMEPAGE=/d" \
            -e "/^MAINTAINER=/d" \
            -e "/^EMAIL=/d" \
            -e "s/^REQUIRES=/#REQUIRES=/" \
            "${normalized_file}"

            if grep -q '^ARCH=' $normalized_file; then
                sed -i 's/^ARCH=.*/ARCH="x86_64"/' $normalized_file
            else
                echo 'ARCH="x86_64"' >> $normalized_file
            fi

            # auto-strip -current phantom deps present in the .info REQUIRES
            load_phantom_deps
            local phantom; phantom=$(phantom_deps_in_info "$info")
            if [[ -n "$phantom" ]]; then
                printf 'DELREQUIRES="%s"\n' "$phantom" >> "$normalized_file"
                echo "added DELREQUIRES=\"$phantom\" (unneeded on -current)."
            fi

            if [[ -n "$VERSION" ]]; then
                local old_version
                old_version=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//')
                # bump both '_' and '-' variants (see update_hint_file note)
                sed -i "s/${old_version}/${VERSION}/g" "$normalized_file"
                if [[ "$old_version" == *_* || "$VERSION" == *_* ]]; then
                    sed -i "s/${old_version//_/-}/${VERSION//_/-}/g" "$normalized_file"
                fi
                update_checksums "$normalized_file"
            fi

            if [[ $NO_DL -eq 1 ]]; then
                add_nodownload "$normalized_file"
            fi

            echo "generated $normalized_file from $(basename $info)."
            echo "Check variables before using."

            load_bundle_manifests
            local _bm_pkg="${normalized_file%.hint}"
            _bm_pkg="${_bm_pkg##*/}"
            if pkg_has_manifest "$_bm_pkg"; then
                local _bm_ver; _bm_ver=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//')
                local _bm_url; _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_ver")
                echo ""
                echo "$_bm_pkg: bundled-dep manifest check"
                reconcile_bundle_deps "$_bm_pkg" "$normalized_file" "$_bm_url" report || true
            fi

            add_nvchecker_section "${normalized_file%.hint}" "$info"
        fi
    else
        echo "Hint file exists: $normalized_file" >&2
        mv "$normalized_file" "${normalized_file}.bak"
        echo "Backed up to: ${normalized_file}.bak" >&2
        # Create new hint file with empty variables
        cat > "$normalized_file" <<EOF
VERSION="${VERSION}"
ARCH="x86_64"
DOWNLOAD=""
MD5SUM=""
DOWNLOAD_x86_64=""
MD5SUM_x86_64=""
EOF
        if [[ $NO_DL -eq 1 ]]; then
            add_nodownload "$normalized_file"
        fi

        echo "Created new hint file [EMPTY]: $normalized_file"

    fi
}

# Emit the TOML section label for a package: bare if the name is a valid
# bare key ([A-Za-z0-9_] only), otherwise double-quoted. nvchecker (and TOML)
# require quoting for names containing '.', '-', etc.
_nvchecker_label() {
    local pkg="$1"
    if [[ "$pkg" =~ ^[A-Za-z0-9_]+$ ]]; then
        printf '[%s]' "$pkg"
    else
        printf '["%s"]' "$pkg"
    fi
}

# Return 0 if NVCHECKER_CONFIG already has a section for pkg (bare or quoted)
_has_nvchecker_section() {
    local pkg="$1"
    [[ -f "$NVCHECKER_CONFIG" ]] || return 1
    local label; label=$(_nvchecker_label "$pkg")
    # fixed-string match of the exact label at line start, trailing space allowed
    grep -qE "^$(printf '%s' "$label" | sed 's/[][\.*^$/]/\\&/g')[[:space:]]*$" \
        "$NVCHECKER_CONFIG"
}

# Append an nvchecker [pkg] section to NVCHECKER_CONFIG, auto-detecting the
# source from the package's .info DOWNLOAD/HOMEPAGE. No-op if section exists.
add_nvchecker_section() {
    local pkg="$1"
    local info_file="$2"

    # Ensure config dir/file exist (do not create __config__; user owns that)
    mkdir -p "$(dirname "$NVCHECKER_CONFIG")"
    touch "$NVCHECKER_CONFIG"

    local label; label=$(_nvchecker_label "$pkg")

    # Skip if section already present
    if _has_nvchecker_section "$pkg"; then
        echo "nvchecker: ${label} already present in $NVCHECKER_CONFIG"
        return 0
    fi

    local download="" homepage=""
    if [[ -f "$info_file" ]]; then
        download=$(grep -E '^(DOWNLOAD|DOWNLOAD_x86_64)=' "$info_file" | head -1)
        homepage=$(grep -E '^HOMEPAGE=' "$info_file" | head -1)
    fi
    local haystack="${download} ${homepage}"

    local section=""
    if [[ "$haystack" =~ github\.com/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+) ]]; then
        local owner="${BASH_REMATCH[1]}"
        local repo="${BASH_REMATCH[2]}"
        repo="${repo%.git}"
        section=$(cat <<EOF

${label}
source = "github"
github = "${owner}/${repo}"
use_max_tag = true
EOF
)
    elif [[ "$haystack" =~ (pypi\.org|files\.pythonhosted\.org) ]]; then
        section=$(cat <<EOF

${label}
source = "pypi"
pypi = "${pkg}"
EOF
)
    else
        section=$(cat <<EOF

${label}
# TODO: configure nvchecker source for "${pkg}"
# source = "regex"
# url = "..."
# regex = "..."
# see https://nvchecker.readthedocs.io/en/latest/usage.html
EOF
)
    fi

    printf '%s\n' "$section" >> "$NVCHECKER_CONFIG"
    echo "nvchecker: review/fill ${label} section in $NVCHECKER_CONFIG"
}

# Add NODOWNLOAD=yes after MD5SUM_x86_64 line if not already present
add_nodownload() {
    local file="$1"
    if ! grep -q '^NODOWNLOAD=' "$file"; then
        sed -i '/^MD5SUM_x86_64=/a NODOWNLOAD=yes' "$file"
    fi
}

# Parse multiline variable value (handles \ continuation lines)
# Prints each whitespace-separated token on its own line
parse_multiline_var() {
    local varname="$1"
    local file="$2"
    # Join continuation lines, strip variable name and quotes, print one token per line
    awk -v var="${varname}" '
        BEGIN { found=0; buf="" }
        !found && $0 ~ "^"var"=\"" {
            found=1
            buf=$0
            sub("^"var"=\"", "", buf)
            if (buf !~ /\\[[:space:]]*$/) {
                gsub(/"[[:space:]]*$/, "", buf)
                n=split(buf, arr, /[[:space:]]+/)
                for (k=1;k<=n;k++) if(arr[k]!="") print arr[k]
                found=0; buf=""
            } else {
                gsub(/\\[[:space:]]*$/, "", buf)
            }
            next
        }
        found {
            if ($0 ~ /\\[[:space:]]*$/) {
                line=$0; gsub(/\\[[:space:]]*$/, "", line)
                buf=buf" "line
            } else {
                line=$0; gsub(/"[[:space:]]*$/, "", line)
                buf=buf" "line
                n=split(buf, arr, /[[:space:]]+/)
                for (k=1;k<=n;k++) if(arr[k]!="") print arr[k]
                found=0; buf=""
            }
        }
    ' "$file"
}

# Prompt user for updated continuation URLs; returns updated URLs via nameref array
# First URL is always kept as-is (already updated by version sed before this call)
prompt_continuation_urls() {
    local -n _urls="$1"   # nameref: array of current URLs
    local varname="$2"

    local i
    for (( i=1; i<${#_urls[@]}; i++ )); do
        local current="${_urls[$i]}"
        echo ""
        echo "  ${varname} line $((i+1)) (current): $current"
        read -r -p "  New URL (leave blank to keep): " new_url
        if [[ -n "$new_url" ]]; then
            _urls[$i]="$new_url"
        fi
    done
}

# Build multiline variable string for writing back to file
# Usage: build_multiline_value urls_array -> prints quoted multiline value
build_multiline_value() {
    local -n _arr="$1"
    local count=${#_arr[@]}
    local i
    for (( i=0; i<count; i++ )); do
        if (( i == 0 )); then
            printf '"%s' "${_arr[$i]}"
        else
            printf ' \\\n    %s' "${_arr[$i]}"
        fi
    done
    printf '"\n'
}

# Query nvchecker for a package's latest version and let the user accept or
# override it. Echoes the chosen version on stdout. Returns non-zero if the
# user declines or no version is available (caller decides what to do).
suggest_version() {
    local pkg="$1"

    # Refresh nvchecker results (stderr only; keep stdout clean for the echo).
    # -e limits the run to this entry instead of scanning the whole config.
    nvchecker -c "$NVCHECKER_CONFIG" -e "$pkg" >&2 || true

    local latest
    latest=$(nvchecker_latest "$pkg") || {
        echo "Error: no nvchecker result for '$pkg'. Add/fix its [${pkg}] section in $NVCHECKER_CONFIG" >&2
        return 1
    }
    latest=$(_normalize_version "$latest")

    # Read current version from the hint file (best effort, for display)
    local hintpath="${HINT_DIR%/}/${pkg}.hint"
    local current=""
    [[ -f "$hintpath" ]] && current=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//')

    local answer
    read -r -p "current ${current:-?}, latest ${latest}. Use ${latest}? [Y/n] (or type a version) " answer >&2
    answer="${answer:-Y}"
    case "$answer" in
        [Yy]) printf '%s\n' "$latest" ;;
        [Nn]) return 1 ;;
        *)    printf '%s\n' "$answer" ;;
    esac
}

# Download files and update MD5SUM/MD5SUM_x86_64 in hint file
update_checksums() {
    local file="$1"

    _process_download_var "DOWNLOAD" "MD5SUM" "$file"
    _process_download_var "DOWNLOAD_x86_64" "MD5SUM_x86_64" "$file"
}

# Process one DOWNLOAD/MD5SUM variable pair in a hint file
_process_download_var() {
    local dl_var="$1"
    local md5_var="$2"
    local file="$3"

    # Read current URLs into array
    mapfile -t urls < <(parse_multiline_var "$dl_var" "$file")

    [[ ${#urls[@]} -eq 0 ]] && return
    [[ "${urls[0]}" == "UNSUPPORTED" || "${urls[0]}" == "UNTESTED" ]] && return

    # Read current md5sums into array (parallel to urls)
    mapfile -t md5s < <(parse_multiline_var "$md5_var" "$file")

    # Save original URLs for change detection after prompt
    local orig_urls=("${urls[@]}")

    # Prompt user to update continuation URLs if present
    if (( ${#urls[@]} > 1 )); then
        echo ""
        echo "Multiline ${dl_var} detected in $(basename "$file")."
        prompt_continuation_urls urls "$dl_var"
    fi

    # Download and calculate md5 for each URL
    local new_md5s=()
    local i
    for (( i=0; i<${#urls[@]}; i++ )); do
        local url="${urls[$i]}"
        if (( i == 0 )); then
            # Always re-download first URL
            echo "Downloading: $url"
            new_md5s+=( "$(download_file "$url")" )
        else
            # Only re-download if URL changed from original
            if [[ "$url" != "${orig_urls[$i]}" ]]; then
                echo "Downloading (updated): $url"
                new_md5s+=( "$(download_file "$url")" )
            else
                echo "Keeping existing md5 for: $url"
                new_md5s+=( "${md5s[$i]}" )
            fi
        fi
    done

    # Rebuild and write back DOWNLOAD variable (may have updated continuation URLs)
    local new_dl_value
    new_dl_value=$(build_multiline_value urls)
    # Strip surrounding quotes — perl wraps them in the substitution
    new_dl_value="${new_dl_value#\"}"
    new_dl_value="${new_dl_value%\"}"
    perl -i -0pe 'BEGIN{$v=shift} s|^'"${dl_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${dl_var}"'="$v"|m' \
        "$new_dl_value" "$file"

    # Rebuild and write back MD5SUM variable
    local new_md5_value
    new_md5_value=$(build_multiline_value new_md5s)
    new_md5_value="${new_md5_value#\"}"
    new_md5_value="${new_md5_value%\"}"
    perl -i -0pe 'BEGIN{$v=shift} s|^'"${md5_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${md5_var}"'="$v"|m' \
        "$new_md5_value" "$file"
}

# Update existing hint file
update_hint_file() {
    cd "$HINT_DIR"
    local file="$1"
    local new_version="$2"
    local old_version=""

    local normalized_file="${file}"

    if [[ "$file" != *.hint ]]; then
        normalized_file="${file}.hint"
    fi

    # Check if file exists
    if [[ ! -f "$normalized_file" ]]; then
        echo "Error: Hint file does not exist: $normalized_file" >&2
        exit 2
    fi

    # Force backup as precaution before modifying
    echo "Hint file exists: $normalized_file" >&2
    cp "$normalized_file" "${normalized_file}.bak"
    echo "Backed up to: ${normalized_file}.bak" >&2

    # Extract current version from hint file
    old_version=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//')

    # Use sed for global replacement of OLD_VERSION in all variables.
    # SlackBuild VERSION uses '_' but download URLs often carry the upstream
    # '-' form (dates like 2026-07-07, tags), so bump both variants — otherwise
    # a dated URL keeps pointing at the old tarball and its md5 goes stale.
    sed -i "s/${old_version}/${new_version}/g" "$normalized_file"
    if [[ "$old_version" == *_* || "$new_version" == *_* ]]; then
        sed -i "s/${old_version//_/-}/${new_version//_/-}/g" "$normalized_file"
    fi

    update_checksums "$normalized_file"

    if [[ $NO_DL -eq 1 ]]; then
        add_nodownload "$normalized_file"
    fi

    hf=$(cat $normalized_file)
    echo "Updated hint file: $normalized_file"
    echo "=========================================="
    echo -n "$hf"
    echo; echo "=========================================="
}

# True if a built package (.txz) for <pkg> exists in the repo.
pkg_in_repo() {
    local pkg="$1"
    compgen -G "${PACKAGES_DIR%/}/*/${pkg}/${pkg}-*.txz" >/dev/null 2>&1
}

# Prompt to run `slackrepo <action> <pkgs...>`; no-op on empty list.
run_slackrepo() {
    local action="$1"; shift
    [[ $# -eq 0 ]] && return 0
    local answer
    read -r -p "Run 'slackrepo $action $*'? [Y/n] " answer
    answer="${answer:-Y}"
    if [[ "$answer" =~ ^[Yy]$ ]]; then
        slackrepo "$action" "$@"
    fi
}

# Single-package dispatch: update if already built, else build.
prompt_slackrepo() {
    local pkg="$1"
    if pkg_in_repo "$pkg"; then
        run_slackrepo update "$pkg"
    else
        run_slackrepo build "$pkg"
    fi
}

# Remove a hint file and its .bak if present. No existence guard, no exit —
# safe to call inside loops. Echoes what was removed.
_remove_hint() {
    local full_path="$1"
    rm "$full_path"
    echo "Deleted: $full_path"
    local bak_path="${full_path}.bak"
    if [[ -f "$bak_path" ]]; then
        rm "$bak_path"
        echo "Deleted: $bak_path"
    fi
}

# Delete a hint file (and .bak if present)
delete_hint_file() {
    local file="$1"
    local normalized_file="${file}"

    if [[ "$file" != *.hint ]]; then
        normalized_file="${file}.hint"
    fi

    local full_path="${HINT_DIR}/${normalized_file}"

    if [[ ! -f "$full_path" ]]; then
        echo "Error: Hint file does not exist: $full_path" >&2
        exit 2
    fi

    _remove_hint "$full_path"
}

# Clean .bak files from HINT_DIR
clean_bak_files() {
    if [[ ! -d "$HINT_DIR" ]]; then
        echo "Error: Hint directory does not exist: $HINT_DIR" >&2
        exit 2
    fi

    local count=0
    for bakfile in "$HINT_DIR"/*.bak; do
        if [[ -f "$bakfile" ]]; then
            rm "$bakfile"
            count=$((count + 1))
        fi
    done

    echo "Removed $count .bak file(s) from $HINT_DIR"
}

# Bulk-check hint files for upstream updates and apply interactively.
# Usage: check_updates [pkg...]   (no args = all *.hint in HINT_DIR)
check_updates() {
    check_nvchecker

    if [[ ! -d "$HINT_DIR" ]]; then
        echo "Error: Hint directory does not exist: $HINT_DIR" >&2
        exit 2
    fi

    # Build the target package list
    local targets=()
    if [[ $# -gt 0 ]]; then
        targets=("$@")
    else
        local f
        for f in "$HINT_DIR"/*.hint; do
            [[ -f "$f" ]] || continue
            local b; b=$(basename "$f"); targets+=("${b%.hint}")
        done
    fi

    # Refresh nvchecker results. With exactly one explicit target, -e limits
    # the run to that entry; otherwise scan the whole config in one pass.
    echo "Running nvchecker..."
    if [[ ${#targets[@]} -eq 1 && $# -gt 0 ]]; then
        nvchecker -c "$NVCHECKER_CONFIG" -e "${targets[0]}" >&2 || true
    else
        nvchecker -c "$NVCHECKER_CONFIG" >&2 || true
    fi

    # Classify each target
    local outdated_pkgs=() outdated_old=() outdated_new=() outdated_flag=()
    local missing_sections=()
    local pkg
    for pkg in "${targets[@]}"; do
        local hintpath="${HINT_DIR%/}/${pkg}.hint"
        [[ -f "$hintpath" ]] || { echo "skip ${pkg}: no hint file"; continue; }
        local current; current=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//')
        local latest
        if ! latest=$(nvchecker_latest "$pkg"); then
            if _has_nvchecker_section "$pkg"; then
                echo "skip ${pkg}: no nvchecker result"
            else
                echo "skip ${pkg}: no nvchecker section"
                missing_sections+=("$pkg")
            fi
            continue
        fi
        local latest_norm; latest_norm=$(_normalize_version "$latest")
        [[ "$current" == "$latest_norm" ]] && continue   # up to date
        # determine direction with sort -V (compare normalized forms)
        local newest; newest=$(printf '%s\n%s\n' "$current" "$latest_norm" | sort -V | tail -1)
        local flag="update"
        [[ "$newest" == "$current" ]] && flag="?downgrade"
        outdated_pkgs+=("$pkg")
        outdated_old+=("$current")
        outdated_new+=("$latest_norm")
        outdated_flag+=("$flag")
    done

    # Offer to populate nvchecker.toml for packages with no section
    if [[ ${#missing_sections[@]} -gt 0 ]]; then
        echo ""
        echo "${#missing_sections[@]} package(s) have no nvchecker section: ${missing_sections[*]}"
        local answer
        read -r -p "Populate ${NVCHECKER_CONFIG} now? [Y/n] " answer
        answer="${answer:-Y}"
        if [[ "$answer" =~ ^[Yy]$ ]]; then
            local mp info
            for mp in "${missing_sections[@]}"; do
                info=$(find "$REPO_DIR" -mindepth 2 -name "${mp}.info" 2>/dev/null | head -1)
                if [[ -z "$info" ]]; then
                    echo "skip ${mp}: no .info found in $REPO_DIR"
                    continue
                fi
                add_nvchecker_section "$mp" "$info"
            done
            echo ""
            echo "Sections added. Review $NVCHECKER_CONFIG (fill any stubs), then re-run 'mkhint -C'."
            return 0
        fi
    fi

    local updated=()
    if [[ ${#outdated_pkgs[@]} -eq 0 ]]; then
        echo "all up to date"
    else
        # Report
        echo ""
        echo "Updates available:"
        local i
        for (( i=0; i<${#outdated_pkgs[@]}; i++ )); do
            local note=""; [[ "${outdated_flag[$i]}" == "?downgrade" ]] && note=" (?downgrade)"
            printf "  %-30s %s -> %s%s\n" "${outdated_pkgs[$i]}" "${outdated_old[$i]}" "${outdated_new[$i]}" "$note"
        done
        echo ""

        # Per-package confirm + update
        for (( i=0; i<${#outdated_pkgs[@]}; i++ )); do
            local p="${outdated_pkgs[$i]}"
            local note=""; [[ "${outdated_flag[$i]}" == "?downgrade" ]] && note=" (?downgrade)"
            local answer
            read -r -p "${p}  ${outdated_old[$i]} -> ${outdated_new[$i]}${note}. Update? [Y/n] " answer
            answer="${answer:-Y}"
            if [[ "$answer" =~ ^[Yy]$ ]]; then
                update_hint_file "$p" "${outdated_new[$i]}"
                nvtake -c "$NVCHECKER_CONFIG" "$p" >&2 || true
                updated+=("$p")
            fi
        done

        # Split updated packages: existing → update, new → build.
        if [[ ${#updated[@]} -gt 0 ]]; then
            local -a existing=() fresh=()
            local up
            for up in "${updated[@]}"; do
                if pkg_in_repo "$up"; then existing+=("$up"); else fresh+=("$up"); fi
            done
            run_slackrepo update "${existing[@]}"
            run_slackrepo build  "${fresh[@]}"
        fi
    fi

    # Phase 2: bundled-dep manifest reconcile for listed packages.
    load_bundle_manifests
    local was_updated
    for pkg in "${targets[@]}"; do
        pkg_has_manifest "$pkg" || continue
        local hintpath="${HINT_DIR%/}/${pkg}.hint"
        [[ -f "$hintpath" ]] || continue
        was_updated=0
        local u
        for u in "${updated[@]}"; do [[ "$u" == "$pkg" ]] && was_updated=1; done
        if [[ $was_updated -eq 0 && $FORCE -ne 1 ]]; then
            continue
        fi
        local cur; cur=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//')
        local murl; murl=$(manifest_url_for "$pkg" "$cur") || continue
        echo ""
        echo "Bundled-dep reconcile: $pkg (manifest @ $cur)"
        reconcile_bundle_deps "$pkg" "$hintpath" "$murl" report || true
        local ans
        read -r -p "Apply bundled-dep updates for $pkg? [Y/n] " ans
        ans="${ans:-Y}"
        if [[ "$ans" =~ ^[Yy]$ ]]; then
            reconcile_bundle_deps "$pkg" "$hintpath" "$murl" apply || true
        fi
    done
}

# Main function
main() {
    local parsed
    parsed=$(getopt -o vV:f:n:lcCdNhRF \
        --long version,set-version:,hintfile:,new:,list,clean,check,delete,no-dl,help,review,fix-current,force \
        -n 'mkhint' -- "$@") || { show_help; exit 1; }
    eval set -- "$parsed"

    while true; do
        case "$1" in
            --version|-v)
                echo "mkhint $MKHINT_VERSION"
                exit 0
                ;;
            --set-version|-V)
                VERSION="$2"
                shift 2
                ;;
            --hintfile|-f)
                HINT_FILE="$2"
                shift 2
                ;;
            --new|-n)
                NEW_HINT_FILE="$2"
                shift 2
                ;;
            --list|-l)
                SHOW_LIST=1
                shift
                ;;
            --review|-R)
                RUN_REVIEW=1
                shift
                ;;
            --clean|-c)
                COMMAND="clean"
                shift
                ;;
            --check|-C)
                COMMAND="check"
                shift
                ;;
            --fix-current|-F)
                COMMAND="fix-current"
                shift
                ;;
            --delete|-d)
                COMMAND="delete"
                shift
                ;;
            --no-dl|-N)
                NO_DL=1
                shift
                ;;
            --force)
                FORCE=1
                shift
                ;;
            --help|-h)
                COMMAND="help"
                shift
                ;;
            --)
                shift
                break
                ;;
            *)
                echo "Unknown option: $1" >&2
                show_help
                exit 1
                ;;
        esac
    done

    # Collect remaining positional args.
    # When -R is active and no other command set, they are review targets;
    # otherwise they are delete targets.
    while [[ $# -gt 0 ]]; do
        if [[ -n "$RUN_REVIEW" && -z "$COMMAND" ]]; then
            REVIEW_PKGS+=("$1")
        elif [[ -n "$SHOW_LIST" && -z "$COMMAND" ]]; then
            LIST_PKGS+=("$1")
        else
            DELETE_HINT_FILES+=("$1")
        fi
        shift
    done

    if [[ -z "$COMMAND" ]]; then
        # Default to update hint file if VERSION and HINT_FILE are provided
        if [[ -n "$HINT_FILE" ]]; then
            COMMAND="update"
        elif [[ -n "$NEW_HINT_FILE" ]]; then
            COMMAND="new"
        elif [[ ${#DELETE_HINT_FILES[@]} -gt 0 ]]; then
            COMMAND="delete"
        fi
    fi

    if [[ $NO_DL -eq 1 && -z "$HINT_FILE" && -z "$NEW_HINT_FILE" ]]; then
        echo "Error: --no-dl requires --hintfile or --new" >&2
        exit 1
    fi

    if [[ "$COMMAND" == "check" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then
        echo "Error: --check cannot be combined with --set-version/--hintfile/--new" >&2
        exit 1
    fi

    if [[ "$COMMAND" == "fix-current" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then
        echo "Error: --fix-current cannot be combined with --set-version/--hintfile/--new" >&2
        exit 1
    fi

    if [[ $FORCE -eq 1 && "$COMMAND" != "check" ]]; then
        echo "Error: --force is only valid with --check" >&2
        exit 1
    fi

    if [[ -n "$SHOW_LIST" && ${#LIST_PKGS[@]} -gt 0 ]]; then
        local p
        for p in "${LIST_PKGS[@]}"; do
            if [[ ! -f "${HINT_DIR%/}/${p}.hint" ]]; then
                echo "Error: hint file not found: ${HINT_DIR%/}/${p}.hint" >&2
                exit 2
            fi
        done
        for p in "${LIST_PKGS[@]}"; do
            _show_hint_diff "$p"
        done
        exit 0
    fi

    if [[ -n "$SHOW_LIST" || -n "$RUN_REVIEW" ]]; then
        [[ -n "$SHOW_LIST" ]] && list_hint_files
        if [[ -n "$RUN_REVIEW" ]]; then
            if [[ ${#REVIEW_PKGS[@]} -gt 0 ]]; then
                review_hint_files "${REVIEW_PKGS[@]}"
            else
                # ensure MATCHED_PKGS is populated even when -l was not given
                [[ -z "$SHOW_LIST" ]] && list_hint_files >/dev/null
                review_hint_files
            fi
        fi
        exit $?
    fi

    case "$COMMAND" in
        help)
            show_help
            ;;
        clean)
            clean_bak_files
            ;;
        check)
            check_updates "${DELETE_HINT_FILES[@]}"
            ;;
        fix-current)
            fix_current
            ;;
        update)
            check_wget
            if [[ -z "$VERSION" ]]; then
                check_nvchecker
                VERSION=$(suggest_version "$HINT_FILE") || { echo "Aborted." >&2; exit 0; }
                check_nvchecker_take=1
            fi
            update_hint_file "$HINT_FILE" "$VERSION"
            if [[ "${check_nvchecker_take:-0}" -eq 1 ]]; then
                nvtake -c "$NVCHECKER_CONFIG" "$HINT_FILE" >&2 || true
            fi

            load_bundle_manifests
            local _bm_pkg="${HINT_FILE%.hint}"
            _bm_pkg="${_bm_pkg##*/}"
            if pkg_has_manifest "$_bm_pkg"; then
                local _bm_hint="${HINT_DIR%/}/${_bm_pkg}.hint"
                local _bm_cur; _bm_cur=$(grep '^VERSION=' "$_bm_hint" | sed 's/VERSION="//;s/"$//')
                local _bm_url; _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_cur")
                echo ""
                echo "Bundled-dep reconcile: $_bm_pkg (manifest @ $_bm_cur)"
                reconcile_bundle_deps "$_bm_pkg" "$_bm_hint" "$_bm_url" report || true
                local _bm_ans
                read -r -p "Apply bundled-dep updates for $_bm_pkg? [Y/n] " _bm_ans
                _bm_ans="${_bm_ans:-Y}"
                if [[ "$_bm_ans" =~ ^[Yy]$ ]]; then
                    reconcile_bundle_deps "$_bm_pkg" "$_bm_hint" "$_bm_url" apply || true
                fi
            fi

            prompt_slackrepo "$HINT_FILE"
            ;;
        new)
            if [[ -n "$VERSION" ]]; then
                check_wget
            fi
            create_new_hint_file "$NEW_HINT_FILE"
            ;;
        delete)
            for f in "${DELETE_HINT_FILES[@]}"; do
                delete_hint_file "$f"
            done
            ;;
        *)
            echo "Error: Unknown command: $COMMAND" >&2
            show_help
            exit 1
            ;;
    esac
}

# Only run main when executed directly; allow tests to source this file to
# reach individual functions without triggering the CLI (and its `exit 1`
# for a no-args invocation).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
