#!/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.3.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
INFO_PKG=""

# 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)
  --info, -i FILE          Show category/program path and README for a package
  --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_version <url> — extract the version token from a download URL's basename.
# Dep names can themselves contain digits/dashes (lua-compat-5.3, win32yank-x64),
# so no fixed regex can split name from version. When the URL is a github one we
# know the repo name: strip a leading "<repo>-"/"<repo>_" prefix (case-insensitive,
# since github is case-insensitive: LuaJIT vs luajit) and the rest is the version.
# Otherwise fall back to a trailing-version / trailing-sha heuristic, then drop a
# leading "v". Both sides of a matched pair reduce to "the part that isn't the
# name", so equal versions in different URL shapes compare equal.
# ponytail: name comes from the github repo. A non-github host whose basename is
# "<digit-containing-name>-<version>" can't derive the name and may misparse; add
# a manifest name hint if such a dep ever appears.
_url_version() {
    local url="$1"
    local base="${url##*/}"
    base="${base%.tar.gz}"; base="${base%.tar.xz}"; base="${base%.tar.bz2}"
    base="${base%.zip}"; base="${base%.tgz}"
    local ver="$base"
    local rslug; rslug=$(_url_repo "$url")
    local repo="${rslug#*/}"                                  # bare repo name, lowercased
    # A shared blob repo (neovim/deps/raw/.../opt/<name>-<ver>) names the host,
    # not the dep, so its repo name is useless — fall through to the heuristic,
    # same exclusion match_dep_url makes.
    [[ "$rslug" == */deps ]] && repo=""
    local base_lc="${base,,}"
    if [[ -n "$repo" ]]; then
        # github: name is known. Strip a "<repo>[-_]" prefix if present; else the
        # whole basename is the version (bare-tag shape, e.g. "1.52.1-0", "v0.13").
        if [[ "$base_lc" == "$repo-"* || "$base_lc" == "${repo}_"* ]]; then
            ver="${base:${#repo}+1}"
        else
            ver="$base"
        fi
    elif [[ "$base" =~ -([vV]?[0-9][^/]*)$ ]]; then
        ver="${BASH_REMATCH[1]}"                              # non-github: trailing version
    elif [[ "$base" =~ -([0-9a-f]{7,})$ ]]; then
        ver="${BASH_REMATCH[1]}"                              # non-github: trailing sha
    fi
    ver="${ver#[vV]}"
    printf '%s\n' "$(_normalize_version "$ver")"
}

# _url_repo <url> — echo owner/repo for a github URL, empty otherwise.
# Lowercased: github owners/repos are case-insensitive, and the same dep can
# appear as JuliaStrings/utf8proc in one URL and juliastrings/utf8proc in another.
_url_repo() {
    [[ "$1" =~ github\.com/([^/]+)/([^/]+) ]] || return 0
    local r; printf -v r '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]%.git}"
    printf '%s\n' "${r,,}"
}

# 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 maps: pkg -> mode ("url"|"sha"); pkg -> mode-specific remainder.
# url:  remainder = the {VERSION} manifest URL template.
# sha:  remainder = "<repo> <version-template> <name=path> <name=path>...".
declare -A BUNDLE_MODE=()
declare -A BUNDLE_REST=()
load_bundle_manifests() {
    BUNDLE_MODE=(); BUNDLE_REST=()
    [[ -f "$BUNDLE_MANIFEST_FILE" ]] || return 0
    local line pkg mode rest
    while IFS= read -r line; do
        line="${line%%#*}"
        [[ -z "${line// }" ]] && continue
        read -r pkg mode rest <<< "$line"
        [[ -n "$pkg" && -n "$mode" && -n "$rest" ]] || continue
        BUNDLE_MODE["$pkg"]="$mode"
        BUNDLE_REST["$pkg"]="$rest"
    done < "$BUNDLE_MANIFEST_FILE"
}

# bundle_mode <pkg> — echo the mode for pkg, empty if not listed.
bundle_mode() { printf '%s\n' "${BUNDLE_MODE[$1]:-}"; }

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

# True if pkg has any bundle manifest configured (either mode).
pkg_has_manifest() {
    [[ -n "${BUNDLE_MODE[$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_or_ignored> <mode-arg>
# Dispatch on the package's configured bundle mode. url-mode uses the manifest
# URL argument (as before); sha-mode ignores it and reads BUNDLE_REST. Keeps the
# three existing call sites unchanged. Returns whatever the mode handler returns.
reconcile_bundle_deps() {
    local pkg="$1"
    case "${BUNDLE_MODE[$pkg]:-url}" in
        sha) reconcile_bundle_deps_sha "$@" ;;
        *)   reconcile_bundle_deps_url "$@" ;;
    esac
}

# reconcile_bundle_deps_sha <pkg> <hintfile> <ignored> <mode>
# mode = report | apply. Compares each manifest name=path submodule's upstream
# SHA (contents API) against the matching hint DOWNLOAD line's sha, rewrites +
# re-md5s drifted lines. Reports all deps ((current) for unchanged). Returns 2 in
# report mode when there are changes to apply, else 0. apply mode returns 0.
reconcile_bundle_deps_sha() {
    local pkg="$1" hint="$2" _ignored="$3" mode="$4"
    local rest="${BUNDLE_REST[$pkg]}"
    local repo ver_tmpl
    read -r repo ver_tmpl rest <<< "$rest"
    local cur_ver; cur_ver=$(grep '^VERSION=' "$hint" | sed 's/VERSION="//;s/"$//')
    local ref="${ver_tmpl//\{VERSION\}/$cur_ver}"

    local -a urls md5s
    mapfile -t urls < <(parse_multiline_var "DOWNLOAD_x86_64" "$hint")
    (( ${#urls[@]} == 0 )) && mapfile -t urls < <(parse_multiline_var "DOWNLOAD" "$hint")
    mapfile -t md5s < <(parse_multiline_var "MD5SUM_x86_64" "$hint")
    (( ${#md5s[@]} == 0 )) && mapfile -t md5s < <(parse_multiline_var "MD5SUM" "$hint")

    local -a new_urls=("${urls[@]}")
    local -a changed_idx=()
    local -a report_lines=()
    local pair name path upstream_sha subrepo i
    for pair in $rest; do
        name="${pair%%=*}"; path="${pair#*=}"
        local resp; resp=$(_fetch_submodule_sha "$repo" "$path" "$ref") || {
            report_lines+=("  $name: submodule path not found at $ref"); continue; }
        upstream_sha="${resp%%$'\t'*}"; subrepo="${resp#*$'\t'}"
        local found=-1 hrepo
        for (( i=0; i<${#urls[@]}; i++ )); do
            hrepo=$(_url_repo "${urls[$i]}")
            [[ "$hrepo" == "$subrepo" ]] && { found=$i; break; }
        done
        if (( found < 0 )); then
            report_lines+=("  $name: no matching DOWNLOAD line (FYI)"); continue
        fi
        local cur_sha=""
        [[ "${urls[$found]}" =~ /archive/([0-9a-f]{40}) ]] && cur_sha="${BASH_REMATCH[1]}"
        if [[ "$cur_sha" == "$upstream_sha" ]]; then
            report_lines+=("  $name $(printf %.7s "$cur_sha") (current)")
        else
            new_urls[$found]="${urls[$found]//$cur_sha/$upstream_sha}"
            changed_idx+=("$found")
            report_lines+=("  $name $(printf %.7s "$cur_sha") -> $(printf %.7s "$upstream_sha")")
        fi
    done

    if [[ "$mode" == report ]]; then
        echo ""
        echo "$pkg bundled deps (sha):"
        printf '%s\n' "${report_lines[@]}"
        (( ${#changed_idx[@]} > 0 )) && return 2
        return 0
    fi

    (( ${#changed_idx[@]} == 0 )) && return 0
    cp "$hint" "${hint}.bak"
    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]}"
        fi
    done
    local var_dl var_md5
    if grep -q '^DOWNLOAD_x86_64=' "$hint"; then var_dl="DOWNLOAD_x86_64"; var_md5="MD5SUM_x86_64"
    else var_dl="DOWNLOAD"; var_md5="MD5SUM"; fi
    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{$var=shift;$v=shift} s|^\Q$var\E="[^"]*(?:\\\n[^"]*)*"|$var."=\"".$v."\""|me' "$var_dl" "$new_dl" "$hint"
    perl -i -0pe 'BEGIN{$var=shift;$v=shift} s|^\Q$var\E="[^"]*(?:\\\n[^"]*)*"|$var."=\"".$v."\""|me' "$var_md5" "$new_md5v" "$hint"
    return 0
}

# _github_token — echo a GitHub token from nvchecker's keyfile, or nothing.
# nvchecker.toml has `keyfile = "<path>"`; that file has `github = "<token>"`.
_github_token() {
    local kf
    kf=$(grep -E '^[[:space:]]*keyfile[[:space:]]*=' "$NVCHECKER_CONFIG" 2>/dev/null \
         | head -1 | cut -d '"' -f2)
    [[ -n "$kf" ]] || return 0
    [[ "$kf" != /* ]] && kf="$(dirname "$NVCHECKER_CONFIG")/$kf"
    [[ -f "$kf" ]] || return 0
    grep -E '^[[:space:]]*github[[:space:]]*=' "$kf" 2>/dev/null \
        | head -1 | cut -d '"' -f2
}

# _fetch_submodule_sha <repo> <path> <ref> — GET the contents API for a submodule
# path at ref; echo "<sha>\t<submodule_owner/repo lowercased>". Non-zero on fail.
# Authed with _github_token when present (5000/h vs 60/h anon).
_fetch_submodule_sha() {
    local repo="$1" path="$2" ref="$3"
    repo="${repo#github:}"
    local url="https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}"
    [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR"
    local out="${TMP_DIR}/api_resp"
    rm -f "$out"
    local tok; tok=$(_github_token)
    local -a auth=()
    [[ -n "$tok" ]] && auth=(--header="Authorization: Bearer $tok")
    wget "${auth[@]}" -O "$out" "$url" >&2 || return 1
    [[ -s "$out" ]] || return 1
    local sha suburl
    sha=$(jq -r '.sha // empty' "$out")
    suburl=$(jq -r '.submodule_git_url // empty' "$out")
    [[ -n "$sha" ]] || return 1
    local sr=""
    if [[ "$suburl" =~ github\.com/([^/]+)/([^/]+) ]]; then
        printf -v sr '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]%.git}"
        sr="${sr,,}"
    fi
    printf '%s\t%s\n' "$sha" "$sr"
}

# _fetch_gitmodules_paths <repo> <ref> — echo each submodule "path" line from the
# .gitmodules at ref (one per line). Non-zero on fetch fail.
_fetch_gitmodules_paths() {
    local repo="${1#github:}" ref="$2"
    local url="https://raw.githubusercontent.com/${repo}/${ref}/.gitmodules"
    [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR"
    local out="${TMP_DIR}/gitmodules_${ref}"
    rm -f "$out"
    wget -O "$out" "$url" >&2 || return 1
    [[ -s "$out" ]] || return 1
    grep -E '^[[:space:]]*path[[:space:]]*=' "$out" | sed 's/.*=[[:space:]]*//'
}

# detect_set_drift <pkg> <old-ver> <new-ver> — print the full submodule inventory
# roster. Diffs .gitmodules@old vs @new; tags each submodule bundled/(ignored),
# with +/- change glyphs and ACTION (bundled removed) / review (new unbundled).
# FYI only, never edits. Skips with a notice if a fetch fails.
detect_set_drift() {
    local pkg="$1" oldv="$2" newv="$3"
    local rest="${BUNDLE_REST[$pkg]}"
    local repo; read -r repo _ rest <<< "$rest"
    local -A bundled=()
    local pair
    for pair in $rest; do bundled["${pair#*=}"]=1; done

    local -a new_paths old_paths
    local gm_new gm_old
    gm_new=$(_fetch_gitmodules_paths "$repo" "$newv") || {
        echo "  $pkg: .gitmodules@$newv unavailable — inventory skipped"; return 0; }
    mapfile -t new_paths <<< "$gm_new"
    if [[ "$oldv" == "$newv" ]]; then
        old_paths=("${new_paths[@]}")
    else
        gm_old=$(_fetch_gitmodules_paths "$repo" "$oldv") || {
            echo "  $pkg: .gitmodules@$oldv unavailable — inventory skipped"; return 0; }
        mapfile -t old_paths <<< "$gm_old"
    fi
    local -A in_old=() in_new=()
    local p; for p in "${old_paths[@]}"; do in_old["$p"]=1; done
    for p in "${new_paths[@]}"; do in_new["$p"]=1; done

    echo ""
    echo "$pkg submodule inventory $oldv -> $newv:"
    local glyph tag
    for p in "${new_paths[@]}"; do
        glyph=" "; [[ -z "${in_old[$p]:-}" ]] && glyph="+"
        if [[ -n "${bundled[$p]:-}" ]]; then
            tag="bundled"
        else
            tag="(ignored)"; [[ "$glyph" == "+" ]] && tag="review (new, not bundled)"
        fi
        printf '  %s %-46s %s\n' "$glyph" "$p" "$tag"
    done
    for p in "${old_paths[@]}"; do
        [[ -n "${in_new[$p]:-}" ]] && continue
        if [[ -n "${bundled[$p]:-}" ]]; then
            printf '  %s %-46s %s\n' "-" "$p" "ACTION (bundled, removed upstream)"
        else
            printf '  %s %-46s %s\n' "-" "$p" "(ignored, removed upstream)"
        fi
    done
}

# reconcile_bundle_deps_url <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.
# In report mode: returns 2 when there are changes to apply, 0 otherwise (all
# current, no deps, fetch/parse fail) so a caller can skip the apply prompt.
# apply mode always returns 0.
reconcile_bundle_deps_url() {
    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_to=()
    local -a changed_name=() changed_oldver=() changed_newver=()
    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
        # Detect real drift by version, not URL string: the manifest and the
        # hint often carry the same version in different path shapes (bare-tag
        # archive vs SBo-fetched tree). Only a version change is a change.
        local hv mv
        hv=$(_url_version "${urls[$i]}")
        mv=$(_url_version "$m")
        if [[ -n "$mv" && "$hv" != "$mv" ]]; then
            changed_idx+=("$i"); changed_to+=("$m"); new_urls[$i]="$m"
            changed_name+=("$(_dep_label "${urls[$i]}")")
            changed_oldver+=("${hv:-?}"); changed_newver+=("$mv")
        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 && "$mode" == report ]]; then
        echo "$pkg: manifest has ${#extra[@]} deps not in hint:"
        printf '%s\n' "${extra[@]}"
    fi

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

    if [[ "$mode" == report ]]; then
        echo ""
        echo "$pkg bundled deps changed upstream:"
        for (( i=0; i<${#changed_idx[@]}; i++ )); do
            printf '  %s  %s  ->  %s\n' "${changed_name[$i]}" "${changed_oldver[$i]}" "${changed_newver[$i]}"
        done
        # report mode signals "there are changes to apply" via exit 2, so the
        # caller can skip the apply prompt when nothing changed. Every other
        # report exit (no change, no deps, fetch fail) returns 0.
        return 2
    fi

    # apply mode: recompute md5 only for changed lines, rewrite DOWNLOAD+MD5SUM.
    # Back up first (mkhint convention) — only reached when there is something
    # to write, since the no-change case already returned above.
    cp "$hint" "${hint}.bak"

    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=""
                if [[ "$(bundle_mode "$_bm_pkg")" == "url" ]]; then
                    _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_ver") || _bm_url=""
                fi
                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
}

# --info/-i: show a package's category/program path (green on a TTY) then its
# README, paged only when it doesn't fit the terminal.
show_info() {
    local pkg="$1"
    if [[ -z "$pkg" ]]; then
        echo "Error: --info requires a package name" >&2
        exit 1
    fi

    # Find the category dir(s): REPO_DIR/*/pkg/
    local -a matches=()
    local d
    for d in "${REPO_DIR%/}"/*/"$pkg"/; do
        [[ -d "$d" ]] && matches+=("$d")
    done
    if [[ ${#matches[@]} -eq 0 ]]; then
        echo "Error: package not found in ${REPO_DIR%/}: $pkg" >&2
        exit 2
    fi
    if [[ ${#matches[@]} -gt 1 ]]; then
        echo "Error: multiple matches for $pkg in ${REPO_DIR%/}:" >&2
        printf '  %s\n' "${matches[@]}" >&2
        exit 2
    fi

    local dir="${matches[0]%/}"
    local category; category=$(basename "$(dirname "$dir")")

    # Green header on a TTY (same gate as --list). Yellow for the equal case.
    local g_on="" g_off="" y_on="" y_off=""
    if [[ -n "$MKHINT_FORCE_COLOR" || -t 1 ]]; then
        if command -v tput &>/dev/null && tput setaf 2 &>/dev/null; then
            g_on=$(tput setaf 2); g_off=$(tput sgr0)
            y_on=$(tput setaf 3); y_off=$(tput sgr0)
        else
            g_on=$'\033[32m'; g_off=$'\033[0m'
            y_on=$'\033[33m'; y_off=$'\033[0m'
        fi
    fi
    local header="${g_on}${category}/${pkg}${g_off}"

    # Version-compare row (body line 1, scrolls with README). Skip if the
    # .info has no VERSION. Reuses _normalize_version + sort -V like --list.
    local vrow=""
    local sbo_ver=""
    [[ -f "${dir}/${pkg}.info" ]] && \
        sbo_ver=$(grep "^VERSION" "${dir}/${pkg}.info" | cut -d '"' -f2)
    if [[ -n "$sbo_ver" ]]; then
        local hint_ver=""
        [[ -f "${HINT_DIR%/}/${pkg}.hint" ]] && \
            hint_ver=$(grep "^VERSION" "${HINT_DIR%/}/${pkg}.hint" | cut -d '"' -f2)
        if [[ -z "$hint_ver" ]]; then
            vrow="SBo: ${sbo_ver}  (no hint)"
        else
            local sn hn
            sn=$(_normalize_version "$sbo_ver")
            hn=$(_normalize_version "$hint_ver")
            if [[ "$sn" == "$hn" ]]; then
                vrow="${y_on}SBo: ${sbo_ver} = Hint: ${hint_ver}${y_off}"
            else
                local newer; newer=$(printf '%s\n%s\n' "$sn" "$hn" | sort -V | tail -1)
                if [[ "$newer" == "$hn" ]]; then
                    vrow="SBo: ${sbo_ver} < Hint: ${g_on}${hint_ver}${g_off}"
                else
                    vrow="SBo: ${g_on}${sbo_ver}${g_off} > Hint: ${hint_ver}"
                fi
            fi
        fi
    fi

    local readme="${dir}/README"
    if [[ ! -f "$readme" ]]; then
        printf '%s\n' "$header"
        [[ -n "$vrow" ]] && printf '%s\n' "$vrow"
        echo "(no README)"
        exit 0
    fi

    # Non-TTY, or README fits: print inline. Else page with header pinned.
    local rows; rows="${LINES:-}"
    [[ -z "$rows" ]] && command -v tput &>/dev/null && rows=$(tput lines 2>/dev/null)
    [[ -z "$rows" ]] && rows=24
    local lines; lines=$(wc -l < "$readme")

    if [[ ! -t 1 || "$lines" -le "$rows" ]]; then
        printf '%s\n' "$header"
        [[ -n "$vrow" ]] && printf '%s\n' "$vrow"
        cat "$readme"
        exit 0
    fi

    # Page: header as line 1 so it survives; pin it if pager is less. The
    # version row is body line 1 so it scrolls with the README.
    local pager="${PAGER:-less}"
    if [[ "$(basename "$pager")" == "less" ]]; then
        { printf '%s\n' "$header"; [[ -n "$vrow" ]] && printf '%s\n' "$vrow"; cat "$readme"; } | less -R --header=1
    else
        { printf '%s\n' "$header"; [[ -n "$vrow" ]] && printf '%s\n' "$vrow"; cat "$readme"; } | $pager
    fi
    exit 0
}

# 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"
}

# Print the [pkg] section (header + body) from NVCHECKER_CONFIG: from the label
# line until the next line starting with '[' or EOF. Empty if not found.
_extract_nvchecker_section() {
    local pkg="$1"
    [[ -f "$NVCHECKER_CONFIG" ]] || return 0
    local label; label=$(_nvchecker_label "$pkg")
    awk -v lbl="$label" '
        $0==lbl { grab=1; print; next }
        grab && /^\[/ { exit }
        grab { print }
    ' "$NVCHECKER_CONFIG"
}

# Extract a package name from a language-registry download URL.
# Args: <url> <prgnam-fallback>. Prints the parsed name, or the fallback
# (PRGNAM) when no host-specific pattern matches.
_registry_name_from_url() {
    local url="$1" fallback="$2"
    case "$url" in
        *crates.io/api/v1/crates/*)
            # .../crates/NAME/NAME-VER.crate  or  .../crates/NAME/VER/download
            printf '%s' "${url#*crates.io/api/v1/crates/}" | cut -d/ -f1
            return 0 ;;
        *rubygems.org/downloads/*)
            # /downloads/NAME-VER.gem
            local base="${url##*/downloads/}"; base="${base%.gem}"
            printf '%s' "${base%-*}"
            return 0 ;;
        *registry.npmjs.org/*)
            # /NAME/-/NAME-VER.tgz   (NAME may be @scope/pkg)
            printf '%s' "${url#*registry.npmjs.org/}" | sed 's|/-/.*||'
            return 0 ;;
        *hackage.haskell.org/package/*)
            printf '%s' "${url#*hackage.haskell.org/package/}" | cut -d/ -f1
            return 0 ;;
    esac
    printf '%s' "$fallback"
}

# Detect an nvchecker source from a package's DOWNLOAD/HOMEPAGE haystack.
# Args: <haystack> <prgnam>. Prints the TOML body (no [label] header). Empty
# output means "unrecognised", the caller emits the commented stub.
_detect_nvchecker_source() {
    local haystack="$1" pkg="$2"

    # ── owner/repo forges ─────────────────────────────────────────────────
    if [[ "$haystack" =~ (github\.com|gitlab\.com|bitbucket\.org|gitea\.com|codeberg\.org|pagure\.io)/([A-Za-z0-9._-]+)(/([A-Za-z0-9._-]+))? ]]; then
        local host="${BASH_REMATCH[1]}"
        local owner="${BASH_REMATCH[2]}"
        local repo="${BASH_REMATCH[4]}"
        repo="${repo%.git}"; owner="${owner%.git}"
        case "$host" in
            github.com)
                printf 'source = "github"\ngithub = "%s/%s"\nuse_latest_release = true\n# use_max_tag = true  # if the repo publishes no Releases\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner" "$repo"
                return 0 ;;
            gitlab.com)
                printf 'source = "gitlab"\ngitlab = "%s/%s"\nuse_max_tag = true\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner" "$repo"
                return 0 ;;
            bitbucket.org)
                printf 'source = "bitbucket"\nbitbucket = "%s/%s"\nuse_max_tag = true\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner" "$repo"
                return 0 ;;
            gitea.com)
                printf 'source = "gitea"\ngitea = "%s/%s"\nuse_max_tag = true\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner" "$repo"
                return 0 ;;
            codeberg.org)
                printf 'source = "gitea"\ngitea = "%s/%s"\nhost = "codeberg.org"\nuse_max_tag = true\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner" "$repo"
                return 0 ;;
            pagure.io)
                # pagure field is the repo only, on pagure.io the first path
                # segment IS the repo (no owner), so it lands in $owner.
                printf 'source = "pagure"\npagure = "%s"\nuse_max_tag = true\n# prefix = "v"  # uncomment if tags are v-prefixed\n' "$owner"
                return 0 ;;
        esac
    fi

    # ── language registries ───────────────────────────────────────────────
    local url; url=$(printf '%s' "$haystack" | grep -oE 'https?://[^ ]+' | head -1)
    local name
    case "$haystack" in
        *pypi.org*|*files.pythonhosted.org*)
            printf 'source = "pypi"\npypi = "%s"\n' "$pkg"; return 0 ;;
        *registry.npmjs.org*|*npmjs.com*)
            name=$(_registry_name_from_url "$url" "$pkg")
            printf 'source = "npm"\nnpm = "%s"\n' "$name"; return 0 ;;
        *rubygems.org*)
            name=$(_registry_name_from_url "$url" "$pkg")
            printf 'source = "gems"\ngems = "%s"\n' "$name"; return 0 ;;
        *crates.io*)
            name=$(_registry_name_from_url "$url" "$pkg")
            printf 'source = "cratesio"\ncratesio = "%s"\n' "$name"; return 0 ;;
        *metacpan.org*|*cpan.org*)
            printf 'source = "cpan"\ncpan = "%s"\n' "$pkg"; return 0 ;;
        *hackage.haskell.org*)
            name=$(_registry_name_from_url "$url" "$pkg")
            printf 'source = "hackage"\nhackage = "%s"\n' "$name"; return 0 ;;
        *packagist.org*)
            printf 'source = "packagist"\npackagist = "%s"\n' "$pkg"; return 0 ;;
        *cran.r-project.org*)
            printf 'source = "cran"\ncran = "%s"\n' "$pkg"; return 0 ;;
    esac

    return 0   # empty output → caller stubs
}

# 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")

    # Already present: dump the existing section so the user sees what's set.
    if _has_nvchecker_section "$pkg"; then
        echo "nvchecker: ${label} already present in $NVCHECKER_CONFIG:"
        echo "────────────────────────────"
        _extract_nvchecker_section "$pkg"
        echo "────────────────────────────"
        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 body; body=$(_detect_nvchecker_source "$haystack" "$pkg")
    local section
    if [[ -n "$body" ]]; then
        section=$(printf '\n%s\n%s' "$label" "$body")
    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: added ${label} section to $NVCHECKER_CONFIG:"
    echo "────────────────────────────"
    printf '%s\n' "$label"
    if [[ -n "$body" ]]; then
        printf '%s\n' "$body"
    else
        echo "# TODO: configure nvchecker source for \"${pkg}\""
    fi
    echo "────────────────────────────"
}

# 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 <file> [skip_continuation_prompt]
# skip_continuation_prompt=1 leaves continuation (index >= 1) URLs untouched and
# does not prompt for them — used for manifest-listed packages, whose extra
# lines Phase 2 (reconcile_bundle_deps) rewrites from the upstream manifest, so
# hand-editing them here would be a pointless double-touch.
update_checksums() {
    local file="$1"
    local skip_cont="${2:-0}"

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

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

    # 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. Skipped for
    # manifest-listed packages: Phase 2 (reconcile_bundle_deps) owns those lines.
    if (( ${#urls[@]} > 1 )); then
        if [[ "$skip_cont" == 1 ]]; then
            echo ""
            echo "Multiline ${dl_var} in $(basename "$file"): continuation URLs left to bundled-dep reconcile."
        else
            echo ""
            echo "Multiline ${dl_var} detected in $(basename "$file")."
            prompt_continuation_urls urls "$dl_var"
        fi
    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

    # For manifest-listed packages, skip the continuation-URL prompt — Phase 2
    # (reconcile_bundle_deps) rewrites those lines from the upstream manifest.
    load_bundle_manifests
    local _pkg="${normalized_file%.hint}"; _pkg="${_pkg##*/}"
    local _skip_cont=0
    if pkg_has_manifest "$_pkg"; then _skip_cont=1; fi

    update_checksums "$normalized_file" "$_skip_cont"

    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
    local -A prev_version=()   # pkg -> version before this run's bump (Job B)

    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")
        prev_version["$pkg"]="$current"   # pre-bump version for Job B
    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=""
        if [[ "$(bundle_mode "$pkg")" == "url" ]]; then
            murl=$(manifest_url_for "$pkg" "$cur") || continue
        fi
        echo ""
        echo "Bundled-dep reconcile: $pkg (manifest @ $cur)"
        local rc=0
        reconcile_bundle_deps "$pkg" "$hintpath" "$murl" report || rc=$?
        # rc==2 means the report found changes to apply; anything else (0, or a
        # real error) means nothing to prompt about.
        if [[ $rc -eq 2 ]]; then
            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
        fi
        # Job B: submodule inventory / set-drift roster (sha-mode only).
        if [[ "$(bundle_mode "$pkg")" == "sha" ]]; then
            local _old_ver="${prev_version[$pkg]:-$cur}" _new_ver="$cur"
            detect_set_drift "$pkg" "$_old_ver" "$_new_ver" || true
        fi
    done
}

# Main function
main() {
    local parsed
    parsed=$(getopt -o vV:f:n:i:lcCdNhRF \
        --long version,set-version:,hintfile:,new:,info:,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
                ;;
            --info|-i)
                COMMAND="info"
                INFO_PKG="$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 [[ "$COMMAND" == "info" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then
        echo "Error: --info 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
            ;;
        info)
            show_info "$INFO_PKG"
            ;;
        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=""
                if [[ "$(bundle_mode "$_bm_pkg")" == "url" ]]; then
                    _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_cur") || _bm_url=""
                fi
                echo ""
                echo "Bundled-dep reconcile: $_bm_pkg (manifest @ $_bm_cur)"
                local _bm_rc=0
                reconcile_bundle_deps "$_bm_pkg" "$_bm_hint" "$_bm_url" report || _bm_rc=$?
                if [[ $_bm_rc -eq 2 ]]; then
                    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
            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
    [[ -n "${MKHINT_NOMAIN:-}" ]] || main "$@"
fi
