#!/bin/bash

# Copyright (C) 2025  Association foOpgp <info@foopgp.org>
# Author: Sébastien Picardeau (u4D9SrwuxesuMU90PM8xypxQe_48.78_002.19) <piseb@foopgp.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# 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.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

set -euo pipefail

# A public apt archive must be readable by apache (group www-data) and by the
# world. Fix the umask so every file and directory the tool creates is
# group/world readable, whatever the invoking user's login umask.
umask 022

# Login-shell compatibility. When djibian-debs-build is set as the login shell
# of the build user, it must still behave like a shell for non-interactive
# uses: `ssh host CMD`, scp, git and sshwgpg's preparatory `ssh host 'gpgconf
# ...'` all invoke it as `<shell> -c "CMD"`. Pass those straight to bash so
# remote commands and gpg-agent forwarding keep working; a bare interactive
# login still opens the menu.
if [ "${1-}" = "-c" ]; then
  shift
  exec /bin/bash -c "$@"
fi
# No arguments and no controlling terminal (a login shell in a headless
# context): fall back to a real shell rather than a TTY-only menu.
if [ "$#" -eq 0 ] && [ ! -t 0 ]; then
  exec /bin/bash
fi

PROGDIR="$(cd "$(dirname "$0")" && pwd)"
declare -r PROGDIR

# Configuration: /etc/djibian-build/debs/build_config (a shell fragment).
#   ARCHS          architectures the archive serves. The host architecture is
#                  built natively; the others are only warned about, to be
#                  built elsewhere and dropped in before a reindex.
#   GITREPOS_PATH  where the admin clones the source repositories, as the
#                  djibian-build-debs user. A repository to build is any
#                  directory below it that holds both a .git/ and a debian/.
declare -r config_file="${DJIBIAN_BUILD_CONFIG:-/etc/djibian-build/debs/build_config}"
ARCHS="amd64 arm64"
GITREPOS_PATH="/var/lib/djibian-build-debs/repos"
# shellcheck source=/dev/null
[ -r "$config_file" ] && source "$config_file"
declare -r ARCHS GITREPOS_PATH
declare -r repos_dir="${GITREPOS_PATH%/}"

# Served archive roots -- hardcoded (the site points its DNS at them). The
# package's postinst provisions them owned djibian-build-debs:www-data and
# setgid, so apache reads what the tool writes without an ACL. prod doubles
# "debs" because it is served at http://djibian.foopgp.org/debs/.
declare -r archive_test="/var/www/djibian/debs/test"
declare -r archive_prod="/var/www/djibian/debs/debs"

# Build state (staged debs + the promotion log) lives in the build user's home,
# separate from the repositories.
declare -r build_state_dir="/var/lib/djibian-build-debs/state"

#declare -r tag_gbp_builded_regex="debian/[0-9.\-]+[b]?"
declare -r tag_gbp_builded_regex="[^;]*[0-9.\-]+[b]?"

# Extra options handed to gbp buildpackage, split on spaces. Two uses so far:
#   GBP_OPTS=-B                     on any builder but the reference one, so
#                                   that Architecture: all packages come from a
#                                   single machine -- two builders otherwise
#                                   produce the same *_all.deb filename with
#                                   different checksums, and a flat archive
#                                   keeps whichever landed last;
#   GBP_OPTS='--git-builder=sbuild ...'   to build inside a chroot.
declare -a gbp_opts=()
if [ -n "${GBP_OPTS-}" ]; then
  read -ra gbp_opts <<< "$GBP_OPTS"
fi
declare -a repos
declare -A archive_dirs repos_tag_step
declare -r log_file="${build_state_dir}/logs"
declare -r debs_dir="${build_state_dir}/debs"
declare -r delimiter_log=";"
archive_dirs["test"]="${archive_test}"
archive_dirs["prod"]="${archive_prod}"


wp_reset() {
  # Only the build state and the served archives -- NEVER the source
  # repositories, which the admin cloned and owns.
  if whiptail --title "RESET for DEV" --yesno --defaultno \
       "RESET build state (log, staged debs) and both archives ?\n\nSource repositories under $repos_dir are left untouched." 10 78; then
    rm -rf "$build_state_dir" "${archive_dirs["test"]}" "${archive_dirs["prod"]}"
  fi
}

init() {
  local gitdir repo_root

  [ -d "$repos_dir" ] || { echo "GITREPOS_PATH not found: $repos_dir" >&2 ; exit 2 ; }

  # A repository to build = any directory under GITREPOS_PATH holding both a
  # .git/ and a debian/. No remote, no clone, no bulk pull: the admin clones
  # (as djibian-build-debs) on the branch they want; the refresh happens per repo,
  # at build time (see build_head).
  repos=()
  while IFS= read -r gitdir; do
    repo_root=$(dirname "$gitdir")
    [ -d "$repo_root/debian" ] || continue
    repos+=("${repo_root#"$repos_dir"/}")
  done < <(find "$repos_dir" -mindepth 1 -maxdepth 3 -type d -name .git -prune | sort)
  readonly repos
  [ ${#repos[@]} -eq 0 ] && { echo "No buildable repo under $repos_dir (need a dir with .git/ and debian/)" >&2 ; exit 2 ; }

  install -d -m 0750 "$build_state_dir" "$debs_dir"
  [ ! -e "$log_file" ] && touch "$log_file"

  update_repos_tag_step
}

append_log() {
  printf "%s%s" "$(date -Iminutes)" "$delimiter_log" >> "$log_file"
  for (( i = 1; i <= $#; i++ )); do
    printf "%s%s" "${!i}" "$delimiter_log" >> "$log_file"
  done
  printf "\n" >> "$log_file"

  update_repos_tag_step
}

tag_repo_step_in_log() {
  local -r repo=$1 step=$2
  local -n ntag=$3
  local line

  while IFS= read -r line; do
    declare repo step 
    if [[ $line =~ ${delimiter_log}${repo}${delimiter_log}(${tag_gbp_builded_regex})${delimiter_log}${step}${delimiter_log} ]]; then
      # shellcheck disable=SC2034
      ntag="${BASH_REMATCH[1]}"
      return 0
    fi
  done < <(tac "$log_file")
  # Not found is normal: a repo has not necessarily reached every step yet.
  # Silent -- the only caller does "|| continue".
  return 1
}

update_repos_tag_step() {
  local line repo step tag
  local -a steps=(prod test deb)
  repos_tag_step=()

  for repo in "${repos[@]}"; do
    [[ -v "repos_tag_step[$repo]" ]] || repos_tag_step[$repo]=""
    for step in "${steps[@]}"; do
      tag_repo_step_in_log "$repo" "$step" tag || continue
      [[ ${repos_tag_step[$repo]} == *"${delimiter_log}${tag}"* ]] \
      || repos_tag_step[$repo]+="${tag}${delimiter_log}${step}${delimiter_log}"
    done
  done
}

build_repo_tag() {
  local build_dir tag
  local -r repo=$1 tag=$2
  local -a debs
  local repo_local="${repos_dir}/${repo}"

  build_dir=$(mktemp -d)
  trap 'rm -rf "$build_dir"' EXIT

  cd "$repo_local"
  # -us -uc: do not sign the .dsc/.changes. We only keep the .deb (the archive
  # itself is signed later, in build_archive) and the changelog maintainer's
  # key is not the builder's. || true so a leftover non-zero (e.g. lintian)
  # does not abort; a real build failure is caught below by "no debs file".
  gbp buildpackage --git-export-dir="$build_dir" -us -uc "${gbp_opts[@]}" || true

  mapfile -t debs < <(find "${build_dir}" -name "*.deb" -exec basename {} \;)
  [ -z "${debs[*]}" ] && printf "ERROR: no debs file\n" && exit 1
  mv -v "${build_dir}"/*deb "$debs_dir"

  # shellcheck disable=SC2068
  append_log "$repo" "$tag" "deb" ${debs[@]}

  cd -
  rm -rf "$build_dir"
  trap - EXIT
}

get_debs_repo_tag() {
  local -r repo_tag=$1
  local -n nrr_debs=$2

  while IFS= read -r line; do
    if [[ $line =~ ${repo_tag}${delimiter_log}deb${delimiter_log}(.*) ]]; then
      nrr_debs+="${BASH_REMATCH[1]}"
      return 0
    fi
  done < <(tac "$log_file")

  return 1
}

get_debs_step_exclude() {
  local -r step=$1 exclude_repo=$3
  # shellcheck disable=SC2034
  local -n nr_debs=$2
  local repo tags_steps tag

  for repo in "${repos[@]}"; do
    if [ "$exclude_repo" = "$repo" ]; then
      continue
    fi
    tags_steps="${repos_tag_step[$repo]}"
    if [[ $tags_steps =~ (${tag_gbp_builded_regex})${delimiter_log}${step} ]]; then
      tag="${BASH_REMATCH[1]}"
      get_debs_repo_tag "${repo}${delimiter_log}${tag}" nr_debs
    fi
  done
}

# One Contents index per architecture actually present.
#
# apt-ftparchive walks a whole directory, so a flat archive holding both amd64
# and arm64 gets a single index -- named after one architecture and listing the
# files of both. Architecture: all packages belong to every architecture, hence
# the second linking pass.
build_contents() {
  local -r dir=$1
  local arch archs deb
  local -a all_debs=()

  mapfile -t all_debs < <(find "$dir" -maxdepth 1 -name '*_all.deb')
  # sed rather than grep: grep returns 1 when it filters everything out, and
  # this script runs under set -e.
  archs=$(find "$dir" -maxdepth 1 -name '*.deb' -exec dpkg-deb --field {} Architecture \; \
          | sort --unique | sed '/^all$/d')

  for arch in $archs; do
    rm -rf "${dir}/.contents-${arch}"
    mkdir -p "${dir}/.contents-${arch}"
    find "$dir" -maxdepth 1 -name "*_${arch}.deb" \
         -exec ln -sf {} "${dir}/.contents-${arch}/" \;
    for deb in "${all_debs[@]}"; do
      ln -sf "$deb" "${dir}/.contents-${arch}/"
    done
    apt-ftparchive contents "${dir}/.contents-${arch}" > "${dir}/Contents-${arch}"
    gzip -k -f "${dir}/Contents-${arch}"
    rm -rf "${dir}/.contents-${arch}"
  done
}

build_archive() {
  local -r step=$1
  local -r archive_dir=${archive_dirs[$step]}
  local -r debs=${2-}
  local build_dir deb

  build_dir=$(mktemp -d)
  trap 'rm -rf "$build_dir"' EXIT

  for deb in ${debs//;/" "}; do
    cp -v "${debs_dir}/$deb" "$build_dir"
  done

  cd "$build_dir"
  dpkg-scanpackages -m . > Packages
  gzip -k -f Packages
  build_contents "$PWD"
  apt-ftparchive release . > Release

  gpg --clearsign -o - Release > InRelease

  [ -e "$archive_dir" ] && rm -rf "$archive_dir"
  mkdir -p "$archive_dir"
  cp -v ./* "$archive_dir"

  cd -
  rm -rf "$build_dir"
  trap - EXIT
}

push_repo_tag_step() {
  local -r repo_tag_step=$1
  local repo tag step next_step debs

  IFS=";" read -r repo tag step <<< "$repo_tag_step"
  readonly repo tag step

  case "$step" in
    "prod") whiptail --msgbox "cant push after prod" 10 50; return ;;
    "test") next_step="prod" ;;
    "deb") next_step="test" ;;
    *) printf "%s: Error: unknown step '%s'\n" "$FUNCNAME" "$step" >&2 ; return 1 ;;
  esac

  get_debs_repo_tag "$repo;$tag" debs
  get_debs_step_exclude "$next_step" debs "$repo"
  build_archive "$next_step" "$debs"
  append_log "$repo" "$tag" "$next_step"

  if [ "$next_step" = prod ];then
    debs=""
    get_debs_step_exclude "$step" debs ""
    build_archive test "$debs"
  fi
}

wp_repo() {
  local -r repo=$1
  local -a wp_menu_repo=() tmp_tag_step
  local choice tag txt

  while :; do
    IFS=";" read -ra tmp_tag_step <<< "${repos_tag_step[$repo]}"
    # [build HEAD] is always offered: it refreshes the checkout (git clean
    # -dfx; git pull) and builds the current HEAD, entering the "deb" step.
    wp_menu_repo=("[build HEAD]" "git clean -dfx ; git pull ; gbp buildpackage")
    for (( i=0; i<${#tmp_tag_step[@]}; i+=2 )); do
      tag="${tmp_tag_step[i]}"
      step="${tmp_tag_step[i+1]}"
      txt="$tag actualy in $step"
      [[ ${wp_menu_repo[*]} =~ $tag ]] || wp_menu_repo+=("$txt" "")
    done

    if choice=$(whiptail --title "$repo" --menu "build HEAD, or push on next step: deb -> test -> prod" 25 78 16 "${wp_menu_repo[@]}" 3>&2 2>&1 1>&3); then
      case "$choice" in
        "[build HEAD]") build_head "$repo" || whiptail --msgbox "build failed for $repo (see terminal)" 10 50 ;;
        *)              push_repo_tag_step "$repo;${choice/" actualy in "/;}" ;;
      esac
    else
      break
    fi
  done
}

reindex_archive() {
  # Regenerate Packages / per-arch Contents / Release / InRelease of an archive
  # in place, over whatever .deb files it currently holds -- so an admin can
  # drop a foreign-arch package straight into the served dir and publish it
  # without a rebuild. Reuses the standalone djibian-debs-index.
  local -r step=$1
  local -r archive_dir=${archive_dirs[$step]}
  local indexer="$PROGDIR/djibian-debs-index"

  if [ ! -d "$archive_dir" ]; then
    whiptail --msgbox "no $step archive yet at $archive_dir" 10 60
    return 1
  fi
  [ -x "$indexer" ] || indexer="djibian-debs-index"
  "$indexer" "$archive_dir"
}

wp_repos() {
  local -a wp_menu_repo
  local choice

  while :; do
    wp_menu_repo=("[reindex test]" "regenerate indexes of $archive_test"
                  "[reindex prod]" "regenerate indexes of $archive_prod"
                  "[shell]"        "open an interactive bash shell")
    for repo in "${repos[@]}"; do
      wp_menu_repo+=("$repo" "")
    done

    if choice=$(whiptail --title "Repos" --menu "Select a repo to build/promote, reindex an archive, or open a shell" 25 78 16 "${wp_menu_repo[@]}" 3>&2 2>&1 1>&3); then
      case "$choice" in
        "[reindex test]") reindex_archive test || true ;;
        "[reindex prod]") reindex_archive prod || true ;;
        "[shell]")        bash -l || true ;;
        *)                wp_repo "$choice" ;;
      esac
    else
      break
    fi
  done
}

build_head() {
  local -r repo=$1
  local repo_local="${repos_dir}/${repo}" tag host_arch other

  if [ ! -d "$repo_local/.git" ]; then
    printf "%s: Error: '%s' is not a git repository\n" "$FUNCNAME" "$repo" >&2
    return 1
  fi

  cd "$repo_local"
  # HEAD as-is: wipe leftover build artefacts, then fast-forward. A repo with
  # no upstream (detached, or a local-only branch) still builds -- the pull is
  # best effort, on the branch the admin checked out.
  git clean -dfx
  git pull --ff-only || printf "%s: Warning: git pull failed in '%s', building current HEAD\n" "$FUNCNAME" "$repo" >&2

  if ! tag=$(dpkg-parsechangelog --show-field Version 2>/dev/null) || [ -z "$tag" ]; then
    printf "%s: Error: no debian/changelog version in '%s'\n" "$FUNCNAME" "$repo" >&2
    cd - > /dev/null
    return 1
  fi
  cd - > /dev/null

  printf "%s: Info: building %s %s\n" "$FUNCNAME" "$repo" "$tag"
  build_repo_tag "$repo" "$tag"

  # Cross-compile stays out of scope: build native, warn for every other
  # architecture the archive serves. Build those elsewhere and drop the .deb
  # in, then reindex (menu, or djibian-debs-index).
  host_arch=$(dpkg --print-architecture)
  for other in $ARCHS; do
    if [ "$other" != "$host_arch" ]; then
      printf "%s: Warning: %s built for %s only; %s must be built elsewhere and reindexed\n" \
             "$FUNCNAME" "$repo" "$host_arch" "$other" >&2
    fi
  done
}

build_all() {
  local repo
  local -i failed=0

  for repo in "${repos[@]}"; do
    # A subshell because build_repo_tag exits rather than returns when a build
    # yields no package: without it, one bad repo ends the whole run.
    ( build_head "$repo" ) || failed+=1
  done

  if (( failed )); then
    printf "%s: Warning: %d repo(s) did not build\n" "$FUNCNAME" "$failed" >&2
  fi
}

usage() {
  printf "%s" "Usage: $(basename "$0") [OPTION]

Builds the source repositories found under GITREPOS_PATH (any directory with a
.git/ and a debian/). Without an option, the interactive menu: build HEAD, then
promote deb -> test -> prod, or reindex an archive.

OPTIONS:
  -b, --build REPO   Build REPO's current HEAD (git clean -dfx; git pull), no dialog
  -B, --build-all    Build every repo under GITREPOS_PATH at its current HEAD
  -h, --help         Print this help and exit
      dev            Reset build state and archives, then the interactive menu

ENVIRONMENT:
  TMPDIR             Where packages are built. Point it at real storage: on
                     some of our machines /tmp is a tmpfs and a large build
                     dies in RAM without saying so.
  GBP_OPTS           Extra options for gbp buildpackage. Use -B on every
                     builder but the reference one, so that the Architecture:
                     all packages come from a single machine.
"
}

main() {
  case "${1-}" in
    -h|--help)      usage ; return 0 ;;
    -b|--build)     init ; build_head "${2-}" ; return ;;
    -B|--build-all) init ; build_all ; return ;;
    dev)            reset ; return ;;
    "")             init ; wp_repos ; return ;;
    *)
      printf "%s: Error: unrecognized option '%s'\n\n" "$(basename "$0")" "$1" >&2
      usage >&2
      return 2 ;;
  esac
}

reset() {
  wp_reset
  init
  wp_repos
}

main "$@"
