#!/bin/bash
#
# sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0
# overlay chroot. Non-interactive, dependency-resolving, per-target disposable
# overlay, persistent logs, color summary.
#
# Some parts of this script are inspired by overlay-chroot.sh by Slackware user
# bassmadrigal (Jeremy Hansen): specifically the overlayfs setup, the system
# bind mounts (dev, proc, sys, dev/pts, resolv.conf, dbus machine-id), the base
# patching from the local mirror, and the teardown ordering. That teardown order
# (pts, dev/proc/sys, resolv.conf, dbus machine-id, overlay last) is preserved
# deliberately, it is the correct unwind order.
#
# Runs INSIDE a Slackware64-current VM. Verifies SlackBuilds BUILD and install
# cleanly against 15.0 userland. Does NOT test kernel modules (shares host
# kernel). Resolution is LOCAL-tree-only, never network. Does not touch
# slackrepo.
#
# No em dashes in prose by author convention.

# =============================================================================
# CONFIG
# =============================================================================
#
# Do NOT edit values here. Real config lives in an external file, sourced below.
# Default path: ~/.config/sbo-batch-tester/config (override with $SBO_BATCH_CONFIG).
# Copy config.example from the repo and edit it. Runs as root, so ~ is root's
# home (/root/.config/sbo-batch-tester/config).
#
# Config file sets these (see config.example for docs on each):
#   SLACKWARE_BASE LOCAL_MIRROR_15 SBO_TREE_ROOTS CHROOT_LOCATION LOG_ROOT VERSION

# Defaults. The config file overrides any it sets. These are placeholders, a run
# fails fast in validate_env / init_base if the config has not set real paths.
SLACKWARE_BASE=""
LOCAL_MIRROR_15=""
SBO_TREE_ROOTS=()
CHROOT_LOCATION="/tmp"
LOG_ROOT="/var/log/sbo-batch-test"

# Persistent package cache (local disk). Built dependency packages are stored
# here and reused across runs while their version is unchanged. Empty disables
# the cache (every package builds fresh). Wiped automatically when the base is
# patched. Layout mirrors the SBo tree: <cat>/<prog>/<prog>-<ver>-...txz
PKG_CACHE=""

VERSION="15.0"

# rsync source for the SBo tree (same source sbopkg uses). --setup-repo rsyncs
# it fresh; --update rsyncs with --delete (personal subtrees excluded) to
# unshadow. Empty defaults; real value in the external config.
SBO_RSYNC_URL=""

# Personal SBo subtrees: dir name (under SBO_TREE_ROOTS[0]) -> git clone URL.
# declare -A here so the external config's assignment lands as a global assoc
# array. --setup-repo clones each; --update git-pulls each; both then shadow.
declare -A SBO_PERSONAL_TREES=()

# Source the external config if present. Not an error here if missing (keeps the
# script sourceable by test-logic.sh); validate_env / init_base report it.
SBO_BATCH_CONFIG="${SBO_BATCH_CONFIG:-$HOME/.config/sbo-batch-tester/config}"
if [[ -f "$SBO_BATCH_CONFIG" ]]; then
  # shellcheck disable=SC1090
  source "$SBO_BATCH_CONFIG"
fi

# Derived: the actual Slackware tree under the mirror mountpoint. Holds
# ChangeLog.txt, slackware64/, and patches/. Set after sourcing so it picks up
# the config's LOCAL_MIRROR_15 and VERSION.
MIRROR_TREE="$LOCAL_MIRROR_15/slackware64-$VERSION"

# =============================================================================
# END CONFIG
# =============================================================================

set -uo pipefail
# Note: NOT using -e globally. One package build failing is an expected,
# handled outcome, not a script crash. Per-package execution is isolated.

# ---- flags / globals --------------------------------------------------------
USE_COLOR=1     # --no-color or non-TTY disables
DRY_RUN=0       # resolve + print build order, do not build
WITH_X=0        # --with-x: optional X passthrough (default headless)
JOBS=1          # -j: reserved, see TODO
INIT_BASE=0     # --init-base: populate SLACKWARE_BASE from the mirror, then exit
SETUP_REPO=0    # --setup-repo: rm+rsync tree, clone subtrees, shadow, then exit
DO_UPDATE=0     # --update: rsync-unshadow + pull subtrees + shadow before build
TARGET_ARG=""

RUN_DIR=""              # timestamped log dir for this run
declare -a ACTIVE_MOUNTS=()   # overlay chroot roots currently mounted (for trap unwind)
CURRENT_OVERLAY=""            # the $TMPDIR of the overlay being built in now

# Status tracking. Keyed by "category/prog". Parallel assoc arrays.
declare -A ST_STATUS=()   # SUCCESS|BUILD-FAILED|DOWNLOAD-FAILED|MD5-MISMATCH|INSTALL-FAILED|BLOCKED-BY-DEP|UNMET-DEP
declare -A ST_REASON=()
declare -A ST_TIME=()     # elapsed seconds
declare -A ST_README=()   # 1 if package carries %README%

# =============================================================================
# usage
# =============================================================================
usage() {
  cat <<'EOF'
sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0 overlay chroot

USAGE:
  sbo-batch-test [OPTIONS] <program-name>
  sbo-batch-test [OPTIONS] <category-folder>

MODES:
  <program-name>     Resolve its full SBo dependency tree, build+install every
                     dep in topological order, then build+install the target.
  <category-folder>  Treat every SlackBuild dir inside as an independent target.
                     Each target gets its OWN fresh overlay against pristine 15.0.

OPTIONS:
  -h, --help       This text.
  --no-color       Disable ANSI color (auto-disabled when stdout is not a TTY).
  --dry-run        Resolve and print the build order, do not build.
  --with-x         Enable X passthrough via xhost +local:hosts (security caveat:
                   allows local non-network connections to your X server).
                   Default is headless (no X).
  -j, --jobs N     Reserved. Currently a no-op stub (builds are serial).
  --init-base      First-time populate SLACKWARE_BASE with the FULL 15.0
                   package set from the mirror, then exit. Run once before the
                   first build. Refuses to clobber an existing base.
  --setup-repo     First-time build of the SBo tree: REMOVE SBO_TREE_ROOTS[0],
                   rsync it fresh from SBO_RSYNC_URL, clone the SBO_PERSONAL_TREES
                   subtrees in, shadow duplicated upstream packages, then exit.
                   Does not build. Runs as root.
  --update         Before building: rsync the tree (--delete, personal subtrees
                   preserved) to unshadow and refresh upstream, git-pull each
                   personal subtree, re-shadow duplicated upstream packages.

EXTENSION POINTS (not implemented, see TODOs in source):
  - "all" mode (build every package across all SBo roots).
  - queue/list-file mode (build a named list of targets).
  - -j parallelism.

EOF
}

# =============================================================================
# color helpers
# =============================================================================
init_color() {
  if [[ $USE_COLOR -eq 1 && -t 1 ]]; then
    C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_RST=$'\e[0m'
  else
    C_RED=""; C_GRN=""; C_YEL=""; C_RST=""
  fi
}

# =============================================================================
# arg parsing
# =============================================================================
parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) usage; exit 0 ;;
      --no-color) USE_COLOR=0; shift ;;
      --dry-run) DRY_RUN=1; shift ;;
      --with-x) WITH_X=1; shift ;;
      -j|--jobs) JOBS="${2:-1}"; shift 2 ;;  # TODO: implement parallelism
      --init-base) INIT_BASE=1; shift ;;
      --setup-repo) SETUP_REPO=1; shift ;;
      --update) DO_UPDATE=1; shift ;;
      -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
      *)
        if [[ -n "$TARGET_ARG" ]]; then
          echo "Only one target argument accepted (got '$TARGET_ARG' and '$1')." >&2
          exit 2
        fi
        TARGET_ARG="$1"; shift ;;
    esac
  done
  if [[ -z "$TARGET_ARG" && $INIT_BASE -eq 0 && $SETUP_REPO -eq 0 ]]; then
    echo "No target given." >&2; usage >&2; exit 2
  fi
}

# =============================================================================
# startup validation (fail fast, copy-pasteable hints)
# =============================================================================

# Shared: the config file must exist and set the required paths. Called by both
# validate_env and init_base.
require_config() {
  if [[ ! -f "$SBO_BATCH_CONFIG" ]]; then
    cat >&2 <<EOF
No config file: $SBO_BATCH_CONFIG
Copy the example and edit it (paths run as root, so this is /root/.config/...):

  mkdir -p "\$(dirname "$SBO_BATCH_CONFIG")"
  cp config.example "$SBO_BATCH_CONFIG"
  \${EDITOR:-vi} "$SBO_BATCH_CONFIG"
EOF
    exit 1
  fi
  if [[ -z "$SLACKWARE_BASE" || -z "$LOCAL_MIRROR_15" || ${#SBO_TREE_ROOTS[@]} -eq 0 ]]; then
    echo "Config $SBO_BATCH_CONFIG is missing SLACKWARE_BASE, LOCAL_MIRROR_15, or SBO_TREE_ROOTS." >&2
    exit 1
  fi
}

validate_env() {
  if [[ $EUID -ne 0 ]]; then
    echo "This tool must run as root (overlay + chroot)." >&2
    exit 1
  fi
  require_config

  # SLACKWARE_BASE: local, looks like a real Slackware install.
  if [[ ! -d "$SLACKWARE_BASE" || ! -d "$SLACKWARE_BASE/var/log/packages" ]]; then
    cat >&2 <<EOF
SLACKWARE_BASE is missing or incomplete: $SLACKWARE_BASE
It must be a LOCAL (non-NFS) full Slackware 15.0 install tree.
Populate it from the mirror with the FULL package set (not minimal,
a minimal base causes false "missing dependency" results):

  $0 --init-base
EOF
    exit 1
  fi
  # Guard against the classic mistake: base sitting on the NFS mirror path.
  case "$SLACKWARE_BASE" in
    "$LOCAL_MIRROR_15"*)
      echo "SLACKWARE_BASE must NOT live under LOCAL_MIRROR_15 (NFS). overlayfs over NFS lowerdir is unsupported here." >&2
      exit 1 ;;
  esac

  # LOCAL_MIRROR_15: NFS, fstab entry is noauto. Auto-mount if not mounted
  # (needs the fstab entry for bare `mount <path>`). Left mounted after the run.
  if ! mountpoint -q "$LOCAL_MIRROR_15"; then
    echo "LOCAL_MIRROR_15 not mounted, attempting: mount $LOCAL_MIRROR_15" >&2
    if ! mount "$LOCAL_MIRROR_15"; then
      echo "Could not mount $LOCAL_MIRROR_15 (no fstab entry? mount it manually)." >&2
      exit 1
    fi
  fi
  if [[ ! -f "$MIRROR_TREE/ChangeLog.txt" ]]; then
    echo "Mirror mounted but no ChangeLog.txt under: $MIRROR_TREE" >&2
    echo "Expected the Slackware 15.0 tree at \$LOCAL_MIRROR_15/slackware64-\$VERSION." >&2
    exit 1
  fi

  # SBo roots exist locally.
  local r found=0
  for r in "${SBO_TREE_ROOTS[@]}"; do
    if [[ -d "$r" ]]; then found=1; else
      echo "SBO_TREE_ROOTS path does not exist: $r" >&2
    fi
  done
  if [[ $found -eq 0 ]]; then
    echo "No valid SBo tree root found. Fix SBO_TREE_ROOTS." >&2
    exit 1
  fi
}

# =============================================================================
# keep base patched from the mirror (reuse reference logic, point at NFS mirror,
# write to local base). Skipped on --dry-run.
# =============================================================================
# =============================================================================
# first-time base populate. --init-base only. Installs the FULL 15.0 package
# set from the mirror into SLACKWARE_BASE, then exits (caller does not build).
# Refuses to clobber an already-populated base.
# =============================================================================
init_base() {
  if [[ $EUID -ne 0 ]]; then
    echo "--init-base must run as root (installpkg --root)." >&2; exit 1
  fi
  require_config
  case "$SLACKWARE_BASE" in
    "$LOCAL_MIRROR_15"*)
      echo "SLACKWARE_BASE must NOT live under LOCAL_MIRROR_15 (NFS)." >&2; exit 1 ;;
  esac
  if ! mountpoint -q "$LOCAL_MIRROR_15"; then
    echo "LOCAL_MIRROR_15 not mounted, attempting: mount $LOCAL_MIRROR_15" >&2
    if ! mount "$LOCAL_MIRROR_15"; then
      echo "Could not mount $LOCAL_MIRROR_15 (no fstab entry? mount it manually)." >&2; exit 1
    fi
  fi
  if [[ ! -f "$MIRROR_TREE/ChangeLog.txt" ]]; then
    echo "Mirror mounted but no ChangeLog.txt under: $MIRROR_TREE" >&2; exit 1
  fi
  if [[ -d "$SLACKWARE_BASE/var/log/packages" ]]; then
    echo "Base already populated: $SLACKWARE_BASE" >&2
    echo "Remove it first to re-init, or just run a build (update_base keeps it patched)." >&2
    exit 1
  fi
  echo "Populating base from mirror into: $SLACKWARE_BASE"
  mkdir -p "$SLACKWARE_BASE"
  local p n=0
  for p in "$MIRROR_TREE"/slackware64/*/*.t?z; do
    [[ -e "$p" ]] || continue
    installpkg --terse --root "$SLACKWARE_BASE" "$p"
    ((n++)); ((n % 50 == 0)) && echo "  ...$n packages"
  done
  if [[ $n -eq 0 ]]; then
    echo "No packages found under $MIRROR_TREE/slackware64/*/. Wrong mirror layout?" >&2
    exit 1
  fi
  # Seed the update marker so the next run does not re-patch needlessly.
  head -n1 "$MIRROR_TREE/ChangeLog.txt" > "$SLACKWARE_BASE/last-base-update"
  echo "Base populated: $n packages installed into $SLACKWARE_BASE"
}

update_base() {
  local marker="$SLACKWARE_BASE/last-base-update"
  touch "$marker"
  local head_now; head_now="$(head -n1 "$MIRROR_TREE/ChangeLog.txt")"
  if [[ "$head_now" == "$(cat "$marker")" ]]; then
    echo "Base is up-to-date with the mirror."
    return
  fi
  echo "Patching base from mirror..."
  local p
  for p in "$MIRROR_TREE"/patches/packages/*.t?z; do
    [[ -e "$p" ]] || continue
    if [[ ! -e "$SLACKWARE_BASE/var/lib/pkgtools/packages/$(basename "${p%.*}")" ]]; then
      ROOT="$SLACKWARE_BASE" upgradepkg --terse --install-new "$p"
    fi
  done
  echo "$head_now" > "$marker"
  # Base changed: cached deps were built against the old base, drop them all.
  if [[ -n "$PKG_CACHE" && -d "$PKG_CACHE" ]]; then
    rm -rf "${PKG_CACHE:?}"/*
    echo "Package cache cleared (base changed)."
  fi
  echo "Base patched."
}

# Echo one anchored `--exclude=/<dir>/` per SBO_PERSONAL_TREES key, one per
# line. Anchored to the tree root so rsync --delete never wipes a personal
# clone (they are not on the rsync source). Empty when no personal trees.
rsync_excludes() {
  local dir
  for dir in "${!SBO_PERSONAL_TREES[@]}"; do
    echo "--exclude=/$dir/"
  done
}

# rsync the SBo tree from SBO_RSYNC_URL into <root>, excluding every personal
# subtree from transfer AND deletion. Flags match sbopkg's own rsync (files
# root:root). Returns rsync's exit status; caller decides hard-fail vs warn.
rsync_tree() {
  local root="$1"
  local excludes=()
  mapfile -t excludes < <(rsync_excludes)
  rsync -a --delete --no-owner --no-group \
    "${excludes[@]}" \
    "$SBO_RSYNC_URL/" "$root/"
}

# Echo (one per line) the <root>-relative dirs of upstream packages that
# duplicate a package in a personal subtree. Reads only, never removes. A
# personal package is a subdir with a matching <name>.SlackBuild; an upstream
# dup is a same-named depth-2 dir anywhere under <root> outside the personal
# trees.
collect_shadows() {
  local root="$1"
  local -a personal_dirs=() prune=()
  local dir
  for dir in "${!SBO_PERSONAL_TREES[@]}"; do
    [[ -d "$root/$dir" ]] || continue
    personal_dirs+=("$root/$dir")
    prune+=(! -path "$root/$dir/*")
  done
  [[ ${#personal_dirs[@]} -eq 0 ]] && return
  local pkgdir pkg match
  while IFS= read -r pkgdir; do
    pkg=$(basename "$pkgdir")
    [[ -f "$pkgdir/$pkg.SlackBuild" ]] || continue
    while IFS= read -r match; do
      echo "${match#"$root"/}"
    done < <(find "$root" -mindepth 2 -maxdepth 2 -type d \
        -name "$pkg" "${prune[@]}")
  done < <(find "${personal_dirs[@]}" -mindepth 1 -maxdepth 1 -type d)
}

# Show the upstream dups of personal packages, confirm, and rm -r them. Reads
# the set from collect_shadows. Interactive by design (the user wants to see
# removals before they happen). No git (the 15.0 tree is rsync'd, not git).
shadow_scan() {
  local root="$1"
  local -a to_shadow=()
  mapfile -t to_shadow < <(collect_shadows "$root")
  if [[ ${#to_shadow[@]} -eq 0 ]]; then
    echo "No upstream packages match your personal subtrees. Nothing to shadow."
    return
  fi
  echo
  echo "The following upstream packages will be shadowed (rm -r):"
  local item
  for item in "${to_shadow[@]}"; do
    echo "  - $item"
  done
  echo
  read -rp "Proceed? [y/N] " confirm
  if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
    echo "Aborted shadowing; no packages were removed."
    return
  fi
  for item in "${to_shadow[@]}"; do
    echo "Shadowing: $item"
    rm -r "${root:?}/$item"
  done
}

# --setup-repo: first-time full build of the SBo tree. rm -rf the tree root,
# rsync it fresh, clone the personal subtrees in, shadow upstream dups. Runs its
# own checks (the tree may not exist yet). Standalone: exits, never builds.
setup_repo() {
  [[ $EUID -eq 0 ]] || { echo "--setup-repo must run as root (rsync/rm to the tree)." >&2; exit 1; }
  require_config
  [[ -n "$SBO_RSYNC_URL" ]] || { echo "SBO_RSYNC_URL is unset; set it in $SBO_BATCH_CONFIG." >&2; exit 1; }
  local root="${SBO_TREE_ROOTS[0]}"
  [[ -n "$root" ]] || { echo "SBO_TREE_ROOTS[0] is empty." >&2; exit 1; }

  echo "This will REMOVE $root and rsync it fresh from $SBO_RSYNC_URL."
  read -rp "Proceed? [y/N] " confirm
  [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }

  rm -rf "${root:?}"
  echo "Rsyncing tree..."
  rsync_tree "$root" || { echo "rsync failed." >&2; exit 1; }

  local dir
  for dir in "${!SBO_PERSONAL_TREES[@]}"; do
    echo "Cloning $dir..."
    git clone "${SBO_PERSONAL_TREES[$dir]}" "$root/$dir" \
      || { echo "clone of $dir failed." >&2; exit 1; }
  done

  shadow_scan "$root"
  echo "Repository ready at $root"
  exit 0
}

# --update: per-run refresh. rsync the tree (--delete, personal excluded) to
# unshadow + refresh upstream, git-pull each personal subtree, re-shadow. Inline
# and fail-soft: a network hiccup warns but never kills the build session.
update_personal_trees() {
  local root="${SBO_TREE_ROOTS[0]}"
  [[ -n "$root" ]] || { echo "SBO_TREE_ROOTS[0] is empty; skipping --update." >&2; return; }

  if [[ -n "$SBO_RSYNC_URL" ]]; then
    echo "Refreshing tree (rsync, personal subtrees preserved)..."
    rsync_tree "$root" || echo "WARN: rsync failed; continuing." >&2
  else
    echo "SBO_RSYNC_URL unset; skipping tree rsync (no unshadow)." >&2
  fi

  local dir d
  for dir in "${!SBO_PERSONAL_TREES[@]}"; do
    d="$root/$dir"
    if [[ ! -d "$d/.git" ]]; then
      echo "WARN: $d is not a git clone, skipping pull." >&2
      continue
    fi
    echo "Pulling $dir..."
    git -C "$d" pull || echo "WARN: git pull failed in $dir; continuing." >&2
  done

  shadow_scan "$root"
}

# =============================================================================
# Package cache. Persistent, on-disk store of built packages so unchanged deps
# are installed from cache instead of rebuilt. Layout mirrors the SBo tree:
#   $PKG_CACHE/<cat>/<prog>/<prog>-<ver>-<arch>-<build>_<tag>.txz
# One .txz per prog dir (the latest tested build). Key is prog+version; build
# number, arch, and tag do not affect a hit. Empty PKG_CACHE disables caching.
# =============================================================================

# Echo the version field of a cached package filename. <file> is a basename like
# prog-1.2-x86_64-1_danix.txz; prog may itself contain dashes, so strip the known
# prog prefix first, then take the field up to the next dash.
_cache_ver_of() {
  local prog="$1" base="$2"
  base="${base#"$prog"-}"     # drop "prog-"
  echo "${base%%-*}"          # version is up to the next dash
}

# cache_decision <cat> <prog> <version> -> echoes: cached | bump:OLD:NEW | new
cache_decision() {
  local cat="$1" prog="$2" version="$3"
  [[ -z "$PKG_CACHE" ]] && { echo new; return; }
  local dir="$PKG_CACHE/$cat/$prog"
  local f newest=""
  for f in "$dir/$prog"-*.t?z; do
    [[ -e "$f" ]] || continue
    [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f"
  done
  [[ -z "$newest" ]] && { echo new; return; }
  local have; have="$(_cache_ver_of "$prog" "$(basename "$newest")")"
  if [[ "$have" == "$version" ]]; then
    echo cached
  else
    echo "bump:$have:$version"
  fi
}

# cache_path <cat> <prog> <version> -> echoes the cached .txz path for that
# version, or nothing. Used to installpkg a cached dep.
cache_path() {
  local cat="$1" prog="$2" version="$3"
  [[ -z "$PKG_CACHE" ]] && return
  local dir="$PKG_CACHE/$cat/$prog"
  local f newest=""
  for f in "$dir/$prog"-*.t?z; do
    [[ -e "$f" ]] || continue
    [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f"
  done
  [[ -z "$newest" ]] && return
  [[ "$(_cache_ver_of "$prog" "$(basename "$newest")")" == "$version" ]] && echo "$newest"
}

# lint_pkg <txz> <logf> -> run sbopkglint on a built package as a final check.
# Host-side, target only (called from build_one's success branch while the
# overlay is still mounted). Fail-soft: skip with a note if the tool is absent,
# and never affect the build's SUCCESS status. Findings go to screen + the
# package log. ponytail: no toggle flag, run iff installed.
lint_pkg() {
  local txz="$1" logf="$2"
  if ! command -v sbopkglint >/dev/null 2>&1; then
    echo "  sbopkglint not installed, skipping lint (system/sbo-maintainer-tools)"
    return 0
  fi
  echo "  sbopkglint $(basename "$txz") ..."
  echo "===== sbopkglint: $(basename "$txz") =====" >> "$logf"
  local out rc
  out="$(sbopkglint "$txz" 2>&1)"; rc=$?
  printf '%s\n' "$out" >> "$logf"
  if [[ $rc -eq 0 ]]; then
    echo "  lint: ${C_GRN}clean${C_RST}"
  else
    # Findings: surface on screen too (indented), not just the log, so they are
    # easy to catch. Full copy is in the log.
    echo "  lint: ${C_RED}findings${C_RST} (see $(basename "$logf")):"
    printf '%s\n' "$out" | sed 's/^/    /'
  fi
}

# cache_store <cat> <prog> <src-txz> -> clear the prog dir, copy src in.
cache_store() {
  local cat="$1" prog="$2" src="$3"
  [[ -z "$PKG_CACHE" ]] && return
  local dir="$PKG_CACHE/$cat/$prog"
  mkdir -p "$dir"
  rm -f "$dir"/*.t?z
  cp -a "$src" "$dir/"
}

# =============================================================================
# SBo tree lookup. Find the SlackBuild dir for a prog name across roots.
# Echoes the dir path, returns 0 if found, 1 otherwise.
# =============================================================================
find_slackbuild_dir() {
  local prog="$1" root d
  for root in "${SBO_TREE_ROOTS[@]}"; do
    [[ -d "$root" ]] || continue
    # <root>/<category>/<prog>
    for d in "$root"/*/"$prog"; do
      if [[ -d "$d" && -f "$d/$prog.info" ]]; then
        echo "$d"; return 0
      fi
    done
  done
  return 1
}

# Category of a SlackBuild dir = its parent dir name.
category_of() { basename "$(dirname "$1")"; }
# Key used in status maps and log filenames.
pkg_key() { echo "$(category_of "$1")/$(basename "$1")"; }

# Read REQUIRES from a .info, stripped. Echoes space-separated tokens.
read_requires() {
  local info="$1"
  # shellcheck disable=SC1090
  ( set +u; source "$info"; echo "${REQUIRES:-}" )
}

# Echo the VERSION field from a SlackBuild dir's .info (empty if unreadable).
# Used as the cache key's version component.
version_of() {
  local dir="$1"
  local info="$dir/$(basename "$dir").info"
  [[ -f "$info" ]] || return
  # .info lines look like: VERSION="1.2.3"
  local v; v="$(grep -m1 '^VERSION=' "$info" | cut -d'"' -f2)"
  echo "$v"
}

# =============================================================================
# DEPENDENCY RESOLUTION
# Builds a topological order over the local SBo tree.
#  - transitive REQUIRES
#  - %README% recorded, not built
#  - deps already in base (installed in chroot base) are satisfied, not built
#  - deps neither in SBo tree nor installed => UNMET-DEP
#  - cycles detected and reported, no infinite loop
#
# Outputs the order into the global array RESOLVED_ORDER (dir paths).
# Records unmet deps into UNMET (prog -> requiring pkg) and cycle errors.
# Returns 1 if any hard resolution failure (unmet dep, cycle) for the target set.
# =============================================================================
declare -a RESOLVED_ORDER=()
declare -A UNMET=()         # prog -> "needed by X"
declare -a CYCLES=()        # human-readable cycle descriptions
declare -A HAS_README=()    # dir -> 1 if its REQUIRES carried %README%

# Is a prog already installed in the base tree? (stock 15.0 or otherwise present)
installed_in_base() {
  local prog="$1"
  # Package db entries look like prog-version-arch-build[ tag]
  ls "$SLACKWARE_BASE"/var/log/packages/ 2>/dev/null \
    | grep -qE "^${prog}-[^-]+-[^-]+-[^-]+"
}

# DFS topo sort with cycle detection.
# visit_state: dir -> 0 visiting (on stack), 1 done
declare -A _vstate=()
_resolve_visit() {
  local dir="$1" parent="$2"
  local key; key="$(basename "$dir")"

  if [[ "${_vstate[$dir]:-}" == "1" ]]; then return 0; fi
  if [[ "${_vstate[$dir]:-}" == "0" ]]; then
    CYCLES+=("cycle involving $key (pulled in via $parent)")
    return 1
  fi
  _vstate["$dir"]=0

  local info="$dir/$(basename "$dir").info"
  local req tok depdir rc=0
  req="$(read_requires "$info")"
  for tok in $req; do
    if [[ "$tok" == "%README%" ]]; then
      HAS_README["$dir"]=1
      continue
    fi
    if depdir="$(find_slackbuild_dir "$tok")"; then
      _resolve_visit "$depdir" "$key" || rc=1
    elif installed_in_base "$tok"; then
      : # satisfied by base, nothing to build
    else
      UNMET["$tok"]="needed by $key"
      rc=1
    fi
  done

  _vstate["$dir"]=1
  RESOLVED_ORDER+=("$dir")
  return $rc
}

# Resolve a single target dir into RESOLVED_ORDER (deps first, target last).
resolve_target() {
  local dir="$1"
  RESOLVED_ORDER=()
  CYCLES=()
  UNMET=()
  _vstate=()
  _resolve_visit "$dir" "(top)"
}

# =============================================================================
# OVERLAY LIFECYCLE (per target). Patterns from overlay-chroot.sh.
# setup_overlay -> echoes the chroot root path, sets CURRENT_OVERLAY.
# teardown_overlay <tmpdir> -> idempotent unwind in correct reverse order.
# =============================================================================
setup_overlay() {
  local tmpdir; tmpdir="$(mktemp -d "$CHROOT_LOCATION"/sbo-bt.XXXXXX)"
  mkdir "$tmpdir"/{changes,tmp,chroot}

  # overlayfs: read-only 15.0 base as lowerdir, disposable upper on top.
  mount -t overlay overlay \
    -olowerdir="$SLACKWARE_BASE",upperdir="$tmpdir/changes",workdir="$tmpdir/tmp" \
    "$tmpdir/chroot"

  # bind system dirs
  mkdir -p "$tmpdir"/changes/{dev,proc,sys}
  local i
  for i in dev proc sys; do
    mount -o bind "/$i" "$tmpdir/chroot/$i"
  done
  # /dev/pts (sudo/pty)
  mkdir -p "$tmpdir"/changes/dev/pts
  mount -o bind /dev/pts "$tmpdir/chroot/dev/pts"
  # internet
  mount -o bind /etc/resolv.conf "$tmpdir/chroot/etc/resolv.conf"
  chroot "$tmpdir/chroot" /bin/bash -c "/usr/sbin/update-ca-certificates --fresh >/dev/null 2>&1" || true
  # dbus machine-id
  touch "$tmpdir/chroot/var/lib/dbus/machine-id"
  mount -o bind /var/lib/dbus/machine-id "$tmpdir/chroot/var/lib/dbus/machine-id"

  CURRENT_OVERLAY="$tmpdir"
  ACTIVE_MOUNTS+=("$tmpdir")
  echo "$tmpdir"
}

# Idempotent teardown. Safe to call twice, safe mid-abort.
teardown_overlay() {
  local tmpdir="$1"
  [[ -n "$tmpdir" && -d "$tmpdir" ]] || return 0
  local c="$tmpdir/chroot"

  mountpoint -q "$c/dev/pts" && umount "$c/dev/pts"
  local i
  for i in dev proc sys; do
    mountpoint -q "$c/$i" && umount "$c/$i"
  done
  mountpoint -q "$c/etc/resolv.conf" && umount "$c/etc/resolv.conf"
  mountpoint -q "$c/var/lib/dbus/machine-id" && umount "$c/var/lib/dbus/machine-id"
  mountpoint -q "$c" && umount "$c"

  # Remove from ACTIVE_MOUNTS
  local n=() m
  for m in "${ACTIVE_MOUNTS[@]}"; do [[ "$m" != "$tmpdir" ]] && n+=("$m"); done
  ACTIVE_MOUNTS=("${n[@]:-}")

  rm -rf "$tmpdir"
}

# Trap: unwind every live overlay on abort. Reverse order of creation.
cleanup_trap() {
  [[ $WITH_X -eq 1 ]] && xhost -local:hosts >/dev/null 2>&1 || true
  local i
  for (( i=${#ACTIVE_MOUNTS[@]}-1; i>=0; i-- )); do
    teardown_overlay "${ACTIVE_MOUNTS[$i]}"
  done
}
trap cleanup_trap EXIT INT TERM

# =============================================================================
# BUILD + INSTALL one package inside an existing overlay chroot.
# Args: <chroot-tmpdir> <slackbuild-dir>
# Sets ST_STATUS / ST_REASON / ST_TIME for the package key.
# Returns 0 on SUCCESS, 1 otherwise.
# =============================================================================
build_one() {
  local tmpdir="$1" dir="$2" is_target="${3:-0}"
  local c="$tmpdir/chroot"
  local prog cat key
  prog="$(basename "$dir")"; cat="$(category_of "$dir")"; key="$cat/$prog"
  local logf="$RUN_DIR/${cat}_${prog}.log"
  local start; start=$(date +%s)
  local version; version="$(version_of "$dir")"

  # %README% reminder carries through to summary
  [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1

  # Dependency with a version-matching cached package: installpkg it into the
  # overlay and skip the build entirely. The target never takes this path.
  if [[ "$is_target" != "1" ]]; then
    local cached; cached="$(cache_path "$cat" "$prog" "$version")"
    if [[ -n "$cached" ]]; then
      local cb; cb="$(basename "$cached")"
      local workroot="/sbo-work"
      mkdir -p "$c$workroot"
      cp -a "$cached" "$c$workroot/"
      {
        echo "===== sbo-batch-test: $prog (from cache) ====="
        echo "cached package: $cb"
      } >> "$logf"
      if chroot "$c" /bin/bash -c "installpkg --terse '$workroot/$cb'" >>"$logf" 2>&1; then
        ST_TIME["$key"]=$(( $(date +%s) - start ))
        ST_STATUS["$key"]="CACHED"
        return 0
      fi
      # Cache install failed: fall through and build fresh.
      # installpkg is not atomic; a partial cache install is fine here because the
      # overlay is disposable per target. Rebuild fresh below.
      echo "cache install failed, building fresh" >> "$logf"
    fi
  fi

  # 1. copy SlackBuild dir into the overlay
  local workroot="/sbo-work"
  mkdir -p "$c$workroot"
  cp -a "$dir" "$c$workroot/$prog"

  # 2-5. download, md5, build, install all run INSIDE the chroot non-interactively.
  # The heredoc script writes a status token to a known file we read back out.
  # overlayfs note: if a build fails ONLY here and works on bare 15.0, suspect
  # an overlayfs sharp edge (rename/whiteout quirks) rather than a real build bug.
  local statf="$c$workroot/$prog.status"
  chroot "$c" /bin/bash -s <<CHROOT_EOF >>"$logf" 2>&1
set -uo pipefail
cd "$workroot/$prog" || { echo "BUILD-FAILED: cannot cd"; echo BUILD-FAILED > "$workroot/$prog.status"; exit 1; }
. ./$prog.info

# Force a controlled output dir so the harness, not the SlackBuild's environment,
# decides where the package lands. Never inherit OUTPUT (the user's -current repo
# convention sets it to /repo, which must not be touched here).
export OUTPUT=/sbo-work/output
mkdir -p "\$OUTPUT"

# Log resolved build context so the overlay never needs to be kept.
echo "===== sbo-batch-test: $prog ====="
echo "PRGNAM=\${PRGNAM:-$prog} VERSION=\${VERSION:-?} BUILD=\${BUILD:-?} TAG=\${TAG:-?}"
echo "uname -m: \$(uname -m)   OUTPUT=\$OUTPUT"
echo "REQUIRES=\${REQUIRES:-}"
echo "================================="

# pick arch-specific download/md5 if present (x86_64 VM)
if [ "\$(uname -m)" = "x86_64" ] && [ -n "\${DOWNLOAD_x86_64:-}" ] && [ "\${DOWNLOAD_x86_64}" != "UNSUPPORTED" ] && [ "\${DOWNLOAD_x86_64}" != "UNTESTED" ]; then
  DL="\$DOWNLOAD_x86_64"; MD="\$MD5SUM_x86_64"
else
  DL="\$DOWNLOAD"; MD="\$MD5SUM"
fi

# 2. download
for u in \$DL; do
  wget -c --tries=3 "\$u" || { echo DOWNLOAD-FAILED > "$workroot/$prog.status"; exit 1; }
done

# 3. verify md5
set -- \$MD
for u in \$DL; do
  f="\$(basename "\$u")"
  want="\$1"; shift
  got="\$(md5sum "\$f" | cut -d' ' -f1)"
  if [ "\$got" != "\$want" ]; then
    echo "MD5 mismatch on \$f: want \$want got \$got"
    echo MD5-MISMATCH > "$workroot/$prog.status"; exit 1
  fi
done

# 4. build non-interactively
chmod +x ./$prog.SlackBuild
if ! ./$prog.SlackBuild; then
  echo BUILD-FAILED > "$workroot/$prog.status"; exit 1
fi

# 5. installpkg the resulting package (written to the forced OUTPUT above)
out="\$OUTPUT"
pkg="\$(ls -t "\$out"/${prog}-*.t?z 2>/dev/null | head -n1)"
if [ -z "\$pkg" ]; then
  echo "No package produced in \$out"
  echo BUILD-FAILED > "$workroot/$prog.status"; exit 1
fi
if ! installpkg --terse "\$pkg"; then
  echo INSTALL-FAILED > "$workroot/$prog.status"; exit 1
fi
# Log the installed file list (from the package db) so the overlay is disposable.
echo "===== installed files: \$(basename "\$pkg") ====="
pkgname="\$(basename "\$pkg")"; pkgname="\${pkgname%.t?z}"
cat "/var/log/packages/\$pkgname" 2>/dev/null || echo "(package db entry not found: \$pkgname)"
echo "================================="
echo SUCCESS > "$workroot/$prog.status"
CHROOT_EOF

  local status="BUILD-FAILED"
  [[ -f "$statf" ]] && status="$(cat "$statf")"
  local end; end=$(date +%s)
  ST_TIME["$key"]=$(( end - start ))
  ST_STATUS["$key"]="$status"
  if [[ "$status" == "SUCCESS" ]]; then
    # Copy the produced package out of the overlay into the persistent cache
    # before teardown, so it can be reused as a dep in this or a later run. The
    # build wrote to the forced OUTPUT=/sbo-work/output (see the heredoc).
    local built newest=""
    for built in "$c"/sbo-work/output/${prog}-*.t?z; do
      [[ -e "$built" ]] || continue
      [[ -z "$newest" || "$built" -nt "$newest" ]] && newest="$built"
    done
    [[ -n "$newest" ]] && cache_store "$cat" "$prog" "$newest"
    # Final verification: lint the target's built package (host-side, overlay
    # still mounted so $newest is reachable). Target only; deps are vetted SBo
    # packages and would just add noise.
    [[ "$is_target" == "1" && -n "$newest" ]] && lint_pkg "$newest" "$logf"
    return 0
  fi
  ST_REASON["$key"]="see $(basename "$logf")"
  return 1
}

# Echo a human label for a package's cache outcome, given its SlackBuild dir and
# whether it is the run's target. Mirrors cache_decision but renders text.
#   target, build (new) | target, rebuild: A -> B | cached (V) | rebuild: A -> B | build (new)
cache_label() {
  local dir="$1" is_target="$2"
  local cat prog ver dec
  cat="$(category_of "$dir")"; prog="$(basename "$dir")"; ver="$(version_of "$dir")"
  dec="$(cache_decision "$cat" "$prog" "$ver")"
  local label
  case "$dec" in
    cached)   label="cached ($ver)" ;;
    bump:*)   label="rebuild: ${dec#bump:}"; label="${label/:/ -> }" ;;
    *)        label="build (new)" ;;
  esac
  # The target always builds; never show it as plain "cached".
  if [[ "$is_target" == "1" ]]; then
    case "$dec" in
      cached) label="build (cached $ver, rebuilt as target)" ;;
    esac
    echo "target, $label"
  else
    echo "$label"
  fi
}

# =============================================================================
# Run one target: fresh overlay, build its resolved chain, teardown.
# Args: <target-slackbuild-dir>
# =============================================================================
run_target() {
  local target_dir="$1"
  local tkey; tkey="$(pkg_key "$target_dir")"

  echo
  echo "=== Target: $tkey ==="
  resolve_target "$target_dir"
  local resolve_rc=$?

  # Hard resolution failures: mark target and (newly seen) deps, skip building.
  if [[ ${#CYCLES[@]} -gt 0 || ${#UNMET[@]} -gt 0 ]]; then
    local why=""
    if [[ ${#UNMET[@]} -gt 0 ]]; then
      local u
      for u in "${!UNMET[@]}"; do why+="unmet:$u(${UNMET[$u]}) "; done
    fi
    [[ ${#CYCLES[@]} -gt 0 ]] && why+="${CYCLES[*]}"
    ST_STATUS["$tkey"]="UNMET-DEP"
    ST_REASON["$tkey"]="$why"
    echo "  resolution failed: $why"
    return
  fi

  if [[ $DRY_RUN -eq 1 ]]; then
    echo "  build order:"
    local d
    for d in "${RESOLVED_ORDER[@]}"; do
      local it=0; [[ "$d" == "$target_dir" ]] && it=1
      local rdm=""; [[ "${HAS_README[$d]:-}" == 1 ]] && rdm="  [%README%]"
      printf "    %-30s %s%s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")" "$rdm"
      echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"
    done
    return
  fi

  echo "  build order:"
  for d in "${RESOLVED_ORDER[@]}"; do
    local it=0; [[ "$d" == "$target_dir" ]] && it=1
    printf "    %-30s %s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")"
    echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"
  done

  local tmpdir; tmpdir="$(setup_overlay)"

  # Build chain in order. On a failure, mark dependents BLOCKED-BY-DEP.
  local d failed_progs=()
  for d in "${RESOLVED_ORDER[@]}"; do
    local key; key="$(pkg_key "$d")"
    local prog; prog="$(basename "$d")"

    # already blocked because an earlier dep it needs failed?
    if depends_on_failed "$d" failed_progs; then
      ST_STATUS["$key"]="BLOCKED-BY-DEP"
      ST_REASON["$key"]="blocked by failed dep"
      [[ "${HAS_README[$d]:-}" == "1" ]] && ST_README["$key"]=1
      echo "  $key: BLOCKED-BY-DEP"
      failed_progs+=("$prog")
      continue
    fi

    local it=0; [[ "$d" == "$target_dir" ]] && it=1
    echo "  building $key ..."
    if build_one "$tmpdir" "$d" "$it"; then
      echo "  $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)"
    else
      echo "  $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)"
      failed_progs+=("$prog")
    fi
  done

  # Overlay is always torn down: the per-package logs capture everything worth
  # inspecting (full build/install output, resolved .info env, installed file
  # list), so there is no reason to retain the filesystem.
  teardown_overlay "$tmpdir"
}

# Does SlackBuild dir $1 directly require any prog in failed list (nameref $2)?
# ponytail: direct REQUIRES check only; transitive blocking still works because
# this runs in topo order, so a dep's failure propagates up one hop at a time.
depends_on_failed() {
  local dir="$1"; local -n failed="$2"
  local info="$dir/$(basename "$dir").info"
  local req tok f
  req="$(read_requires "$info")"
  for tok in $req; do
    [[ "$tok" == "%README%" ]] && continue
    for f in "${failed[@]:-}"; do
      [[ "$tok" == "$f" ]] && return 0
    done
  done
  return 1
}

# =============================================================================
# SUMMARY (screen color + plain summary.log)
# =============================================================================
print_summary() {
  local total=$SECONDS
  local succ=0 fail=0 blocked=0 cached=0
  local summary="$RUN_DIR/summary.log"

  {
    echo "sbo-batch-test run summary"
    echo "target: $TARGET_ARG"
    echo
  } > "$summary"

  echo
  echo "================ SUMMARY ================"
  local key
  for key in "${!ST_STATUS[@]}"; do
    local st="${ST_STATUS[$key]}" rsn="${ST_REASON[$key]:-}" t="${ST_TIME[$key]:-0}"
    local rd=""; [[ "${ST_README[$key]:-}" == "1" ]] && rd=" [%README%]"
    local col="$C_YEL"
    case "$st" in
      SUCCESS) col="$C_GRN"; ((succ++)) ;;
      CACHED)  col="$C_GRN"; ((cached++)) ;;
      BLOCKED-BY-DEP|UNMET-DEP) col="$C_YEL"; ((blocked++)) ;;
      *) col="$C_RED"; ((fail++)) ;;
    esac
    printf "%s%-30s %-16s%s %s%s (%ss)\n" "$col" "$key" "$st" "$C_RST" "$rsn" "$rd" "$t"
    printf "%-30s %-16s %s%s (%ss)\n" "$key" "$st" "$rsn" "$rd" "$t" >> "$summary"
  done
  echo "----------------------------------------"
  printf "%s%d succeeded%s, %s%d failed%s, %s%d blocked%s, %s%d cached%s, total %ss\n" \
    "$C_GRN" "$succ" "$C_RST" "$C_RED" "$fail" "$C_RST" "$C_YEL" "$blocked" "$C_RST" \
    "$C_GRN" "$cached" "$C_RST" "$total"
  echo "logs: $RUN_DIR"
  {
    echo
    echo "$succ succeeded, $fail failed, $blocked blocked, $cached cached, total ${total}s"
    echo "logs: $RUN_DIR"
  } >> "$summary"
}

# =============================================================================
# main
# =============================================================================
main() {
  parse_args "$@"
  init_color
  if [[ $INIT_BASE -eq 1 ]]; then
    init_base
    exit 0
  fi
  if [[ $SETUP_REPO -eq 1 ]]; then
    setup_repo
  fi
  validate_env
  [[ $DO_UPDATE -eq 1 ]] && update_personal_trees

  RUN_DIR="$LOG_ROOT/$(date +%Y-%m-%d_%H-%M-%S)"
  mkdir -p "$RUN_DIR"
  : > "$RUN_DIR/build-order.txt"

  [[ $DRY_RUN -eq 0 ]] && update_base

  [[ $WITH_X -eq 1 ]] && { echo "X passthrough on (xhost +local:hosts). Security: allows local connections to your X server."; xhost +local:hosts >/dev/null; }

  # Collect targets.
  local -a targets=()
  if [[ -d "$TARGET_ARG" ]]; then
    # category-folder mode: every subdir with a matching .info is a target
    local d prog
    for d in "$TARGET_ARG"/*; do
      [[ -d "$d" ]] || continue
      prog="$(basename "$d")"
      [[ -f "$d/$prog.info" ]] && targets+=("$d")
    done
    if [[ ${#targets[@]} -eq 0 ]]; then
      echo "No SlackBuild targets found in folder: $TARGET_ARG" >&2
      exit 1
    fi
  else
    # single-package mode: resolve the name in the tree
    local tdir
    if tdir="$(find_slackbuild_dir "$TARGET_ARG")"; then
      targets+=("$tdir")
    else
      echo "Program not found in any SBo tree root: $TARGET_ARG" >&2
      exit 1
    fi
  fi

  # TODO: "all" mode and queue/list-file mode plug in here (populate `targets`).

  local t
  for t in "${targets[@]}"; do
    run_target "$t"
  done

  print_summary
}

main "$@"
