#!/bin/bash
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
#
# test-build - verify an already-published SBo package still builds on a target
# Slackware version inside a throwaway docker container. Resolves + builds its
# SBo deps from the local tree, caches built deps per image digest, reports
# per-package status and lints the target.
#
# Dependency-resolution, cache, and summary logic are adapted from sbo-batch-test
# (github: danixland). The overlay chroot is replaced by a docker container: the
# container IS the disposable environment, so no overlayfs.
#
# No em dashes in prose by author convention.

PROJECT_VERSION="1.1.0"   # bump via sed across all scripts; see CLAUDE.md Releases

# =============================================================================
# CONFIG (do not edit here; real values live in the external config file)
# =============================================================================
SBO_TREE_CURRENT=""
SBO_TREE_STABLE=""
IMAGE_CURRENT=""
IMAGE_STABLE=""
LOG_ROOT="/var/log/sbo-test-build"
PKG_CACHE=""

TB_CONFIG="${TB_CONFIG:-$HOME/.config/sbo-testbuild/config}"
if [[ -f "$TB_CONFIG" ]]; then
  # shellcheck disable=SC1090
  source "$TB_CONFIG"
fi

TB_OVERRIDES="${TB_OVERRIDES:-$HOME/.config/sbo-testbuild/overrides}"

# =============================================================================
set -uo pipefail
# Not -e: a package build failing is a handled outcome, not a script crash.

# ---- flags / globals --------------------------------------------------------
USE_COLOR=1        # --no-color or non-TTY disables
DRY_RUN=0          # --dry-run: resolve + print order, do not build
ASSUME_YES=0       # --yes: skip the confirm prompt (still prints the order)
USE_CACHE=1        # --no-cache disables the dep cache for one run
KEEP_TARGET=0      # --keep: copy the built target package out to KEEP_DIR
LOCAL_DEPS=0       # --local-deps: also resolve deps from the target's own repo
VERSION_ID="current"   # "current" | "15.0"; set by --stable
TARGET_ARG=""

ACTIVE_TREE=""     # selected SBo tree (by version)
ACTIVE_IMAGE=""    # selected image tag (by version)
RUN_DIR=""         # timestamped log dir for this run
DEPS_DIR=""        # per-run host dir of built dep .txz, mounted into the container
BUILD_OUT=""       # per-run host dir where built packages are copied out

# Status tracking. Keyed by "category/prog". Parallel assoc arrays.
declare -A ST_STATUS=()
declare -A ST_REASON=()
declare -A ST_TIME=()
declare -A ST_README=()

usage() {
  cat <<'EOF'
test-build - verify an SBo package builds on a target Slackware in docker

USAGE:
  test-build [OPTIONS] <program-name>

OPTIONS:
  -h, --help    This text.
  -V, --version Print version and exit.
  --stable      Target Slackware 15.0 (image + tree). Default is -current.
  --dry-run     Resolve, apply overrides, print the build order, do not build.
  --yes         Skip the confirm prompt (the order is still printed first).
  --no-cache    Rebuild all deps this run, ignore/refresh the cache.
  --keep        Copy the built target package out to a kept/ dir (path is
                printed), so it can be installed on the host. The build is
                otherwise throwaway.
  --local-deps  Also resolve deps from the target's own repo (its grandparent
                dir), for packages whose deps are siblings in the same repo and
                not on SBo. The configured SBo tree is still searched after.
  --no-color    Disable ANSI color (auto-disabled when stdout is not a TTY).
EOF
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) usage; exit 0 ;;
      -V|--version) echo "test-build $PROJECT_VERSION"; exit 0 ;;
      --stable|15.0) VERSION_ID="15.0"; shift ;;
      --dry-run) DRY_RUN=1; shift ;;
      --yes) ASSUME_YES=1; shift ;;
      --no-cache) USE_CACHE=0; shift ;;
      --keep) KEEP_TARGET=1; shift ;;
      --local-deps) LOCAL_DEPS=1; shift ;;
      --no-color) USE_COLOR=0; shift ;;
      -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
      *)
        if [[ -n "$TARGET_ARG" ]]; then
          echo "Only one target accepted (got '$TARGET_ARG' and '$1')." >&2
          exit 2
        fi
        TARGET_ARG="$1"; shift ;;
    esac
  done
  if [[ -z "$TARGET_ARG" ]]; then
    echo "No target given." >&2; usage >&2; exit 2
  fi
}

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
}

# Map VERSION_ID to the active tree + image. No I/O, unit-testable.
select_version_paths() {
  if [[ "$VERSION_ID" == "15.0" ]]; then
    ACTIVE_TREE="$SBO_TREE_STABLE"; ACTIVE_IMAGE="$IMAGE_STABLE"
  else
    ACTIVE_TREE="$SBO_TREE_CURRENT"; ACTIVE_IMAGE="$IMAGE_CURRENT"
  fi
}

# require_config: the external config must exist and set the version's paths.
require_config() {
  if [[ ! -f "$TB_CONFIG" ]]; then
    cat >&2 <<EOF
No config file: $TB_CONFIG
Copy the example and edit it:

  mkdir -p "\$(dirname "$TB_CONFIG")"
  cp test-build-config.example "$TB_CONFIG"
  \${EDITOR:-vi} "$TB_CONFIG"
EOF
    exit 1
  fi
  select_version_paths
  if [[ -z "$ACTIVE_TREE" || -z "$ACTIVE_IMAGE" ]]; then
    echo "Config $TB_CONFIG is missing the tree/image for version '$VERSION_ID'." >&2
    exit 1
  fi
  if [[ ! -d "$ACTIVE_TREE" ]]; then
    echo "SBo tree for '$VERSION_ID' does not exist: $ACTIVE_TREE" >&2
    exit 1
  fi
}

# =============================================================================
# SBo tree lookup
# =============================================================================
# In this tool there is one active tree per run, but the resolver reads an array
# named SBO_TREE_ROOTS so the ported logic and its self-check match sbo-batch-test.
# main() sets SBO_TREE_ROOTS=("$ACTIVE_TREE") after require_config.
declare -a SBO_TREE_ROOTS=()

find_slackbuild_dir() {
  local prog="$1" root d
  for root in "${SBO_TREE_ROOTS[@]}"; do
    [[ -d "$root" ]] || continue
    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() { basename "$(dirname "$1")"; }
pkg_key() { echo "$(category_of "$1")/$(basename "$1")"; }

# Resolve the TARGET to test from CWD or a path (repo-agnostic). The target is
# the SlackBuild you are editing, wherever it lives; its deps come from the
# configured SBo tree (see find_slackbuild_dir), NOT the other way around.
#   - a path (absolute, or containing '/', or '.'/'./x'): use that dir directly
#   - a bare name: ./<name>/ under CWD, else CWD itself if it IS <name>/
# Prints the resolved absolute dir on success; returns 1 (with a message) if the
# dir has no matching <name>.info.
resolve_target_dir() {
  local arg="$1" dir prog
  if [[ "$arg" == /* || "$arg" == .* || "$arg" == */* ]]; then
    dir="${arg%/}"; prog="$(basename "$dir")"
  elif [[ -f "./$arg/$arg.info" ]]; then
    dir="./$arg"; prog="$arg"
  elif [[ "$(basename "$PWD")" == "$arg" && -f "./$arg.info" ]]; then
    dir="."; prog="$arg"
  else
    echo "Target '$arg' not found: no ./$arg/$arg.info, and CWD is not $arg/." >&2
    return 1
  fi
  if [[ ! -f "$dir/$prog.info" ]]; then
    echo "Target dir '$dir' has no $prog.info (expected a SlackBuild package dir)." >&2
    return 1
  fi
  ( cd "$dir" && pwd )   # emit absolute path
}

read_requires() {
  local info="$1"
  # shellcheck disable=SC1090
  ( set +u; source "$info"; echo "${REQUIRES:-}" )
}

version_of() {
  local dir="$1"
  local info="$dir/$(basename "$dir").info"
  [[ -f "$info" ]] || return
  local v; v="$(grep -m1 '^VERSION=' "$info" | cut -d'"' -f2)"
  echo "$v"
}

# =============================================================================
# DEPENDENCY RESOLUTION (topo sort + cycle detection, LOCAL tree only)
# =============================================================================
declare -a RESOLVED_ORDER=()
declare -A UNMET=()
declare -a CYCLES=()
declare -A HAS_README=()
declare -A _vstate=()
declare -A FETCH_DEPS=()   # prog -> 1: resolve via sbopkg in-container

# Is a prog already present in the container base? Kept as a callback so the
# pure topo logic stays testable.
#
# Populated lazily from the active image's package db on first call: each entry
# in /var/log/packages is named <name>-<ver>-<arch>-<build>, so strip the last
# three dash-fields to get the bare package name and match the SBo token
# against that. TB_BASE_PKGS can be pre-seeded by tests to bypass docker.
declare -A TB_BASE_PKGS=()
TB_BASE_LOADED="${TB_BASE_LOADED:-0}"

load_base_pkgs() {
  [[ "$TB_BASE_LOADED" == "1" ]] && return 0
  TB_BASE_LOADED=1
  [[ -n "$ACTIVE_IMAGE" ]] || return 0
  local entry name
  while IFS= read -r entry; do
    [[ -n "$entry" ]] || continue
    # strip trailing -<ver>-<arch>-<build>
    name="${entry%-*-*-*}"
    TB_BASE_PKGS["$name"]=1
  done < <(docker run --rm "$ACTIVE_IMAGE" \
             /bin/sh -c 'ls /var/log/packages' 2>/dev/null)
}

installed_in_base() {
  load_base_pkgs
  [[ -n "${TB_BASE_PKGS[$1]:-}" ]]
}

_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
    tok="$(apply_rename "$tok")"
    # drop: satisfied by the -current base (e.g. a 15.0-only dep like rust-opt
    # whose payload ships in current's full-install image). Skip it entirely:
    # no recurse, no UNMET, no order entry. Inert on 15.0 (overrides are).
    if [[ "${OV_DROP[$tok]:-}" == "1" ]]; then
      continue
    fi
    if [[ "${OV_FETCH[$tok]:-}" == "1" ]]; then
      FETCH_DEPS["$tok"]=1
      continue
    fi
    if depdir="$(find_slackbuild_dir "$tok")"; then
      _resolve_visit "$depdir" "$key" || rc=1
    elif installed_in_base "$tok"; then
      :
    else
      UNMET["$tok"]="needed by $key"
      rc=1
    fi
  done

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

resolve_target() {
  local dir="$1"
  RESOLVED_ORDER=()
  CYCLES=()
  UNMET=()
  FETCH_DEPS=()
  _vstate=()
  _resolve_visit "$dir" "(top)"
}

# =============================================================================
# current-vs-stable overrides. Parsed from $TB_OVERRIDES. Applied only when
# targeting -current (15.0 is the SBo baseline, no deltas).
# =============================================================================
declare -A OV_DROP=()      # prog -> 1
declare -A OV_RENAME=()    # old  -> new
declare -A OV_FETCH=()     # prog -> 1

load_overrides() {
  OV_DROP=(); OV_RENAME=(); OV_FETCH=()
  # 15.0 is the baseline: no overrides.
  [[ "$VERSION_ID" == "15.0" ]] && return
  [[ -f "$TB_OVERRIDES" ]] || return
  local line kind rest
  while IFS= read -r line; do
    line="${line%%#*}"                       # strip comments
    line="${line#"${line%%[![:space:]]*}"}"  # ltrim
    [[ -z "$line" ]] && continue
    kind="${line%%:*}"; rest="${line#*:}"
    kind="${kind//[[:space:]]/}"
    rest="${rest#"${rest%%[![:space:]]*}"}"  # ltrim value
    case "$kind" in
      drop)   OV_DROP["${rest//[[:space:]]/}"]=1 ;;
      fetch)  OV_FETCH["${rest//[[:space:]]/}"]=1 ;;
      rename)
        # rest is "old -> new"
        local old new
        old="${rest%%->*}"; new="${rest##*->}"
        old="${old//[[:space:]]/}"; new="${new//[[:space:]]/}"
        [[ -n "$old" && -n "$new" ]] && OV_RENAME["$old"]="$new" ;;
      *) echo "WARN: unknown override rule: $line" >&2 ;;
    esac
  done < "$TB_OVERRIDES"
}

# Map a dep token through rename rules (identity if no rule).
apply_rename() {
  local tok="$1"
  echo "${OV_RENAME[$tok]:-$tok}"
}

# Remove dropped packages from RESOLVED_ORDER in place.
apply_overrides_to_order() {
  local d prog keep=()
  for d in "${RESOLVED_ORDER[@]}"; do
    prog="$(basename "$d")"
    [[ "${OV_DROP[$prog]:-}" == "1" ]] && continue
    keep+=("$d")
  done
  # Assign without "${keep[@]:-}": under set -u that fallback yields a
  # one-element array holding an empty string when keep is empty (all deps
  # dropped), which the build loop would then iterate over as d="". Guard it.
  if [[ ${#keep[@]} -eq 0 ]]; then
    RESOLVED_ORDER=()
  else
    RESOLVED_ORDER=("${keep[@]}")
  fi
}

# =============================================================================
# Dependency cache. Layout: $CACHE_ROOT/<cat>/<prog>/<prog>-<ver>-...txz where
# CACHE_ROOT = $PKG_CACHE/<image-digest> (set per run by resolve_cache_root).
# Key is prog+version. --no-cache (USE_CACHE=0) or empty PKG_CACHE disables.
# =============================================================================
CACHE_ROOT=""   # set by resolve_cache_root once the image digest is known

# True when the cache is usable this run.
_cache_on() { [[ $USE_CACHE -eq 1 && -n "$PKG_CACHE" && -n "$CACHE_ROOT" ]]; }

_cache_ver_of() {
  local prog="$1" base="$2"
  base="${base#"$prog"-}"
  echo "${base%%-*}"
}

# cache_decision <cat> <prog> <version> -> cached | bump:OLD:NEW | new
cache_decision() {
  local cat="$1" prog="$2" version="$3"
  _cache_on || { echo new; return; }
  local dir="$CACHE_ROOT/$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() {
  local cat="$1" prog="$2" version="$3"
  _cache_on || return
  local dir="$CACHE_ROOT/$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"
}

cache_store() {
  local cat="$1" prog="$2" src="$3"
  _cache_on || return
  local dir="$CACHE_ROOT/$cat/$prog"
  mkdir -p "$dir"
  rm -f "$dir"/*.t?z
  cp -a "$src" "$dir/"
}

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
  if [[ "$is_target" == "1" ]]; then
    case "$dec" in
      cached) label="build (cached $ver, rebuilt as target)" ;;
    esac
    echo "target, $label"
  else
    echo "$label"
  fi
}

# Compute CACHE_ROOT from the image's digest, namespaced by variant. Falls back
# to the tag if the digest cannot be read (still isolates per image reference).
# The dir is "<variant>-<digest>", so an image rebuild changes the digest and
# self-invalidates the cache (deps built against the old base are never reused).
#
# Prune stale digests of THIS variant on the way in: a base update leaves the
# old <variant>-<olddigest> dir orphaned, so drop it. Scoped to the variant so a
# --current run does not wipe the 15.0 cache (both live caches survive, and
# testing both trees back-to-back still reuses built deps).
resolve_cache_root() {
  [[ -z "$PKG_CACHE" ]] && { CACHE_ROOT=""; return; }
  local digest
  digest="$(docker image inspect --format '{{index .Id}}' "$ACTIVE_IMAGE" 2>/dev/null)"
  [[ -z "$digest" ]] && digest="tag-${ACTIVE_IMAGE//[^a-zA-Z0-9._-]/_}"
  digest="${digest//[^a-zA-Z0-9._-]/_}"
  local name="${VERSION_ID}-${digest}"
  CACHE_ROOT="$PKG_CACHE/$name"
  mkdir -p "$CACHE_ROOT"

  # drop superseded caches for this variant only
  local d
  for d in "$PKG_CACHE/${VERSION_ID}-"*; do
    [[ -d "$d" ]] || continue
    [[ "$(basename "$d")" == "$name" ]] && continue
    rm -rf "$d"
  done
}

# Does SlackBuild dir $1 directly require any prog in the dead list (nameref $2)?
# Direct-requires check only; transitive blocking works because run_target
# iterates in topo order, propagating a failure one hop per package.
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
}

# build_one <slackbuild-dir> <is_target 0|1> [container-name]
# Runs the build in a throwaway container. Sets ST_STATUS/ST_REASON/ST_TIME.
# Successful builds copy their package to a host workdir; deps are cached and
# the target is linted. Returns 0 on SUCCESS/CACHED, 1 otherwise.
build_one() {
  local dir="$1" is_target="${2:-0}"
  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")"

  [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1

  # Dep with a version-matching cached package: installpkg it into the shared
  # dep-package dir; no build. The target never takes this path.
  if [[ "$is_target" != "1" ]]; then
    local cached; cached="$(cache_path "$cat" "$prog" "$version")"
    if [[ -n "$cached" ]]; then
      cp -a "$cached" "$DEPS_DIR/"
      {
        echo "===== test-build: $prog (from cache) ====="
        echo "cached package: $(basename "$cached")"
      } >> "$logf"
      ST_TIME["$key"]=$(( $(date +%s) - start ))
      ST_STATUS["$key"]="CACHED"
      return 0
    fi
  fi

  # Build in a container. Mounts:
  #   $dir           -> /sbo/pkg    (ro, the SlackBuild)
  #   $DEPS_DIR      -> /sbo/deps   (rw, already-built dep .txz to installpkg first)
  #   $BUILD_OUT     -> /sbo/out    (rw, where the built package is copied out)
  # The in-container script installs any deps present, then builds the target,
  # writes a status token to /sbo/out/$prog.status, and copies the package out.
  local statf="$BUILD_OUT/$prog.status"
  rm -f "$statf"

  # fetch deps (removed from -current tree): let the container's sbopkg build
  # them first. FETCH_DEPS is the set collected during resolution.
  local fetch_list=""
  local fp
  for fp in "${!FETCH_DEPS[@]}"; do fetch_list+="$fp "; done

  # -i is required: without it docker does not attach stdin, so `bash -s` reads
  # nothing and the heredoc script is silently discarded (exit 0, empty log).
  docker run --rm -i \
    -v "$dir":/sbo/pkg:ro \
    -v "$DEPS_DIR":/sbo/deps \
    -v "$BUILD_OUT":/sbo/out \
    -e PROG="$prog" \
    -e FETCH_LIST="$fetch_list" \
    -e IS_TARGET="$is_target" \
    "$ACTIVE_IMAGE" /bin/bash -s >>"$logf" 2>&1 <<'CONTAINER_EOF'
set -uo pipefail
prog="$PROG"
statf="/sbo/out/$prog.status"

# 0. install already-built dependency packages (order guaranteed by the host).
for d in /sbo/deps/*.t?z; do
  [[ -e "$d" ]] || continue
  installpkg --terse "$d" || { echo "INSTALL-FAILED (dep $d)"; echo INSTALL-FAILED > "$statf"; exit 1; }
done

# 0b. fetch-from-SBo deps via sbopkg (removed from the -current tree).
for f in $FETCH_LIST; do
  echo "sbopkg-building fetch dep: $f"
  sbopkg -B -i "$f" || { echo "BUILD-FAILED (fetch dep $f)"; echo BUILD-FAILED > "$statf"; exit 1; }
done

# copy the SlackBuild out of the read-only mount so it can write there.
cp -a /sbo/pkg /sbo/build
cd /sbo/build || { echo BUILD-FAILED > "$statf"; exit 1; }
. ./"$prog".info

export OUTPUT=/sbo/out
mkdir -p "$OUTPUT"

echo "===== test-build: $prog ====="
echo "PRGNAM=${PRGNAM:-$prog} VERSION=${VERSION:-?} BUILD=${BUILD:-?} TAG=${TAG:-?}"
echo "uname -m: $(uname -m)   OUTPUT=$OUTPUT"
echo "REQUIRES=${REQUIRES:-}"
echo "================================="

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

for u in $DL; do
  wget -c --tries=3 "$u" || { echo DOWNLOAD-FAILED > "$statf"; exit 1; }
done

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 > "$statf"; exit 1
  fi
done

# Source /etc/profile.d so dep-provided env is live (google-go-lang sets GOROOT
# + PATH to its go here, rust-opt sets cargo, etc.). This heredoc runs in a
# non-login shell, which does NOT read profile.d, so a bare `go build` would
# otherwise pick the system gccgo instead of the installed google-go-lang.
set +u   # profile.d scripts routinely reference unset vars
for pf in /etc/profile.d/*.sh; do
  [ -r "$pf" ] && . "$pf"
done
set -u

chmod +x ./"$prog".SlackBuild
if ! ./"$prog".SlackBuild; then
  echo BUILD-FAILED > "$statf"; exit 1
fi

pkg="$(ls -t "$OUTPUT"/"$prog"-*.t?z 2>/dev/null | head -n1)"
if [ -z "$pkg" ]; then
  echo "No package produced in $OUTPUT"
  echo BUILD-FAILED > "$statf"; exit 1
fi
if ! installpkg --terse "$pkg"; then
  echo INSTALL-FAILED > "$statf"; exit 1
fi
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)"
echo "================================="

# lint the target here in the container: sbopkglint is baked into the image and
# runs as root, so it needs no host sudo. Fail-soft: findings never fail the build.
if [ "${IS_TARGET:-0}" = "1" ] && command -v sbopkglint >/dev/null 2>&1; then
  echo "===== sbopkglint: $(basename "$pkg") ====="
  if sbopkglint "$pkg"; then
    echo "LINT-CLEAN"
  else
    echo "LINT-FINDINGS"
  fi
  echo "================================="
fi
echo SUCCESS > "$statf"
CONTAINER_EOF

  local status="BUILD-FAILED"
  [[ -f "$statf" ]] && status="$(cat "$statf")"
  ST_TIME["$key"]=$(( $(date +%s) - start ))
  ST_STATUS["$key"]="$status"

  if [[ "$status" == "SUCCESS" ]]; then
    # locate the built package copied to the host workdir
    local built newest=""
    for built in "$BUILD_OUT/${prog}"-*.t?z; do
      [[ -e "$built" ]] || continue
      [[ -z "$newest" || "$built" -nt "$newest" ]] && newest="$built"
    done
    if [[ -n "$newest" ]]; then
      if [[ "$is_target" != "1" ]]; then
        cache_store "$cat" "$prog" "$newest"
        # make the dep available to later builds in this run
        cp -a "$newest" "$DEPS_DIR/"
      else
        # lint ran in-container (see IS_TARGET block); surface its verdict from
        # the log so the host summary can show clean/findings.
        if grep -q '^LINT-FINDINGS$' "$logf" 2>/dev/null; then
          echo "  lint: ${C_RED}findings${C_RST} (see $(basename "$logf"))"
        elif grep -q '^LINT-CLEAN$' "$logf" 2>/dev/null; then
          echo "  lint: ${C_GRN}clean${C_RST}"
        fi
        # --keep: copy the built target package to a durable dir (sibling of the
        # logs tree, not inside a throwaway run dir) so it can be installed on
        # the host, e.g. to regenerate post-install artifacts.
        if [[ $KEEP_TARGET -eq 1 ]]; then
          local keepdir; keepdir="$(dirname "$LOG_ROOT")/kept"
          mkdir -p "$keepdir"
          cp -a "$newest" "$keepdir/"
          echo "  kept: $keepdir/$(basename "$newest")"
        fi
      fi
    fi
    return 0
  fi
  ST_REASON["$key"]="see $(basename "$logf")"
  return 1
}

# confirm_order: print the resolved order (overrides marked) and ask to proceed.
# --yes skips the prompt but the order is still printed. --dry-run never reaches
# here. Returns 0 to proceed, 1 to abort.
confirm_order() {
  local target_dir="$1"
  echo "  build order (${VERSION_ID}):"
  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
  # note fetch deps (built via sbopkg in-container, not in the order list)
  local fp
  for fp in "${!FETCH_DEPS[@]}"; do
    printf "    %-30s %s\n" "$fp" "fetch (sbopkg in container)"
  done
  [[ $ASSUME_YES -eq 1 ]] && return 0
  local reply
  read -rp "  Proceed? [Y/n] " reply
  [[ -z "$reply" || "$reply" =~ ^[Yy]$ ]]
}

# run_target <target-slackbuild-dir>
run_target() {
  local target_dir="$1"
  local tkey; tkey="$(pkg_key "$target_dir")"

  echo
  echo "=== Target: $tkey (${VERSION_ID}) ==="
  resolve_target "$target_dir"
  apply_overrides_to_order

  # Hard resolution failures: report and stop, do not build.
  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"
    echo "  add an override rule ($TB_OVERRIDES) and rerun, or fix the tree." >&2
    return
  fi

  if [[ $DRY_RUN -eq 1 ]]; then
    echo "  build order (dry-run):"
    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
    local fp
    for fp in "${!FETCH_DEPS[@]}"; do
      printf "    %-30s %s\n" "$fp" "fetch (sbopkg in container)"
    done
    return
  fi

  if ! confirm_order "$target_dir"; then
    echo "  aborted."
    ST_STATUS["$tkey"]="ABORTED"
    return
  fi

  # Per-run docker workdirs (host side, discarded after the run).
  DEPS_DIR="$RUN_DIR/deps"; BUILD_OUT="$RUN_DIR/out"
  mkdir -p "$DEPS_DIR" "$BUILD_OUT"

  local d failed_progs=()
  for d in "${RESOLVED_ORDER[@]}"; do
    local key; key="$(pkg_key "$d")"
    local prog; prog="$(basename "$d")"

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

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

  {
    echo "test-build run summary"
    echo "target: $TARGET_ARG  version: $VERSION_ID"
    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|ABORTED) 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"
  if [[ $fail -eq 0 && $blocked -eq 0 ]]; then
    echo "${C_GRN}All green.${C_RST} Safe to build the SBo submission tarball on the host."
  fi
  {
    echo
    echo "$succ succeeded, $fail failed, $blocked blocked, $cached cached, total ${total}s"
    echo "logs: $RUN_DIR"
  } >> "$summary"
}


# Ensure the selected image is available locally. It is built by a separate job
# (the image-builder) and pushed to the LAN registry; this script only consumes
# it. If it is not already local, pull it once (ACTIVE_IMAGE is a fully-qualified
# registry ref). A stale local tag is not refreshed here.
# ponytail: pull-if-missing only. If the registry's :current is rebuilt, the
#   local copy goes stale silently. Add a --pull force-flag if that bites.
require_image() {
  docker image inspect "$ACTIVE_IMAGE" >/dev/null 2>&1 && return 0
  echo "Image not present locally, pulling: $ACTIVE_IMAGE" >&2
  docker pull "$ACTIVE_IMAGE" >&2 && return 0
  cat >&2 <<EOF
Image not found and pull failed: $ACTIVE_IMAGE
It is produced by the image-builder job (full Slackware $VERSION_ID +
sbo-maintainer-tools + sbopkg) and pushed to the LAN registry. Check the
registry is reachable and the tag exists, then rerun. This script does not
build images.
EOF
  exit 1
}

main() {
  parse_args "$@"
  init_color
  require_config
  SBO_TREE_ROOTS=("$ACTIVE_TREE")   # target's repo root prepended below if --local-deps
  load_overrides

  # image + cache namespace (skip the image check on a pure dry-run so the order
  # can be inspected without the image present).
  if [[ $DRY_RUN -eq 0 ]]; then
    command -v docker >/dev/null 2>&1 || { echo "docker not found in PATH." >&2; exit 1; }
    require_image
  fi
  resolve_cache_root

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

  # The target is the local SlackBuild under test (CWD or a path); its deps are
  # resolved from the configured SBo tree during _resolve_visit.
  local tdir
  if ! tdir="$(resolve_target_dir "$TARGET_ARG")"; then
    exit 1
  fi

  # --local-deps: search the target's own repo (grandparent of <repo>/<cat>/<pkg>)
  # before the SBo tree, so deps that are siblings in the same repo resolve.
  if [[ $LOCAL_DEPS -eq 1 ]]; then
    local repo_root; repo_root="$(dirname "$(dirname "$tdir")")"
    SBO_TREE_ROOTS=("$repo_root" "${SBO_TREE_ROOTS[@]}")
  fi

  run_target "$tdir"
  print_summary
}

main "$@"
