#!/bin/bash

# stop on any unhandled error
set -e

# @TODO: pipefail not in POSIX
set -o pipefail

# Expand non-matching globs to nothing (not even an empty string)
shopt -s nullglob

# show commands (debug)
#set -x

display_help() {
  cat << EOF
usage: debcraft <build|improve|test|release|update|shell|logs|prune> [options] [<path|pkg|srcpkg|dsc|git-url>]

Debcraft is a tool to easily build .deb packages. The 'build' argument accepts
any of the following:

  * path to directory with program sources including a debian/ subdirectory with
    the Debian packaging instructions

  * path to a .dsc file and source tarballs that can be built into a .deb

  * Debian package name or source package name that apt can download

  * git http(s) or ssh URL that can be downloaded and built

The command 'improve' will try to apply various improvements to the package
based on tools in Debian that automate package maintenance. The command 'test'
will run the Debian-specific regression test suite if the package has
autopkgtest support, and drops to a shell for investigation if tests fail to
pass. The command 'release' uploads a package that is ready to be released and
the command 'update' tries to update the package to the latest upstream version
if the package git repository layout is compatible.

The command 'shell' can be used to explore the container and 'prune' will
clean up temporary files created by Debcraft.

In addition to parameters below, anything passed in DEB_BUILD_OPTIONS will also
be honored (currently DEB_BUILD_OPTIONS='$DEB_BUILD_OPTIONS'). Successful builds
include running './debian/rules clean' to clean up artifacts, while failed
builds will leave them around for inspection.

Note that Debcraft builds never runs as root, and thus packages with
DEB_RULES_REQUIRES_ROOT are not supported.

optional arguments:
  --build-dirs-path       Path for writing build files and artifacts (default: ~/.cache/debcraft)
  --distribution          Linux distribution to build in (default: debian:sid)
  --container-command     Container command to use (default: podman)
  --host-architecture     Host architecture to use when performing a cross build
  --skip-sources          Build only binaries and skip creating a source
                          tarball to make the build slightly faster
                          ('debcraft build' only)
  --with-binaries         Create a release with both source and binaries,
                          for example with the intent to upload to NEW
                          ('debcraft release' only)
  --pull                  Ensure container base is updated
  --copy                  Perform the build on a copy of the package directory
  --clean                 Ensure sources are clean before and after build
                          (only needed for packages with incomplete 'debian/clean'
                          or 'debian/.gitignore' definitions)
  --extra-repository      Use directory as local package repository for builds
  --config                Path to debcraft configuration file
  --release-to            After build or release, copy artefacts to specified dir
  --debug                 Emit debug information
  -h, --help              Display this help and exit
  --version               Display version and exit

To learn more, or to contribute to Debcraft, see project page at
$(clickable_link "salsa.debian.org/debian/debcraft")

To gain more Debian Developer knowledge, please read
$(clickable_link "www.debian.org/doc/manuals/developers-reference/")
and $(clickable_link "www.debian.org/doc/debian-policy/")
EOF
}

# Canonicalize script name if it was run via symlink
DEBCRAFT_CMD_PATH="$(readlink --canonicalize-existing --verbose "$0")"
DEBCRAFT_LIB_DIR="$(dirname "$DEBCRAFT_CMD_PATH")/src"

# Assume system installation directory if not running from source directory
if [ ! -r "$DEBCRAFT_LIB_DIR/container/output.inc.sh" ]
then
  DEBCRAFT_LIB_DIR="/usr/share/debcraft"
fi

# If Debcraft itself is run from a git repository, include the git commit id
display_version() {
  if [ -e "$(dirname "$DEBCRAFT_CMD_PATH")/.git" ]
  then
    cd "$(dirname "$DEBCRAFT_CMD_PATH")"
    if [ -z "$(git tag --list)" ]
    then
      echo "DEBCRAFT ERROR: Unable to find latest git tag. Please run 'git fetch --tags'."
      exit 1
    fi
    LATEST_TAG="$(git describe --first-parent --abbrev=0)"
    LATEST_VERSION="$(echo "$LATEST_TAG" | grep --only-matching --basic-regexp '[0-9.]*')"
    LATEST_COMMIT="$(git rev-parse --short HEAD)"
    echo "Debcraft $LATEST_VERSION-$LATEST_COMMIT"
  elif [ -f /usr/share/doc/debcraft/changelog.gz ]
  then
    VERSION="$(
      zgrep --only-matching --max-count=1 --perl-regexp '\(\K[^\)]*' \
      /usr/share/doc/debcraft/changelog.gz
      )"
    echo "Debcraft $VERSION"
  else
    echo "Debcraft version unknown: neither git version control nor installed package was found"
  fi
}

# Debug flag detection must run before output.inc.sh is loaded
case "$@" in
  *--debug*)
    export DEBUG="true"
    ;;
esac

# Output formatting library is reused inside container as well
# shellcheck source=src/container/output.inc.sh
source "$DEBCRAFT_LIB_DIR/container/output.inc.sh"

# Initialize TITLE_UPDATE_PID early to prevent unbound variable errors
# and allow setting title before package name is known (e.g. during download)
TITLE_UPDATE_PID=""

if [ -z "$1" ]
then
  log_error "Missing argument <build|improve|test|release|shell|logs|prune>"
  echo
  display_help
  exit 1
fi

while :
do
  log_debug "Parse option/argument: $1"
  case "$1" in
    --build-dirs-path)
      if [ -z "$2" ] || [ ! -d "$2" ]
      then
        log_error "Parameter --build-dirs-path requires a path"
        exit 1
      fi
      BUILD_DIRS_PATH="$(readlink -f "$2")"
      export BUILD_DIRS_PATH
      log_debug "Using BUILD_DIRS_PATH=$BUILD_DIRS_PATH"
      shift 2
      ;;
    --distribution)
      # The value is the next argument, e.g. '--distribution bookworm'
      export DISTRIBUTION="$2"
      log_debug "Using DISTRIBUTION=$2"
      shift 2
      ;;
    --container-command)
      export CONTAINER_CMD="$2"
      log_debug "Using CONTAINER_CMD=$2"
      shift 2
      ;;
    --host-architecture)
      export HOST_ARCH="$2"
      shift 2
      ;;
    --skip-sources)
      export SKIP_SOURCES="true"
      shift
      ;;
    --with-binaries)
      export WITH_BINARIES="true"
      shift
      ;;
    --pull)
      export PULL="true"
      shift
      ;;
    --clean)
      export CLEAN="true"
      shift
      ;;
    --copy)
      export COPY="true"
      shift
      ;;
    --extra-repository)
      export DEBCRAFT_EXTRA_REPOSITORY="$2"
      shift 2
      ;;
    --release-to)
      export DEBCRAFT_RELEASE_TO="$2"
      shift 2
      ;;
    --yes)
      export DEBCRAFT_YES="true"
      shift
      ;;
    --config)
      log_debug "Using CONFIG=$2"
      export CONFIG="$2"
      shift 2
      ;;
    --debug)
      # Debug mode detection is already done earlier, ignore it at this stage
      shift
      ;;
    -h | --help)
      display_help  # Call your function
      exit 0
      ;;
    --version)
      display_version
      exit 0
      ;;
    --)
      # No more options
      shift
      break
      ;;
    -*=*)
      log_error "Equals sign syntax in options unsupported: $1"
      exit 1
      ;;
    -*)
      log_error "Unknown option: $1"
      ## or call function display_help
      exit 1
      ;;
    build | improve | test | release | update | shell | logs | prune)
      export ACTION="$1"
      shift
      ;;
    *)
      export TARGET="$1"
      # No more options or arguments
      break
      ;;
  esac
done

if [ -z "$ACTION" ]
then
  # If ACTION is empty the TARGET might have been populated
  log_error "Argument '$TARGET' is not one of <build|improve|test|release|update|shell|logs|prune>"
  echo
  display_help
  exit 1
fi

if [ -n "$SKIP_SOURCES" ] && [ "$ACTION" != "build" ]
then
  log_error "Parameter --skip-sources can only be used with action 'build'"
  echo
  display_help
  exit 1
fi

if [ -n "$WITH_BINARIES" ] && [ "$ACTION" != "release" ]
then
  log_error "Parameter --with-binaries can only be used with action 'release'"
  echo
  display_help
  exit 1
fi

if [ -n "$DISTRIBUTION" ] &&
{
  [ "$ACTION" == "logs" ] || [ "$ACTION" == "update" ]
}
then
  log_error "Parameter --distribution is not supported for action '$ACTION'"
  echo
  display_help
  exit 1
fi

log_debug_var ACTION

# This variable allows debcraft to map distribution names to specific container
# images using a configuration file sourced below.
declare -A DEBCRAFT_DISTRIBUTION_MAPPING

# Source debcraft's configuration files if they exist
# shellcheck source=/dev/null
[[ -f /etc/debcraft ]] && source /etc/debcraft
# shellcheck source=/dev/null
[[ -f "$HOME/.config/debcraft" ]] && source "$HOME/.config/debcraft"
# shellcheck source=/dev/null
[[ -n "$CONFIG" && -f "$CONFIG" ]] && source "$CONFIG"

# shellcheck source=src/distributions.inc.sh
source "$DEBCRAFT_LIB_DIR/distributions.inc.sh"

# shellcheck source=src/generic.inc.sh
source "$DEBCRAFT_LIB_DIR/generic.inc.sh"

# Configure general program behavior after user options and arguments have been parsed
# shellcheck source=src/config-general.inc.sh
source "$DEBCRAFT_LIB_DIR/config-general.inc.sh"

# Check that dependencies are available
for cmd in git tee sed
do
  if ! command -v "$cmd" > /dev/null 2>&1
  then
    log_error "Required dependency '$cmd' is not installed"
    exit 1
  fi
done

# Bash must be new enough to have 'mapfile'.
if ! type mapfile > /dev/null 2>&1
then
  log_error "Bash version too old - 'mapfile' builtin is required"
  exit 1
fi

# Docker does not support '--noheading', so the command will always output at
# least one line
# Hack: Listing images is very slow on Podman, so branch off two different tests
# shellcheck disable=2235 # two subshells necessary in this case
if ([ "$CONTAINER_TYPE" == 'podman' ] && \
    ! grep --quiet debcraft ~/.local/share/containers/storage/overlay-images/images.json 2>/dev/null) \
   || \
   ([ "$CONTAINER_TYPE" == 'docker' ] && \
   [ "$($CONTAINER_CMD images 'debcraft*' | wc -l)" -eq 1 ])
then
  log_warn "No previous Debcraft container was found and thus the first run of"
  log_warn "this tool is expected to be slow as the container base layer needs"
  log_warn "to be built. Re-runs of Debcraft will be fast."
fi

log_debug_var TARGET

if [ -z "$TARGET" ]
then
  # If no argument defined, default to current directory
  TARGET="$(pwd)"
elif [ -d "$TARGET" ]
then
  # If an argument was given, and it is a directory, use TARGET as-is
  :
elif [ ! -d "$TARGET" ] && [[ "build improve update" =~ $ACTION ]]
then
  # If the argument exists, but didn't point to a valid path, try to use the
  # argument to download the package

  # Set title early to show activity during potentially long download
  title_running "download $TARGET"
  # Ensure title is cleaned up if download fails before main trap is set
  trap 'kill $TITLE_UPDATE_PID 2>/dev/null || true; title_clear' EXIT
  trap 'kill $TITLE_UPDATE_PID 2>/dev/null || true; title_clear; exit 130' INT TERM

  # shellcheck source=src/downloader-container.inc.sh
  source "$DEBCRAFT_LIB_DIR/downloader-container.inc.sh"

  # shellcheck source=src/downloader.inc.sh
  source "$DEBCRAFT_LIB_DIR/downloader.inc.sh"

  # After a download, a subdirectory <TARGET>-<VERSION> might be present, and
  # if a container build already ran, also a debcraft-container-<TARGET> will
  # be present and that should be excluded from the listing below that aims to
  # find the downloaded sources.

  # shellcheck disable=SC2012
  NEWEST_DIRECTORY="$(ls --sort=time --time=ctime --format=single-column --group-directories-first --hide="debcraft-container-*" | head --lines=1 || true)"
  # Note! Use ctime above as dpkg-source will keep original mtime for packages
  # and ensure it never emits exit codes, only text output

  # Remove shortest pattern from end of variable, e.g. 'xz-utils-5.2.4' -> 'xz-utils'
  # (https://tldp.org/LDP/abs/html/parameter-substitution.html)
  #PACKAGE="${NEWEST_DIRECTORY%-*}"
  # Ensure source directory exists with the package name, e.g. 'grep' -> 'grep-3.4'
  #ln --verbose --force --symbolic "$NEWEST_DIRECTORY" "$PACKAGE"

  # Newest directory contains the downloaded source package now, use it as TARGET
  export TARGET="$NEWEST_DIRECTORY"
else
  log_error "Debcraft command '$ACTION' can only be used after a build has run," \
            "and a directory with the Debian package source exists."
  exit 1
fi

log_debug_var TARGET

# The previous step guarantees that the source directory either exists, was
# downloaded or the script execution stopped. From here onward the script can
# assume that $PWD is a working directory with sources.
cd "$TARGET" || (log_error "Unable to change directory to $TARGET"; exit 1)

log_debug_var PWD

if [ -f "debian/changelog" ]
then
  # If parsing the changelog emits exit code, intentionally stop here
  PACKAGE="$(head -n 1 debian/changelog | cut -d ' ' -f 1)"
else
  log_error "Directory '$TARGET' is not a valid source package directory as" \
            "debian/changelog was not found"
  exit 1
fi

log_info "Running in directory $PWD that has Debian package sources for '$PACKAGE'"

# Start title animation in background and ensure cleanup on exit
# Note: TITLE_UPDATE_PID was already initialized at script start and may
# contain a PID from the download phase; title_animate will kill it
title_running "$ACTION $PACKAGE"
# Trap EXIT, INT (Ctrl+C), and TERM to ensure title always resets
trap 'kill $TITLE_UPDATE_PID 2>/dev/null || true; title_clear' EXIT
trap 'kill $TITLE_UPDATE_PID 2>/dev/null || true; title_clear; exit 130' INT TERM

# Make sure sources are clean on actions that depend on it
if [ "$ACTION" == "build" ] || [ "$ACTION" == "release" ]
then
  # Every deb build potentially generates these temporary files, and it is safe
  # to assume that they should be deleted before restarting the build, so do it
  # automatically to make the overall experience friendlier
  rm -rfv debian/.debhelper debian/debhelper-build-stamp \
          debian/debhelper.log debian/*.debhelper.log \
          debian/substvars debian/*.substvars debian/files

  # If there are still more files, the user needs to make a decision
  if [ -z "$CLEAN" ] &&
     [ -z "$COPY" ] &&
     [ -d "$PWD/.git" ] &&
     [ -n "$(git status --porcelain --ignored --untracked-files=all)" ]
  then
    log_error "Modified or additional files found"\
              "\n$(git status --porcelain --ignored --untracked-files=all | head)"\
              "\nThese may be leftovers of a failed build, or an incomplete"\
              "\nrun of './debian/rules clean' after a successful build." \
              "\nUse --clean to remove all untracked and uncommitted files"\
              "\nor run with --copy to build in a separate new directory."
    exit 1
  fi
fi

# @TODO: If repository is a git clone, but not a `gbp clone`, it can be converted
# to gbp with `gbp pull --verbose --ignore-branch --pristine-tar --track-missing`

# Clean up before the build if applicable
reset_if_source_repository_and_option_clean

# Configure program behavior after user options and arguments have been parsed
# shellcheck source=src/config-package.inc.sh
source "$DEBCRAFT_LIB_DIR/config-package.inc.sh"

# If the action needs to run in a container, automatically create it
if [ "$ACTION" != "update" ] && [ "$ACTION" != "prune" ] && [ "$ACTION" != "logs" ]
then
  # shellcheck source=src/container.inc.sh
  source "$DEBCRAFT_LIB_DIR/container.inc.sh"
fi

case "$ACTION" in
  build)
    # shellcheck source=src/build.inc.sh
    source "$DEBCRAFT_LIB_DIR/build.inc.sh"
    ;;
  improve)
    # shellcheck source=src/improve.inc.sh
    source "$DEBCRAFT_LIB_DIR/improve.inc.sh"
    ;;
  test)
    # shellcheck source=src/test.inc.sh
    source "$DEBCRAFT_LIB_DIR/test.inc.sh"
    ;;
  release)
    # shellcheck source=src/release.inc.sh
    source "$DEBCRAFT_LIB_DIR/release.inc.sh"
    # shellcheck source=src/release-ppa.inc.sh
    source "$DEBCRAFT_LIB_DIR/release-ppa.inc.sh"
    # shellcheck source=src/release-dput.inc.sh
    source "$DEBCRAFT_LIB_DIR/release-dput.inc.sh"
    ;;
  update)
    # shellcheck source=src/update.inc.sh
    source "$DEBCRAFT_LIB_DIR/update.inc.sh"
    ;;
  shell)
    # shellcheck source=src/shell.inc.sh
    source "$DEBCRAFT_LIB_DIR/shell.inc.sh"
    ;;
  logs)
    # shellcheck source=src/logs.inc.sh
    source "$DEBCRAFT_LIB_DIR/logs.inc.sh"
    ;;
  prune)
    # shellcheck source=src/prune.inc.sh
    source "$DEBCRAFT_LIB_DIR/prune.inc.sh"
    ;;
esac

# Clean up after a successful build if applicable
reset_if_source_repository_and_option_clean
