#!/usr/bin/env bash
#
# Description: Builds packages and sends them via rsync to a specified host.
#
# Homepage: https://codeberg.org/krathalan/krack
# Copyright (C) 2020-2024 Hunter Peavey
# SPDX-License-Identifier: GPL-3.0-or-later

# -----------------------------------------
# -------------- Guidelines ---------------
# -----------------------------------------

# This script follows the Google Shell Style Guide:
# https://google.github.io/styleguide/shell.xml

# This script uses shellcheck: https://www.shellcheck.net/

# See https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
set -Eeuo pipefail

# Ensure temporary directory is always cleaned
trap "clean_up" EXIT SIGINT

# -----------------------------------------
# ----------- Program variables -----------
# -----------------------------------------

# Source a lot of common (between krack scripts) variables
# shellcheck disable=SC1091
source /usr/lib/krack/common

# Source user conf
# shellcheck disable=SC1091
source /etc/krack/build.conf

# Used for logging to track failed builds
TMP_DIR="$(mktemp -d -t "krack-build_XXXXXXXX")"
readonly TMP_DIR
readonly TMP_LOG="${TMP_DIR}/tmp.log"

# Used to keep track of build outcomes to generate a build cycle report at the end
# up-to-date/not built, failed to build, built successfully and not uploaded, built successfully and uploaded
packagesUpToDate=()
packagesFailedToBuild=()
packagesBuiltNotUploaded=()
packagesBuiltAndUploaded=()
packagesGitPullFailed=()

# Used to print build cycle progress
BUILD_INDEX=1

# -----------------------------------------
# --------------- Functions ---------------
# -----------------------------------------

#######################################
# Removes old build artifacts (packages, logs, sigs).
# Globals:
#   MASTER_BUILD_DIR
# Arguments:
#   none
# Returns:
#   none
#######################################
clean_pkgbuild_dirs()
{
  # Clean pkgcache of old packages
  find "${PKG_CACHE_DIR}" -type d -exec paccache -v -r -k 1 -c {} \;

  # Clean master package directory of all build artifacts
  local toClean

  for dir in "${MASTER_BUILD_DIR}"/*; do
    mapfile -t toClean <<< "$(find -L "${dir}" -type f \( -name "*.gz" -o -name "*.xz" -o -name "*.zst" -o -name "*.lz4" -o -name "*.log" -o -name "*.sig" -o -name "*.asc" \))"

    cd "${dir}"
    for file in "${toClean[@]}"; do
      # Make sure the file is not tracked by git before deleting
      if ! git ls-files --error-unmatch "${file}"; then
        rm -f "${file}"
      fi
    done
  done
}


#######################################
# Removes TMP_DIR used to store temporary build logs.
# Globals:
#   TMP_DIR
# Arguments:
#   none
# Returns:
#   none
#######################################
clean_up()
{
  rm -rf "${TMP_DIR}"
  # Fix issue with gpg-agent staying open and preventing rebuilds from occurring
  pkill gpg-agent
}

#######################################
# Prints passed error message before premature exit.
# Prints everything to >&2 (STDERR).
# Globals:
#   RED, NC
# Arguments:
#   $1: error message to print
# Returns:
#   none
#######################################
exit_script_on_failure()
{
  state "death"
  printf "%sERROR%s: %s\n" "${RED}" "${NC}" "$1" >&2

  exit 1
}

#######################################
# Prints an info message.
# Globals:
#   WHITE, NC
# Arguments:
#   $1: info message to print
# Returns:
#   none
#######################################
print_info()
{
  printf "%sInfo:%s %s\n" "${WHITE}" "${NC}" "$1"
}

#######################################
# Performs the build cycle as documented in the krack man page.
# Globals:
#   lots?
# Arguments:
#   $1: package directory
# Returns:
#   none
#######################################
build_package()
{
  local -r directory="$1"
  local -r currentPackage="${directory##*/}"
  substate "checking"

  cd "${directory}" || exit_script_on_failure "Failed to \`cd\` into ${directory}"

  make_build_data_file "${directory}"

  if [[ -f "${PWD}/krack-prepull.sh" ]]; then
    print_info "Executing krack-prepull.sh"
    # shellcheck disable=SC1091
    source "${PWD}/krack-prepull.sh" || {
      important "Executing krack-prepull.sh failed for ${currentPackage}. Requesting rebuild during next build cycle"
      krackctl request-build "${currentPackage}"
      return
    }
  fi

  # Get current git commit hash
  local -r oldCommitHash="$(git rev-parse HEAD)"

  # Check out a fresh pkgbuild
  git checkout PKGBUILD &> /dev/null

  # Pull in new commits
  # git pull can fail for a multitude of reasons, and we
  # should prepare to handle it gracefully
  set +Ee

  local gitOutput
  gitOutput="$(git pull)"
  local -r git_pull_return_code="$?"

  set -Ee

  # If git pull failed, report failure and request a new build for next build cycle
  # Hopefully the user will have resolved the issue by then :)
  if [[ "${git_pull_return_code}" != 0 ]]; then
    if [[ "${directory}" != *"git"* ]]; then
      important "Git pull in package directory failed before building ${currentPackage}. Requesting rebuild during next build cycle"
      krackctl request-build "${currentPackage}"
    else
      # Don't need to request new build if package is -git, will be built anyways
      important "Git pull in package directory failed before building ${currentPackage}. Cancelling build"
    fi

    packagesGitPullFailed+=("${currentPackage}")

    return
  fi

  if [[ -f "${PWD}/krack-postpull.sh" ]]; then
    print_info "Executing krack-postpull.sh"
    # shellcheck disable=SC1091
    source "${PWD}/krack-postpull.sh" || {
      important "Executing krack-postpull.sh failed for ${currentPackage}. Requesting rebuild during next build cycle"
      krackctl request-build "${currentPackage}"
      return
    }
  fi

  if [[ -f "${PWD}/krack-request-build" ]]; then
    rm -f "${PWD}/krack-request-build"
    print_info "Build was requested manually; proceeding with build"
  elif [[ "${gitOutput}" == *"Already up to date."* ]] && [[ "${directory}" != *"git"* ]]; then
    print_info "No need to build ${currentPackage}, it is already up-to-date and is not a -git package; skipping"

    # Add to build cycle report
    packagesUpToDate+=("${currentPackage}")

    return
  fi

  if [[ "${directory}" == *"git"* ]]; then
    print_info "${currentPackage} is a -git package; forcing update"
  fi

  # Build is approved, get new git commit hash only if git pulled in something
  if [[ "${gitOutput}" != *"Already up to date."* ]]; then
    local -r newCommitHash="$(git rev-parse HEAD)"
    # ...and store diff to diff dir
    local -r diffFileName="${currentPackage}-$(date "+%Y%m%d-%H%M%S").diff"
    git diff "${oldCommitHash}".."${newCommitHash}" &> "${GIT_DIFF_DIR}/${diffFileName}"
    print_info "Stored diff of pull as ${GIT_DIFF_DIR}/${diffFileName}"
  fi

  substate "making"
  print_info "Building package ${currentPackage}"

  # Empty temporary log file for makechrootpkg output
  # (if makechrootpkg command fails we will make this log permanent and assign a unique build fail ID to it)
  printf "" > "${TMP_LOG}"

  # Record start time for storing and reporting elapsed build time
  local -r buildStartTime="$(date +%s)"

  # Disable some safety stuff here to provide nice output and continue building other packages if one build fails
  set +Eeo pipefail

  # -c: Clean the chroot before building
  # -d: Bind directory into build chroot as read-write (for ccache)
  # -r: The chroot dir to use
  # Arguments passed to makechrootpkg after the
  # end-of-options marker (--) will be passed to makepkg.
  makechrootpkg -c -d "${CCACHE_DIR}"/:/ccache -r "${MAKECHROOTPKG_DIR}" -- CCACHE_DIR=/ccache 2>&1 | tee -a "${TMP_LOG}"

  local -r buildOutcome=${PIPESTATUS[0]}

  # Re-enforce safe exiting
  set -Eeo pipefail

  local -r buildStopTime="$(date +%s)"

  # Delete old failed build logs for this package, regardless of success or failure
  # If build succeeds we don't need a failure log, if build fails we only want the most recent log
  rm -f "${FAILED_BUILDS_LOG_DIR}/${currentPackage}"*

  # If the build failed...
  if [[ "${buildOutcome}" != 0 ]]; then
    # Save tmp log to failed build logs dir
    local -r log_file_name="${currentPackage}-$(date "+%Y%m%d-%H%M%S").log"
    cp "${TMP_LOG}" "${FAILED_BUILDS_LOG_DIR}/${log_file_name}"

    if [[ "${directory}" != *"git"* ]]; then
      important "Requesting rebuild during next build cycle for ${currentPackage}"
      krackctl request-build "${currentPackage}"
    fi

    important "Build for ${currentPackage} failed at $(date). Build output saved to ${log_file_name}"

    [[ -f "${PWD}/krack-postbuild.sh" ]] && \
      print_info "Skipping postbuild script due to build failure"

    print_info "Continuing to the next build"

    # Add to build cycle report
    packagesFailedToBuild+=("${currentPackage}")

    return
  fi

  # Otherwise, if build succeeded, continue with following commands

  # Store elapsed build time
  local -r elapsedBuildTime=$(( buildStopTime - buildStartTime ))
  record_build_stat "last_build_time" "${elapsedBuildTime}" "${currentPackage}"

  # Get average time, re-average and re-write
  local averageBuildTime
  averageBuildTime="$(jq -r .average_build_time "${directory}/.krack-data.json")"

  if [[ "${averageBuildTime}" == "" ]]; then
    record_build_stat "average_build_time" "${elapsedBuildTime}" "${currentPackage}"
  else
    averageBuildTime=$(( averageBuildTime + elapsedBuildTime ))
    averageBuildTime=$(( averageBuildTime / 2 ))
    record_build_stat "average_build_time" "${averageBuildTime}" "${currentPackage}"
  fi

  # Get number of times built, increment by 1 and re-write
  local numberOfTimesBuilt
  numberOfTimesBuilt="$(jq -r .number_of_times_built "${directory}/.krack-data.json")"

  if [[ "${numberOfTimesBuilt}" == "" ]]; then
    record_build_stat "number_of_times_built" "1" "${currentPackage}"
  else
    numberOfTimesBuilt=$(( numberOfTimesBuilt + 1 ))
    record_build_stat "number_of_times_built" "${numberOfTimesBuilt}" "${currentPackage}"
  fi

  # Store build date
  record_build_stat "last_build_date" "$(date +%s)" "${currentPackage}"

  if [[ -f "${PWD}/krack-postbuild.sh" ]]; then
    print_info "Executing krack-postbuild.sh"
    # shellcheck disable=SC1091
    source "${PWD}/krack-postbuild.sh" || {
      important "Executing krack-postbuild.sh failed for ${currentPackage}. Requesting rebuild during next build cycle"
      return
    }
  fi

  for packageTarball in "${PWD}"/*.pkg.tar.zst; do
    # Store built version number
    if [[ "${builtVersionNumber:-}" == "" ]]; then
      local -r builtVersionNumber="$(tar -O -I zstd -xf "${packageTarball}" .PKGINFO | grep pkgver | cut -d' ' -f3)"
      record_build_stat "last_built_version" "${builtVersionNumber}" "${currentPackage}"
    fi

    if [[ -f "${PKG_CACHE_DIR}/${packageTarball##*/}" ]]; then
      print_info "Package ${currentPackage} with the same version is already in the krack pkgcache; the cat is mildly annoyed 😾"

      # Add to build cycle report
      # Ensure not to add duplicate entries if multiple package files exist,
      # e.g. linux*.pkg.tar.zst and linux-headers*.pkg.tar.zst
      if [[ ! "${packagesBuiltNotUploaded[*]}" =~ ${currentPackage} ]]; then
        packagesBuiltNotUploaded+=("${currentPackage}")
      fi

      continue
    fi

    print_info "Copying package to local krack-build pkgcache"
    cp "${packageTarball}" "${PKG_CACHE_DIR}"

    substate "uploading"

    if [[ "${SIGN_PACKAGES:-true}" == true ]]; then
      print_info "Signing package and uploading to ${DROPBOX_PATH}"
      gpg --default-key "${SIGNING_KEY}" --output "${packageTarball}.sig" --detach-sig "${packageTarball}"
    else
      print_info "Uploading to ${DROPBOX_PATH}"
    fi

    # Sleep for 2 seconds; sometimes the rsync command starts before the sig file has appeared on slower devices
    sleep 2
    rsync -a --progress "${packageTarball}" "${packageTarball}.sig" "krack-receive@${DROPBOX_PATH}:/home/krack-receive/package-dropbox"

    print_info "Package ${currentPackage} built and uploaded successfully; please give your cat a treat 😻"
    
    # Add to build cycle report
    # Ensure not to add duplicate entries if multiple package files exist,
    # e.g. linux*.pkg.tar.zst and linux-headers*.pkg.tar.zst
    if [[ ! "${packagesBuiltAndUploaded[*]}" =~ ${currentPackage} ]]; then
      packagesBuiltAndUploaded+=("${currentPackage}")
    fi
  done
}

#######################################
# Writes the base build data file if it doesn't exist.
# Arguments:
#   $1: pkgdir
# Returns:
#   none
#######################################
make_build_data_file()
{
  if [[ ! -f "$1/.krack-data.json" ]]; then
    printf "{\"average_build_time\": \"\",\"last_build_time\": \"\",\"last_built_version\": \"\",\"build_failure\": \"\",\"number_of_times_built\": \"\",\"last_build_date\": \"\"}" > "$1/.krack-data.json"
  fi
}

#######################################
# Records a build statistic for a specified package to that
# package's build directory.
# Arguments:
#   $1: key (i.e. last_built_version)
#   $2: value (i.e. 1.4)
#   $3: pkgname (i.e. coreutils)
# Returns:
#   none
#######################################
record_build_stat()
{
  local -r outputFile="${MASTER_BUILD_DIR}/$3/.krack-data.json"
  local -r tmpJson="$(jq ".$1 = \"$2\"" "${outputFile}")"
  printf "%s" "${tmpJson}" > "${outputFile}"
}

# Stuff for $(krackctl status) below

#######################################
# Prints an important message.
# Globals:
#   RED, NC
#   LOG_SETTING
#   STATUS_IMPORTANT_MESSAGE_FILE
# Arguments:
#   $1: info message to print
# Returns:
#   none
#######################################
important()
{
  sed -i '/nothing yet :)/d' "${STATUS_IMPORTANT_MESSAGE_FILE}"
  printf "%s\n" "$1" >> "${STATUS_IMPORTANT_MESSAGE_FILE}"
  printf "%sERROR: %s%s\n" "${RED}" "$1" "${NC}"
}

#######################################
# Updates the state of the running krack-build instance.
# Globals:
#   STATUS_DATE_BUILD_START_FILE
# Arguments:
#   $1: info message to print
# Returns:
#   none
#######################################
state()
{
  case "$1" in
    starting) state_helper "starting up..." ;;
    death) state_helper "pronounced dead at $(date). Kitty is sad 😿" ;;
    sleeping) printf "%sInfo:%s %sNapping until next rebuild" "${WHITE}" "${NC}" "${PURPLE}"
              state_helper "sleeping until next rebuild"
              ;;
    building) state_helper "building package ${BUILD_INDEX}/${NUM_TOTAL_PACKAGES}, (started at $(date +%r))"
              ;;
    *) state_helper "unknown. Contact developer at dev@krathalan.net" ;;
  esac
}

state_helper()
{
  printf "%s" "$1" > "${STATUS_STATE_FILE}"
}

#######################################
# Updates the substate of the running krack-build instance.
# Globals:
#   currentPackage
#   STATUS_DATE_BUILD_START_FILE
# Arguments:
#   none
# Returns:
#   none
#######################################
substate()
{
  case "$1" in
    checking) substate_helper "checking if ${currentPackage} needs building" ;;
    making) substate_helper "making ${currentPackage}"
            date +%s > "${STATUS_DATE_MAKE_START_FILE}"
            ;;
    uploading) substate_helper "uploading built ${currentPackage} package" ;;
  esac
}

substate_helper()
{
  printf "%s" "$1" > "${STATUS_SUBSTATE_FILE}"
}

#######################################
# Generates a final build cycle report.
# Globals:
#   STATUS_DATE_BUILD_START_FILE
#   packagesBuiltAndUploaded
#   packagesBuiltNotUploaded
#   packagesFailedToBuild
#   packagesUpToDate
# Arguments:
#   none
# Returns:
#   build cycle report
#######################################
generate_report()
{
  # Calculate length of build cycle 
  local -r startTimeUnix="$(<"${STATUS_DATE_BUILD_START_FILE}")"
  local -r timeNowUnix="$(date +%s)"
  local -r buildCycleLength="$(( timeNowUnix - startTimeUnix ))"

  # Calculate package percentages
  local -r percentBuiltAndUploaded="$(printf "result = (%s / %s) * 100; scale=2; result / 1\n" "${#packagesBuiltAndUploaded[@]}" "${NUM_TOTAL_PACKAGES}" | bc -l)"
  local -r percentBuiltNotUploaded="$(printf "result = (%s / %s) * 100; scale=2; result / 1\n" "${#packagesBuiltNotUploaded[@]}" "${NUM_TOTAL_PACKAGES}" | bc -l)"
  local -r percentFailedToBuild="$(printf "result = (%s / %s) * 100; scale=2; result / 1\n" "${#packagesFailedToBuild[@]}" "${NUM_TOTAL_PACKAGES}" | bc -l)"
  local -r percentUpToDate="$(printf "result = (%s / %s) * 100; scale=2; result / 1\n" "${#packagesUpToDate[@]}" "${NUM_TOTAL_PACKAGES}" | bc -l)"
  local -r percentGitPullFailed="$(printf "result = (%s / %s) * 100; scale=2; result / 1\n" "${#packagesGitPullFailed[@]}" "${NUM_TOTAL_PACKAGES}" | bc -l)"

  printf "## Build cycle report for %s ##\n" "$(date -d @"$(cat "${STATUS_DATE_MAKE_START_FILE}")" "+%c")"

  printf "Total number of packages: %s\n" "${NUM_TOTAL_PACKAGES}"
  
  printf "Duration of build cycle: %s\n\n" "$(date -d@${buildCycleLength} -u +%Hh:%Mm:%Ss)"

  printf "Number of packages built and uploaded successfully: %s (%s%%)\n" "${#packagesBuiltAndUploaded[@]}" "${percentBuiltAndUploaded}"
  if [[ "${#packagesBuiltAndUploaded[@]}" != 0 ]]; then
    printf "Packages: %s\n\n" "${packagesBuiltAndUploaded[*]}"
  else
    printf "\n"
  fi

  printf "Number of packages where the same version was rebuilt: %s (%s%%)\n" "${#packagesBuiltNotUploaded[@]}" "${percentBuiltNotUploaded}"
  if [[ "${#packagesBuiltNotUploaded[@]}" != 0 ]]; then
    printf "Packages: %s\n\n" "${packagesBuiltNotUploaded[*]}"
  else
    printf "\n"
  fi

  printf "Number of packages that failed to build: %s (%s%%)\n" "${#packagesFailedToBuild[@]}" "${percentFailedToBuild}"
  if [[ "${#packagesFailedToBuild[@]}" != 0 ]]; then
    printf "Packages: %s\n\n" "${packagesFailedToBuild[*]}"
  else
    printf "\n"
  fi

  printf "Number of packages where git pull failed, preventing build: %s (%s%%)\n" "${#packagesGitPullFailed[@]}" "${percentGitPullFailed}"
  if [[ "${#packagesGitPullFailed[@]}" != 0 ]]; then
    printf "Packages: %s\n\n" "${packagesGitPullFailed[*]}"
  else
    printf "\n"
  fi

  printf "Number of packages not built/up-to-date: %s (%s%%)\n" "${#packagesUpToDate[@]}" "${percentUpToDate}"
}

# -----------------------------------------
# ---------------- Script -----------------
# -----------------------------------------

[[ "$(whoami)" == "root" ]] &&
  exit_script_on_failure "This script should NOT be run as root (or sudo)!"

# Ensure all important directories exist
# Making FAILED_BUILDS_LOG_DIR makes LOG_DIR
mkdir -p "${MASTER_BUILD_DIR}" \
         "${CCACHE_DIR}" \
         "${FAILED_BUILDS_LOG_DIR}" \
         "${GIT_DIFF_DIR}" \
         "${PKG_CACHE_DIR}" \
         "${STATUS_DIR}"

state "starting"
printf "nothing yet :)" > "${STATUS_IMPORTANT_MESSAGE_FILE}"

if [[ ! -e "${MAKECHROOTPKG_DIR}" ]]; then
  exit_script_on_failure "Makechrootpkg dir ${MAKECHROOTPKG_DIR} doesn't exist. Please run \`sudo krackctl create-chroot\`, or create your own build chroot and specify its location in /etc/krack/build.conf."
fi

print_info "Good morning honey, it is $(date) and it's time to build packages again"
print_info "Flushing important messages log"
printf "nothing yet :)" > "${STATUS_IMPORTANT_MESSAGE_FILE}"

print_info "Cleaning out pkgcache and master package directories before building"
clean_pkgbuild_dirs

# Make/empty report file to print after build cycle
cat /dev/null > "${STATUS_REPORT_FILE}"

# Update build chroot once before each build cycle, not each time
# we build with makechrootpkg (to reduce strain on mirrors)
print_info "Upgrading build chroot before building"
arch-nspawn "${MAKECHROOTPKG_DIR}/root" pacman -Syu --noconfirm || exit_script_on_failure "Failed to update build chroot"

# Get number of packages in ${MASTER_BUILD_DIR}
readonly NUM_TOTAL_PACKAGES="$(( $(find -L "${MASTER_BUILD_DIR}" -maxdepth 1 -type d | wc -l) - 1 ))"

# Set start time for krackctl
date +%s > "${STATUS_DATE_BUILD_START_FILE}"

# Check for empty build dir (https://unix.stackexchange.com/a/202276)
# shellcheck disable=2010
if ls -1qA "${MASTER_BUILD_DIR:-}" | grep -q .; then
  # Run build cycle
  for pkgdir in "${MASTER_BUILD_DIR:-}"/*; do
    if [[ -d "${pkgdir}" ]]; then
      state "building"
      build_package "${pkgdir}"
      BUILD_INDEX="$(( BUILD_INDEX + 1 ))"
    fi
  done
  print_info "It is $(date) and all packages have finished building"
else
  print_info "There are no package directories in ${MASTER_BUILD_DIR:-}."
fi

generate_report > "${STATUS_REPORT_FILE}"

state "sleeping"
