#!/bin/bash
#
# 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.

# =============================================================================
# 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
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

# 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.
  --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.
  --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 ;;
      --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 ;;
      --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 .extras/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")"; }

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? Overridden at build time to
# consult the image; in resolution we treat "in base" as a callback so the pure
# topo logic stays testable. Default: not in base (self-check has no base db).
installed_in_base() { return 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")"
    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
  RESOLVED_ORDER=("${keep[@]:-}")
}

# =============================================================================
# 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. Falls back to the tag if the
# digest cannot be read (still isolates per image reference).
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._-]/_}"
  CACHE_ROOT="$PKG_CACHE/$digest"
  mkdir -p "$CACHE_ROOT"
}

main() {
  parse_args "$@"
  init_color
  require_config
  SBO_TREE_ROOTS=("$ACTIVE_TREE")
  load_overrides
}

main "$@"
