#!/bin/bash
# Copyright (C) 2015, 2019 Opinsys Oy
#
# 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, either version 2 of the License, or
# (at your option) any later version.
#
# 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 <http://www.gnu.org/licenses/>.

g_puavo_pkg_cachedir=/var/cache/puavo-pkg
g_puavo_pkg_rootdir=/var/lib/puavo-pkg
g_puavo_pkg_readonly_cachedir="$g_puavo_pkg_cachedir"
g_puavo_pkg_readonly_rootdir="$g_puavo_pkg_rootdir"
g_puavo_pkg_delete_cache_after_days=14

# These can not be overridden with PUAVO_PKG_ROOTDIR by design
# (we could also move it somewhere else than /var/lib/puavo-pkg/*).
g_puavo_pkg_available_dir="${g_puavo_pkg_rootdir}/available"
g_puavo_pkg_installed_dir="${g_puavo_pkg_rootdir}/installed"

LOG_EMERG=0
LOG_ALERT=1
LOG_CRIT=2
LOG_ERR=3
LOG_WARNING=4
LOG_NOTICE=5
LOG_INFO=6
LOG_DEBUG=7

g_log_level=${LOG_INFO}

SUPPORTED_PKG_FORMAT=2

## Exit status codes:
SC_OK=0         ## Success
SC_ERR=1        ## Generic error
SC_ERR_NET=2    ## Network failure
SC_ERR_NOPKG=3  ## Package does not exist
SC_ERR_BUSY=4   ## Software is busy, retry later

g_sc=${SC_ERR} ## Prime with a generic error.

# XXX get rid of hosttype handling
puavo_hosttype=$(puavo-conf puavo.hosttype)

on_exit()
{
  return ${g_sc}
}

print_help_and_exit()
{
  cat <<'EOF'
Usage: puavo-pkg [OPTIONS] [COMMAND] [COMMAND_ARGS]

Package manager for Puavo packages.

Options:
  -h, --help                     print this help and exit
  -q, --quiet                    less verbose output, can be given
                                 multiple times to silence puavo-pkg

Commands:
  gc-installations               garbage collect puavo-pkg installations
  gc-upstream-packs              garbage collect upstream packs

  install INSTALLER              install package with an installer

  license INSTALLER|PACKAGE      print license URL

  list                           list installed packages

  prepare INSTALLER              prepare package with an installer,
                                 but do not configure it into use

  reconfigure PACKAGE            reconfigure installed PACKAGE
  reconfigure --all              reconfigure all installed packages

  reconfigure-upgrade [--ignore-unconfigure-error] PKG DIR
                                 reconfigure package from new directory
                                 (optionally ignore old package unconfigure error)

  remove PACKAGE                 remove installed PACKAGE
  remove --all                   remove all installed packages

  show INSTALLER|PACKAGE         show package details
  show -F|--field FIELD PACKAGE  show specific FIELD

  switch-to-system PACKAGE       switch to image puavo-pkg version (if exists)
                                 and purge the local versions (if those exist)

  unconfigure PACKAGE            unconfigure installed PACKAGE

Files:
  /etc/puavo-pkg/puavo-pkg.conf  configuration file

Exit status codes:
  0 - Success
  1 - Generic error
  2 - Network failure
EOF
  g_sc=${SC_OK}
  exit 0
}

logmsg()
{
  local human_prio syslog_prio

  human_prio=$1;  shift
  syslog_prio=$1; shift

  # this should behave nicely in image build as well,
  # where /dev/log may not exist
  if [ -S /dev/log ]; then
    logger -p "$syslog_prio" -t puavo-pkg "$@" || true
  fi
  echo "PUAVO-PKG ${human_prio}: $@" >&2
}

log_error()
{
  if [ "$g_log_level" -ge "$LOG_ERR" ]; then
    logmsg ERROR user.err "$@"
  fi
}

log_info()
{
  if [ "$g_log_level" -ge "$LOG_INFO" ]; then
    logmsg INFO user.info "$@"
  fi
}

log_warning()
{
  if [ "$g_log_level" -ge "$LOG_WARNING" ]; then
    logmsg WARNING user.warn "$@"
  fi
}

check_puavopkg_name() {
  if ! printf "%s" "$1" | grep -Eqx '[a-z0-9_-]+'; then
    log_error "bad puavo-pkg package name: '$1'"
    g_sc=${SC_ERR}
    return 1
  fi
  return 0
}

gc_installations() {
  local currently_available_link currently_installed_link gc_status \
        latest_prepared_time msg pkg_dir pkg_installation pkg_name

  gc_status=$SC_OK

  for pkg_dir in "${g_puavo_pkg_rootdir}"/packages/*; do
    test -d "$pkg_dir" || continue

    pkg_name="$(basename "$pkg_dir")"
    check_puavopkg_name "$pkg_name" || continue

    currently_available_link="${g_puavo_pkg_available_dir}/${pkg_name}"
    currently_installed_link="${g_puavo_pkg_installed_dir}/${pkg_name}"

    for pkg_installation in "$pkg_dir"/*; do
      test -d "$pkg_installation" || continue

      if ! [ "$pkg_installation" -ef "$currently_available_link" \
               -o "$pkg_installation" -ef "$currently_installed_link" ]; then
        latest_prepared_time=''
        # Because fatclients may need puavo-pkg packages even when they are
        # not installed on bootservers (only prepared), in bootserver case
        # we also look at the latest timestamp on ".prepared"-file before
        # doing any garbage collection.
        if [ "$puavo_hosttype" = 'bootserver' ]; then
          latest_prepared_time=$(stat -c %Y "${pkg_installation}/.prepared" \
                                   2>/dev/null || true)
        fi

        # 5184000 seconds = 60 days * (24 * 60 * 60) * seconds/day
        if [ -z "$latest_prepared_time" -o \
             "$(($latest_prepared_time + 5184000))" -le "$(date +%s)" ]; then
          if rm -rf "$pkg_installation"; then
            log_info "removed old installation in $pkg_installation"
          else
            msg="could not remove old installation in $pkg_installation"
            log_error "$msg"
            gc_status=$SC_ERR
          fi
        fi
      fi
    done

    rmdir --ignore-fail-on-non-empty "$pkg_dir" 2>/dev/null || true
  done

  if [ "$gc_status" = "$SC_ERR" ]; then
    g_sc=$gc_status
    return 1
  fi

  return 0
}

gc_upstream_packs() {
  local cache_subdir delete_if_older_than gc_status last_modified \
        old_upstream_pack old_upstream_pack_list time_now upstream_pack_path

  [ -n "$g_puavo_pkg_cachedir" ] || return 0

  gc_status=$SC_OK

  # remove possible temporary files in upstream pack cache
  rm -f "$g_puavo_pkg_cachedir"/upstream_packs/*/*.tmp 2>/dev/null || true

  # remove upstream packs in case their last modification time is over
  # ${g_puavo_pkg_delete_cache_after_days} days
  if ! time_now=$(date +%s); then
    log_error 'could not lookup time'
    gc_status=$SC_ERR
    return 1
  fi
  delete_if_older_than=$((${time_now} - ${g_puavo_pkg_delete_cache_after_days} * 24 * 60 * 60))
  for upstream_pack_path in "$g_puavo_pkg_cachedir"/upstream_packs/*/*; do
    test -f "$upstream_pack_path" || continue
    if ! last_modified=$(stat -c %Y "$upstream_pack_path"); then
      log_error "could not lookup last modified time of ${upstream_pack_path}"
      gc_status=$SC_ERR
      continue
    fi
    if [ "$last_modified" -lt "$delete_if_older_than" ]; then
      if rm -f "$upstream_pack_path"; then
        log_info "removed stale pack ${upstream_pack_path} from cache"
      else
        log_error "error removing stale pack ${upstream_pack_path} from cache"
        gc_status=$SC_ERR
      fi
    fi
  done

  # keep only one of the remaining ones
  for cache_subdir in "$g_puavo_pkg_cachedir"/upstream_packs/*; do
    test -d "$cache_subdir" || continue

    old_upstream_pack_list=$(ls -1t "$cache_subdir" | sed -n '2,$p')
    for old_upstream_pack in $old_upstream_pack_list; do
      upstream_pack_path="${cache_subdir}/${old_upstream_pack}"
      if rm -f "$upstream_pack_path"; then
        log_info "removed old pack ${upstream_pack_path} from cache"
      else
        log_error "error removing old pack ${upstream_pack_path} from cache"
        gc_status=$SC_ERR
      fi
    done
    rmdir --ignore-fail-on-non-empty "$cache_subdir" 2>/dev/null || true
  done

  if [ "$gc_status" = "$SC_ERR" ]; then
    g_sc=$gc_status
    return 1
  fi

  return 0
}

list_installed_pkg_dirs()
{
  find "$g_puavo_pkg_installed_dir" \
      -maxdepth 1                   \
      -mindepth 1                   \
      -type l                       \
      -exec readlink -z -e {} \;    \
    | sort -z
}

list_installed_pkgs()
{
  local pkg_dir pkg_name

  while read -d '' pkg_dir; do
    pkg_name=$(get_pkg_name "$pkg_dir") || return 1
    echo "$pkg_name" || return 1
  done < <(list_installed_pkg_dirs)
}

list_installed_pkgs_with_dirs()
{
  local col1_len pkg_dir pkg_name

  col1_len=$(awk 'BEGIN { max = 0 }
                        { max = (length($0) > max) ? length($0) : max }
                  END   { print max }' < <(list_installed_pkgs)) || return 1

  while read -d '' pkg_dir; do
    pkg_name=$(get_pkg_name "$pkg_dir") || return 1
    pkg_id=$(get_pkg_id "$pkg_dir")
    printf "%-${col1_len}s    %s\n" "$pkg_name" "$pkg_id"
  done < <(list_installed_pkg_dirs)
}

get_license_url()
{
  local identifier pkg_dir INSTALLER_REGEX

  identifier=$1
  INSTALLER_REGEX='\.tar\.gz$'

  if [[ "${identifier}" =~ ${INSTALLER_REGEX} ]]; then
    pkg_dir=$(extract_installer "${identifier}") || {
      log_error "failed to extract installer '${identifier}'"
      return 1
    }
  else
    pkg_dir=$(get_available_pkg_dir "$identifier") || return 1
  fi

  [ -f "${pkg_dir}/license" ] && echo "file://${pkg_dir}/license"
}

get_pkg_link()
{
  echo "${g_puavo_pkg_installed_dir}/$1"
}

get_pkg_dir()
{
  local pkg_dir pkg_id pkg_name

  pkg_name=$1
  pkg_id=$2

  pkg_dir="${g_puavo_pkg_readonly_rootdir}/packages/${pkg_name}/${pkg_id}"
  if [ -d "$pkg_dir" ]; then
    echo "$pkg_dir"
    return 0
  fi

  echo "${g_puavo_pkg_rootdir}/packages/${pkg_name}/${pkg_id}"
}

get_writable_pkg_dir()
{
  local pkg_id pkg_name

  pkg_name=$1
  pkg_id=$2

  echo "${g_puavo_pkg_rootdir}/packages/${pkg_name}/${pkg_id}"
}

get_available_pkg_dir()
{
  readlink -e "${g_puavo_pkg_available_dir}/$1"
}

get_installed_pkg_id()
{
  local pkg_dir pkg_name

  pkg_name=$1
  pkg_dir=$(readlink -e "$(get_pkg_link "$1")") || true

  [ -n "$pkg_dir" ] || return 0

  basename "$pkg_dir"
}

get_pkg_name()
{
  local pkg_basedir pkg_dir pkg_name

  pkg_dir=$1
  pkg_basedir=$(dirname "$pkg_dir") || return 1

  pkg_name=$(basename "$pkg_basedir")
  check_puavopkg_name "$pkg_name" || return 1

  printf %s "$pkg_name"
}

get_pkg_id()
{
  basename "$1"
}

get_pkg_version() {
  awk '$1 == "version" { print $2 }' "${1}/.puavo-pkg-version"
}

configure_pkg()
{
  local pkg_dir pkg_id pkg_link pkg_name upstream_dir

  pkg_name=$1
  pkg_id=$2
  check_puavopkg_name "$pkg_name" || return 1
  pkg_dir=$(get_pkg_dir "$pkg_name" "$pkg_id") || return 1

  if [ ! -e "${pkg_dir}/.puavo-pkg-version" ]; then
    log_error "${pkg_name}: not configuring, missing version info"
    return 1
  fi

  upstream_dir="${pkg_dir}/upstream"
  pkg_link=$(get_pkg_link "$pkg_name") || return 1

  pushd "$pkg_dir" >/dev/null || return 1
  ./rules configure "$upstream_dir" || {
    popd > /dev/null 2>&1 || true
    log_error "failed to configure package '${pkg_name}'"
    return 1
  }
  popd > /dev/null 2>&1 || true

  # test with -e for faster (typical) execution
  if [ -e "${pkg_dir}/.upgrade_failed_due_to_old_pkg_unconfigure" ]; then
    rm -f "${pkg_dir}/.upgrade_failed_due_to_old_pkg_unconfigure" || true
  fi

  ln -fns "$pkg_dir" "${g_puavo_pkg_available_dir}/${pkg_name}" || {
    log_error "failed to create an available package link"
    return 1
  }
  ln -fns "$pkg_dir" "$pkg_link" || {
    log_error "failed to create a package link"
    return 1
  }

  touch "${pkg_dir}/.installed" || true

  log_info "${pkg_name}: configured successfully" || true
}

reconfigure_pkg()
{
  local pkg_id pkg_name

  pkg_name=$1
  check_puavopkg_name "$pkg_name" || return 1
  pkg_id=$(get_installed_pkg_id "$pkg_name") || return 1

  if ! unconfigure_pkg "$pkg_name"; then
    log_error "${pkg_name}: failed to unconfigure"
    return 1
  fi

  configure_pkg "$pkg_name" "$pkg_id" || return 1
}

reconfigure_all_pkgs()
{
  local pkg pkgs retval

  pkgs=$(list_installed_pkgs) || return 1
  retval=0

  for pkg in ${pkgs}; do
    reconfigure_pkg "$pkg" || retval=1
  done

  return ${retval}
}

unconfigure_pkg()
{
  local errorcode pkg_dir pkg_id pkg_link pkg_name upstream_dir

  pkg_name=$1
  pkg_id=${2:-}
  check_puavopkg_name "$pkg_name" || return 1

  pkg_link=$(get_pkg_link "$pkg_name") || return 1
  if [ -n "$pkg_id" ]; then
    pkg_dir=$(get_pkg_dir "$pkg_name" "$pkg_id") || return 1
  else
    pkg_dir=$(readlink -e "$pkg_link") || true
  fi
  upstream_dir="${pkg_dir}/upstream"

  [ -n "$pkg_dir" ] || return 0

  pushd "$pkg_dir" >/dev/null || return 1
  ./rules unconfigure "$upstream_dir" >/dev/null || {
    errorcode=$?
    if [ "$errorcode" -eq "$SC_ERR_BUSY" ]; then
      log_warning "${pkg_name}: application is busy, retry unconfigure later"
    fi
    popd > /dev/null 2>&1 || true
    return $errorcode
  }
  popd > /dev/null 2>&1 || true

  rm -f "${pkg_dir}/.installed" || true

  rm -f "$pkg_link" || return 1

  log_info "${pkg_name}: unconfigured successfully"
}

unpack_upstream_pack()
{
  local pkg_dir pkg_id pkg_name upstream_dir upstream_pack upstream_tmpdir

  pkg_name=$1
  pkg_id=$2

  pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id") || return 1
  upstream_pack="${pkg_dir}/packs/upstream_pack"
  upstream_dir="${pkg_dir}/upstream"
  upstream_tmpdir="${upstream_dir}.tmp"

  if ! check_cksum "${pkg_dir}/upstream_pack_sha384sum" "$upstream_pack"; then
    log_error 'upstream pack has incorrect checksum,' \
        'perhaps you should purge the package and download it again?'
    return 1
  fi

  pushd "$pkg_dir" >/dev/null || return 1
  ./rules unpack "$upstream_pack" "$upstream_tmpdir" || {
    popd > /dev/null 2>&1 || true
    return 1
  }

  ## Remove the upstream pack, it is not needed anymore since we just
  ## unpacked it successfully.
  rm -f "$upstream_pack"

  log_info "${pkg_name}: unpacked upstream pack successfully"

  popd > /dev/null 2>&1 || true
}

check_cksum()
{
  local actual_cksum_str cksum_file cksum_file_contents file expected_cksum_str

  cksum_file=$1
  file=$2

  [ -r "$file" ] || return 1

  [ -r "$cksum_file" ] || return 0
  cksum_file_contents=$(cat "$cksum_file") || return 1
  expected_cksum_str=${cksum_file_contents#\! }

  actual_cksum_str=$(sha384sum "$file" 2>/dev/null | awk '{ print $1 }')

  [ -n "$actual_cksum_str"   ] || return 1
  [ -n "$expected_cksum_str" ] || return 1

  if [ "$actual_cksum_str" != "$expected_cksum_str" ]; then
    if [ "$expected_cksum_str" = "$cksum_file_contents" ]; then
      log_error "expected checksum '${expected_cksum_str}' in '${file}'," \
                "yet actual checksum is '${actual_cksum_str}'"
      return 1
    fi
    log_warning "expected checksum '${expected_cksum_str}' in '${file}'," \
                "yet actual checksum is '${actual_cksum_str}'," \
                "yet continuing as checksum was marked advisory only"
  fi

  return 0
}

cache_load_upstream_pack()
{
  local cached_readonly_upstream_pack cached_upstream_pack cksum_file \
        cksum_str pkg_dir pkg_id pkg_name upstream_pack

  pkg_name=$1
  pkg_id=$2

  [ -n "$g_puavo_pkg_cachedir" ] || return 0

  pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id") || return 1
  upstream_pack="${pkg_dir}/packs/upstream_pack"
  cksum_file="${pkg_dir}/upstream_pack_sha384sum"

  [ -r "${cksum_file}"  ] || return 0
  cksum_str=$(cat "${cksum_file}") || return 1

  [ -n "${cksum_str}" ] || return 0

  cached_upstream_pack="${g_puavo_pkg_cachedir}/upstream_packs/${pkg_name}/${cksum_str}"
  cached_readonly_upstream_pack="${g_puavo_pkg_readonly_cachedir}/upstream_packs/${pkg_name}/${cksum_str}"
  if [ -r "$cached_upstream_pack" ]; then
    cp -a -T "$cached_upstream_pack" "$upstream_pack" || return 1
    log_info "${pkg_name}: loaded the upstream pack from the cache"
  elif [ -r "$cached_readonly_upstream_pack" ]; then
    cp -a -T "$cached_readonly_upstream_pack" "$upstream_pack" || return 1
    log_info "${pkg_name}: loaded the upstream pack from the readonly cache"
  fi
}

cache_save_upstream_pack()
{
  local cachedir cachefile_path cksum_str pkg_dir pkg_id pkg_name \
        tmp_cachefile_path upstream_pack

  pkg_name=$1
  pkg_id=$2

  [ -n "$g_puavo_pkg_cachedir" ] || return 0

  pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id") || return 1
  upstream_pack="${pkg_dir}/packs/upstream_pack"
  cksum_str=$(sha384sum "$upstream_pack" | cut -d' ' -f1)

  if [ -z "$cksum_str" ]; then
    log_error "${pkg_name}: could not calculate checksum"
    return 1
  fi

  cachedir="${g_puavo_pkg_cachedir}/upstream_packs/${pkg_name}"
  mkdir -p "$cachedir"

  cachefile_path="${cachedir}/${cksum_str}"
  tmp_cachefile_path="${cachefile_path}.tmp"
  if ! cp -a -T "$upstream_pack" "$tmp_cachefile_path"; then
    rm -f "$tmp_cachefile_path"
    return 1
  fi
  mv "$tmp_cachefile_path" "$cachefile_path"

  log_info "${pkg_name}: saved upstream pack to cache successfully"
}

download_upstream_pack()
{
  local pkg_dir pkg_id pkg_name upstream_pack upstream_tmppack

  pkg_name=$1
  pkg_id=$2
  pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id") || return 1
  upstream_pack="${pkg_dir}/packs/upstream_pack"
  upstream_tmppack="${upstream_pack}.tmp"

  cache_load_upstream_pack "$pkg_name" "$pkg_id" || {
    log_warning "${pkg_name}: failed to load the upstream pack from the cache"
  }

  if check_cksum "${pkg_dir}/upstream_pack_sha384sum" "$upstream_pack"; then
    return 0
  fi

  if [ ! -r "${pkg_dir}/upstream_pack_url" ]; then
    pushd "${pkg_dir}" >/dev/null || return 1
    ./rules download "${upstream_tmppack}" || {
      [ $? -eq 2 ] && g_sc=${SC_ERR_NET}
      popd > /dev/null 2>&1 || true
      rm -f "$upstream_tmppack" || true
      log_error "package downloader returned an error!"
      return 1
    }
    popd > /dev/null 2>&1 || true
  else
    if ! download_upstream_pack_from_multiple_urls "$pkg_dir" \
                                                   "$upstream_tmppack"; then
      rm -f "$upstream_tmppack"
      return 1
    fi
  fi

  if ! check_cksum "${pkg_dir}/upstream_pack_sha384sum" "${upstream_tmppack}"; then
    log_error "downloaded upstream pack has incorrect checksum"
    rm -f "${upstream_tmppack}"
    return 1
  fi

  if ! mv -T "$upstream_tmppack" "$upstream_pack"; then
    rm -f "$upstream_pack"
    return 1
  fi

  if ! cache_save_upstream_pack "$pkg_name" "$pkg_id"; then
    log_warning "${pkg_name}: failed to save upstream pack to the cache"
  fi

  log_info "${pkg_name}: downloaded upstream pack successfully"
}

wget_with_opts()
{
  wget --no-use-server-timestamps \
       --no-cookies               \
       --progress=dot:mega "$@" || return 1
}

download_upstream_pack_from_multiple_urls()
{
  local cdn_archive distfile_server distfile_url pkg_dir upstream_pack_url \
        upstream_tmppack

  pkg_dir=$1
  upstream_tmppack=$2

  # try to lookup files from our own servers first, then the upstream url

  read upstream_pack_url < "${pkg_dir}/upstream_pack_url" || return 1
  upstream_pack_filename="${upstream_pack_url##*/}"

  cdn_archive="cdn.$(cat /etc/puavo/topdomain)"
  for distfile_server in $(puavo-conf puavo.image.servers) \
                         $cdn_archive; do
    distfile_url="https://${distfile_server}/distfiles/${upstream_pack_filename}"
    if wget_with_opts --ca-certificate=/etc/puavo-conf/rootca.pem \
                      --output-document "$upstream_tmppack"       \
                      "$distfile_url"; then
      return 0
    fi
  done

  if wget_with_opts --output-document "$upstream_tmppack" \
                    "$upstream_pack_url"; then
    return 0
  fi

  [ $? -eq 4 ] && g_sc=${SC_ERR_NET}
  return 1
}

get_pkg_field_from_installer()
{
  local field_key field_value installer logerror

  field_key=$1
  installer=$2
  logerror=${3:-}

  field_value=$(
    tar --wildcards -Ozx -f "$installer" '*/.puavo-pkg-version' \
      | awk -v field_key="$field_key" '$1 == field_key { print $2 }')

  if [ -z "$field_value" ]; then
    if [ "$logerror" != 'false' ]; then
      log_error "could not determine '${field_key}' from '${installer}'"
    fi
    return 1
  fi

  printf %s "$field_value"
}

check_image_requirements_met() {
  local current_image_timestamp first_timestamp installer required_image

  installer=$1

  required_image=$(get_pkg_field_from_installer 'required-image' \
                     "$installer" false) || true
  if [ -z "$required_image" ]; then
    return 0
  fi

  current_image_timestamp=$(
    awk '{
      if (match($1, /^.*?-([0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6})-/, timestamp)) {
        print timestamp[1]
      }
    }' /etc/puavo-image/name || true)

  if [ -z "$current_image_timestamp" ]; then
    log_error 'could not determine current image timestamp'
    return 1
  fi

  first_timestamp="$(printf "%s\n%s\n" "$current_image_timestamp" \
                       "$required_image" | sort | head -1)"

  # if $required_image <= $current_image_timestamp, return error
  if [ "$first_timestamp" != "$required_image" ]; then
    log_error "${installer} requires image of at least ${required_image}"
    return 1
  fi

  return 0
}

extract_installer()
{
  local installer pkg_dir pkg_format pkg_id pkg_name pkg_tmpdir

  installer=$1

  if [ ! -f "$installer" ]; then
    log_error "installer '${installer}' does not exist"
    return 1
  fi

  pkg_format=$(get_pkg_field_from_installer 'pkg-format' "$installer") \
                 || true
  if [ -z "$pkg_format" -o "$pkg_format" -gt "$SUPPORTED_PKG_FORMAT" ]; then
    log_error "installer has unsupported format '${pkg_format}'"
    return 1
  fi

  if ! check_image_requirements_met "$installer"; then
    return 1
  fi

  pkg_name=$(get_pkg_field_from_installer name "$installer") || return 1
  check_puavopkg_name "$pkg_name" || return 1
  pkg_id=$(get_pkg_field_from_installer package-id "$installer") \
            || return 1

  pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id")
  pkg_tmpdir="${pkg_dir}.tmp"

  if [ -d "$pkg_dir" ]; then
    echo "$pkg_dir"
    return
  fi

  mkdir -p "$pkg_tmpdir" || return 1

  tar --no-same-owner --no-same-permissions --strip-components=1 \
    -z -x -f "$installer" -C "$pkg_tmpdir" || {
      rm -rf "$pkg_tmpdir"
      return 1
  }

  mv -T "$pkg_tmpdir" "$pkg_dir" || {
    rm -rf "$pkg_tmpdir"
    return 1
  }

  log_info "${pkg_name}: extracted installer file successfully"

  ln -fns "$pkg_dir" "${g_puavo_pkg_available_dir}/${pkg_name}"

  echo "$pkg_dir"
}

prepare_pkg_from_dir()
{
  local msg pkg_dir pkg_id pkg_name

  pkg_dir=$1

  if [ "$(id -nu)" != 'puavo-pkg' ]; then
    msg="refusing to prepare puavo-pkg in $pkg_dir as '$(id -nu)'"
    msg="${msg} (must be user 'puavo-pkg')."
    log_error "$msg"
    return 1
  fi

  pkg_name=$(get_pkg_name "$pkg_dir") || return 1
  check_puavopkg_name "$pkg_name" || return 1
  pkg_id=$(get_pkg_id "$pkg_dir") || return 1

  download_upstream_pack "$pkg_name" "$pkg_id" || {
    log_error "failed to download the upstream pack of package '${pkg_name}'"
    return 1
  }

  unpack_upstream_pack "$pkg_name" "$pkg_id" || {
    log_error "failed to unpack the upstream pack of package '${pkg_name}'"
    return 1
  }

  log_info "${pkg_name}: prepared for configuration"
}

prepare_pkg_with_installer()
{
  local installer pkg_dir upstream_dir upstream_packdir upstream_tmpdir

  installer=$1

  pkg_dir=$(extract_installer "$installer") || {
    log_error "failed to extract '$installer'"
    return 1
  }

  if [ -e "${pkg_dir}/.prepared" ]; then
    # If we have already prepared the package, just update the preparation
    # timestamp to delay garbage collection.
    touch "${pkg_dir}/.prepared"
    echo "$pkg_dir"
    return 0
  fi

  # drop privileges to "puavo-pkg"-user for preparation stage
  upstream_dir="${pkg_dir}/upstream"
  upstream_packdir="${pkg_dir}/packs"
  upstream_tmpdir="${pkg_dir}/upstream.tmp"

  rm -rf "$upstream_dir" "$upstream_packdir" "$upstream_tmpdir"
  install -d -o puavo-pkg -g puavo-pkg -m 755 "$upstream_packdir" \
                                              "$upstream_tmpdir"

  if ! su puavo-pkg -s /bin/bash -- /usr/sbin/puavo-pkg prepare-from-dir \
    "$pkg_dir" > /dev/null; then
      log_error "failed to run /usr/sbin/puavo-pkg prepare-from-dir $pkg_dir"
      rm -rf "$upstream_tmpdir"
      return 1
  fi
  mv "$upstream_tmpdir" "$upstream_dir"
  rm -rf "$upstream_packdir"

  # Update timestamp so it can be considered in garbage collection.
  # We do this at preparation stage, because bootservers do not necessarily
  # want package installation, only preparation, and prepared packages
  # should not be garbage collected even if those are not installed.
  touch "${pkg_dir}/.prepared"

  echo "$pkg_dir"
}

reconfigure_upgrade_pkg() {
  local ignore_unconfigure_error old_pkg_id pkg_dir pkg_id pkg_name

  pkg_name=$1
  pkg_dir=$2
  ignore_unconfigure_error=$3

  pkg_id=$(get_pkg_id "$pkg_dir") || return 1
  old_pkg_id=$(get_installed_pkg_id "$pkg_name") || return 1

  if [ -n "$old_pkg_id" -a "$old_pkg_id" != "$pkg_id" ]; then
    if ! unconfigure_pkg "$pkg_name"; then
      [ $? -eq "$SC_ERR_BUSY" ] && g_sc=${SC_ERR_BUSY}
      touch "${pkg_dir}/.upgrade_failed_due_to_old_pkg_unconfigure" || true
      if [ "$ignore_unconfigure_error" != 'true' ]; then
        log_error "failed to unconfigure package '${pkg_name}'"
        return 1
      fi
      log_warning "failed to unconfigure package '${pkg_name}' (but ignoring)"
    fi
  fi

  if ! configure_pkg "$pkg_name" "$pkg_id"; then
    # if configuration fails, try to reconfigure the old version again
    log_error "${pkg_name}: failed to configure package version '${pkg_id}'"
    if [ -n "$old_pkg_id" -a "$old_pkg_id" != "$pkg_id" ]; then
      log_info "${pkg_name}: reverting to previous version '${old_pkg_id}'"
      if ! unconfigure_pkg "$pkg_name" "$pkg_id"; then
        log_warning "${pkg_name}: unconfiguring version '${pkg_id}' failed"
      fi
      if ! configure_pkg "$pkg_name" "$old_pkg_id"; then
        log_error "${pkg_name}: failed to reconfigure the previous" \
                  "version '${old_pkg_id}'"
        return 1
      fi
      log_info "${pkg_name}: reconfigured the previous" \
               "version '${old_pkg_id}' successfully"
    fi
    return 1
  fi
}

install_pkg_with_installer()
{
  local installer old_pkg_id old_pkg_dir pkg_dir pkg_id pkg_name

  installer=$1
  pkg_dir=$(prepare_pkg_with_installer "$installer") || return 1

  pkg_name=$(get_pkg_name "$pkg_dir")            || return 1
  pkg_id=$(get_pkg_id "$pkg_dir")                || return 1
  old_pkg_id=$(get_installed_pkg_id "$pkg_name") || return 1

  reconfigure_upgrade_pkg "$pkg_name" "$pkg_dir" false || return 1

  log_info "${pkg_name}: installed successfully"

  # remove old package if there was one
  if [ -n "$old_pkg_id" -a "$old_pkg_id" != "$pkg_id" ]; then
    old_pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$old_pkg_id") \
      && rm -rf "$old_pkg_dir" || true
  fi
}

have_local_installations() {
  [ "${g_puavo_pkg_rootdir}/installed" != "$g_puavo_pkg_installed_dir" ]
}

is_locally_in() {
  local collection_dir pkg_name pkg_path

  pkg_name=$1
  collection_dir=$2

  if ! pkg_path="$(readlink "${collection_dir}/${pkg_name}")"; then
    return 1
  fi

  if [ "${pkg_path#${g_puavo_pkg_rootdir}}" != "$pkg_path" ]; then
    # $pkg_path is under $g_puavo_pkg_rootdir
    return 0
  fi

  return 1
}

switch_to_system_pkg()
{
  local need_unconfigure pkg_dir pkg_name

  pkg_name=$1
  check_puavopkg_name "$pkg_name" || return 1

  if ! have_local_installations; then
    return 0
  fi

  need_unconfigure=true

  if is_locally_in "$pkg_name" "$g_puavo_pkg_installed_dir"; then
    for pkg_dir in ${g_puavo_pkg_readonly_rootdir}/packages/${pkg_name}/*; do
      test -d "$pkg_dir" || break
      log_info "found system version for ${pkg_name}, reconfiguring"
      reconfigure_upgrade_pkg "$pkg_name" "$pkg_dir" false || {
        log_error "could not switch to image version for ${pkg_name}"
        return 1
      }
      need_unconfigure=false
      break
    done
    if $need_unconfigure; then
      if ! unconfigure_pkg "$pkg_name"; then
        [ $? -eq "$SC_ERR_BUSY" ] && g_sc=${SC_ERR_BUSY}
        log_error "failed to unconfigure package '${pkg_name}'"
        return 1
      fi
    fi
  fi

  purge_local_pkg "$pkg_name" || return 1
}

purge_local_pkg()
{
  local pkg_name

  pkg_name=$1
  check_puavopkg_name "$pkg_name" || return 1

  if ! have_local_installations; then
    return 0
  fi

  if is_locally_in "$pkg_name" "$g_puavo_pkg_available_dir"; then
    rm -f "${g_puavo_pkg_available_dir}/${pkg_name}"
  fi

  if [ -d "${g_puavo_pkg_cachedir}/upstream_packs/${pkg_name}" \
       -o -d "${g_puavo_pkg_rootdir}/packages/${pkg_name}" ]; then
    rm -rf "${g_puavo_pkg_cachedir}/upstream_packs/${pkg_name}" \
           "${g_puavo_pkg_rootdir}/packages/${pkg_name}" || return 1
    log_info "purged locally installed package ${pkg_name}"
  fi

  return 0
}

remove_pkg()
{
  local pkg_dir pkg_id pkg_name

  pkg_name=$1
  check_puavopkg_name "$pkg_name" || return 1

  pkg_id=$(get_installed_pkg_id "$pkg_name") || return 1
  if [ -n "$pkg_id" ]; then
    unconfigure_pkg "$pkg_name" || {
      [ $? -eq "$SC_ERR_BUSY" ] && g_sc=${SC_ERR_BUSY}
      log_error "failed to unconfigure package '${pkg_name}'"
      return 1
    }
    pkg_dir=$(get_writable_pkg_dir "$pkg_name" "$pkg_id") || return 1
    rm -rf "${pkg_dir}/.installed" \
           "${pkg_dir}/.prepared"  \
           "${pkg_dir}/.upgrade_failed_due_to_old_pkg_unconfigure" \
           "${pkg_dir}/upstream" || return 1
    purge_local_pkg "$pkg_name" || return 1
    log_info "removed package '${pkg_name}'"
    return 0
  fi

  purge_local_pkg "$pkg_name" || return 1

  g_sc=${SC_ERR_NOPKG}
  log_warning "requested to remove package '${pkg_name}' that does not exist"
  return 1
}

remove_all_pkgs()
{
  local pkg pkgs retval

  pkgs=$(list_installed_pkgs) || return 1
  retval=0

  for pkg in ${pkgs}; do
    remove_pkg "$pkg" || retval=1
  done

  return ${retval}
}

show_pkg()
{
  local identifier pkg_description pkg_dir pkg_field pkg_id pkg_legend \
        pkg_name pkg_version INSTALLER_REGEX

  identifier=$1
  pkg_field=$2

  INSTALLER_REGEX='\.tar\.gz$'

  if [[ "$identifier" =~ ${INSTALLER_REGEX} ]]; then
    pkg_dir=$(extract_installer "$identifier") || {
      log_error "failed to extract installer '${identifier}'"
      return 1
    }
  else
    pkg_dir=$(get_available_pkg_dir "$identifier") || return 1
  fi

  pkg_name=$(get_pkg_name "$pkg_dir") || return 1
  check_puavopkg_name "$pkg_name" || return 1
  pkg_id=$(get_pkg_id "$pkg_dir") || return 1
  if [ -z "${pkg_id}" ]; then
    log_error "package ${pkg_name} is not installed"
    return 1
  fi

  pkg_version=$(get_pkg_version "$pkg_dir") || return 1
  if [ -z "$pkg_version" ]; then
    pkg_version='?'
  fi

  pkg_legend="$pkg_name"
  if [ -r "${pkg_dir}/legend" ]; then
    pkg_legend=$(head -n1 "${pkg_dir}/legend") || return 1
  fi

  pkg_description=
  if [ -r "${pkg_dir}/description" ]; then
    pkg_description=$(cat "${pkg_dir}/description") || return 1
  fi

  case "$pkg_field" in
    Name)
      echo "$pkg_name"
      ;;
    Id)
      echo "$pkg_id"
      ;;
    Version)
      echo "$pkg_version"
      ;;
    Directory)
      echo "$pkg_dir"
      ;;
    Legend)
      echo "$pkg_legend"
      ;;
    Description)
      echo "$pkg_description"
      ;;
    '')
      echo "Name: ${pkg_name}"
      echo "Id: ${pkg_id}"
      echo "Version: ${pkg_version}"
      echo "Directory: ${pkg_dir}"
      echo "Legend: ${pkg_legend}"
      echo "Description:"
      if [ -n "${pkg_description}" ]; then
        echo "${pkg_description}" | sed -r -e 's/^\s*$/./' -e 's/(.*)/ \1/'
        echo ' .'
      fi
      ;;
    *)
      log_error "unknown field '${pkg_field}'"
      return 1
      ;;
  esac
}

usage_error()
{
  log_error "$1"
  echo "Try 'puavo-pkg --help' for more information." >&2
  return 1
}

assert_args_count()
{
  local op count

  op=$1
  count=$2
  shift 2

  if [ ! $# ${op} "${count}" ]; then
    usage_error "invalid number of arguments ($#), expected ${count}"
    return
  fi
}

## Main begins.

set -eu

trap on_exit EXIT

while [ $# -gt 0 ]; do
  case $1 in
    -h|--help)
      shift
      print_help_and_exit
      ;;
    -q|--quiet)
      shift
      if [ ${g_log_level} -gt 0 ]; then
        g_log_level=$((g_log_level - 1))
      fi
      ;;
    --)
      shift
      break
      ;;
    -*)
      usage_error "invalid argument '$1'"
      ;;
    *)
      break
      ;;
  esac
done

if [ $# -lt 1 ]; then
  print_help_and_exit
fi

## Override default configuration with values from the config file.
if [ -r /etc/puavo-pkg/puavo-pkg.conf ]; then
  . /etc/puavo-pkg/puavo-pkg.conf
  if [ ! -z ${PUAVO_PKG_CACHEDIR+x} ]; then
    g_puavo_pkg_cachedir="$PUAVO_PKG_CACHEDIR"
  fi
  if [ ! -z ${PUAVO_PKG_ROOTDIR+x} ]; then
    g_puavo_pkg_rootdir="$PUAVO_PKG_ROOTDIR"
  fi
  if [ ! -z ${PUAVO_PKG_READONLY_CACHEDIR+x} ]; then
    g_puavo_pkg_readonly_cachedir="$PUAVO_PKG_READONLY_CACHEDIR"
  fi
  if [ ! -z ${PUAVO_PKG_READONLY_ROOTDIR+x} ]; then
    g_puavo_pkg_readonly_rootdir="$PUAVO_PKG_READONLY_ROOTDIR"
  fi
  if [ ! -z ${PUAVO_PKG_DELETE_CACHE_AFTER_DAYS+x} ]; then
    g_puavo_pkg_delete_cache_after_days="$PUAVO_PKG_DELETE_CACHE_AFTER_DAYS"
  fi
fi

## Validate configuration.
[[ "$g_puavo_pkg_rootdir" =~ ^/ ]] || usage_error "invalid root dir path"

[[ "$g_puavo_pkg_readonly_rootdir" =~ ^/ ]] \
  || usage_error "invalid readonly root dir path"

mkdir -p "$g_puavo_pkg_cachedir" \
         "${g_puavo_pkg_rootdir}/packages" \
         "$g_puavo_pkg_available_dir"      \
         "$g_puavo_pkg_installed_dir"

command=$1
shift

# Some subcommands are useful as non-root as well,
# so do not change owner when those are used.
case "${command}" in
  license|list|show) ;;
  *) chown -R puavo-pkg: "$g_puavo_pkg_cachedir" ;;
esac

case "${command}" in
  gc-installations)
    assert_args_count -eq 0 "$@"
    gc_installations
    ;;
  gc-upstream-packs)
    assert_args_count -eq 0 "$@"
    gc_upstream_packs
    ;;
  install)
    assert_args_count -eq 1 "$@"
    install_pkg_with_installer "$1"
    ;;
  license)
    assert_args_count -eq 1 "$@"
    get_license_url "$1"
    ;;
  list)
    assert_args_count -eq 0 "$@"
    list_installed_pkgs_with_dirs
    ;;
  prepare)
    assert_args_count -eq 1 "$@"
    prepare_pkg_with_installer "$1" > /dev/null
    ;;
  prepare-from-dir)
    # not in usage() because should be called only internally by puavo-pkg
    assert_args_count -eq 1 "$@"
    prepare_pkg_from_dir "$1"
    ;;
  reconfigure)
    assert_args_count -eq 1 "$@"
    if [ "$1" = '--all' ]; then
      reconfigure_all_pkgs
    else
      reconfigure_pkg "$1"
    fi
    ;;
  reconfigure-upgrade)
    ignore_unconfigure_error=false
    if [ "${1:-}" = '--ignore-unconfigure-error' ]; then
      ignore_unconfigure_error=true
      shift
    fi
    assert_args_count -eq 2 "$@"
    reconfigure_upgrade_pkg "$1" "$2" "$ignore_unconfigure_error"
    ;;
  remove)
    assert_args_count -eq 1 "$@"
    if [ "$1" = '--all' ]; then
      remove_all_pkgs
    else
      remove_pkg "$1"
    fi
    ;;
  switch-to-system)
    switch_to_system_pkg "$1"
    ;;
  show)
    assert_args_count -ge 1 "$@"
    case "$1" in
      -F|--field)
        shift
        assert_args_count -eq 2 "$@"
        show_pkg "$2" "$1"
        ;;
      *)
        assert_args_count -eq 1 "$@"
        show_pkg "$1" ""
        ;;
    esac
    ;;
  unconfigure)
    assert_args_count -eq 1 "$@"
    if ! unconfigure_pkg "$1"; then
      [ $? -eq "$SC_ERR_BUSY" ] && g_sc=${SC_ERR_BUSY}
      log_error "failed to unconfigure package '${pkg_name}'"
      exit 1
    fi
    ;;
  *)
    usage_error "unknown command '${command}'"
esac

g_sc=${SC_OK}
exit 0
