#!/bin/sh

# file      : build2-install.sh
# copyright : Copyright (c) 2014-2019 Code Synthesis Ltd
# license   : MIT; see accompanying LICENSE file

usage="Usage: $0 [-h|--help] [<options>] [<install-dir>]"

ver="0.9.0"
type="public"

url="https://download.build2.org"
repo="https://pkg.cppget.org/1/alpha"

toolchain="build2-toolchain-0.9.0"
toolchain_sum_xz="f73cfe5ba5f07dd6b16b822c942e95e465af1ac77f983163b2550483cec0bb7b"
toolchain_sum_gz="9e8905cd0b767bc813a5d1fe798d1bcf81cc5bce808f458046d410a57fbea203"

cver="0.9"
cdir="build2-toolchain-$cver"

pcver="0.8"   # Empty if no upgrade is possible.
pcdir="build2-toolchain-$pcver"

manifest="toolchain.sha256"
stem="build2-install"

owd="$(pwd)"
prog="$0"

fail ()
{
  cd "$owd"
  exit 1
}

diag ()
{
  echo "$*" 1>&2
}

error ()
{
  diag "error: $*"
  fail
}

# Note that this function will execute a command with arguments that contain
# spaces but it will not print them as quoted (and neither does set -x).
#
run ()
{
  diag "+ $@"
  "$@"
  if test "$?" -ne "0"; then
    fail
  fi
}

# Check whether the specified command exists.
#
check_cmd () # <cmd> [<hint>]
{
  if ! command -v "$1" >/dev/null 2>&1; then
    diag "error: unable to execute $1: command not found"
    if test -n "$2"; then
      diag "  info: $2"
    fi
    fail
  fi
}

mode=install

yes=
cxx=
cxx_name="system-default C++ compiler"
sudo=
jobs=
idir=
trust=
check=true
timeout=600
connect_timeout=60

while test $# -ne 0; do
  case "$1" in
    -h|--help)
      diag
      diag "$usage"
      diag "Options:"
      diag "  --yes            Do not ask for confirmation before starting."
      diag "  --cxx <prog>     Alternative C++ compiler to use."
      diag "  --sudo <prog>    Alternative sudo program to use (pass false to disable)."
      diag "  --jobs|-j <num>  Number of jobs to perform in parallel."
      diag "  --trust <fp>     Repository certificate fingerprint to trust."
      diag "  --timeout <sec>  Network operations timeout in seconds."
      diag "  --no-check       Do not check for a new script version."
      diag "  --upgrade        Upgrade previously installed toolchain."
      diag "  --uninstall      Uninstall previously installed toolchain."
      diag
      diag "By default this script will use /usr/local as the installation"
      diag "directory and the current working directory as the build directory."
      diag
      diag "If the installation directory requires root permissions, sudo(1)"
      diag "will be used by default."
      diag
      diag "If --jobs|-j is unspecified, then the number of available hardware"
      diag "threads is used."
      diag
      diag "The --trust option recognizes two special values: 'yes' (trust"
      diag "everything) and 'no' (trust nothing)."
      diag
      diag "Note also that <options> must come before the <install-dir> argument."
      diag
      exit 0
      ;;
    --upgrade)
      mode=upgrade
      shift
      ;;
    --uninstall)
      mode=uninstall
      shift
      ;;
    --yes)
      yes=true
      shift
      ;;
    --cxx)
      shift
      if test $# -eq 0; then
        error "C++ compiler expected after --cxx; run $prog -h for details"
      fi
      cxx="$1"
      cxx_name="$1"
      shift
      ;;
    --sudo)
      shift
      if test $# -eq 0; then
        error "sudo program expected after --sudo; run $prog -h for details"
      fi
      sudo="$1"
      shift
      ;;
    -j|--jobs)
      shift
      if test $# -eq 0; then
        error "number of jobs expected after --jobs|-j; run $prog -h for details"
      fi
      jobs="$1"
      shift
      ;;
    --trust)
      shift
      if test $# -eq 0; then
        error "certificate fingerprint expected after --trust; run $prog -h for details"
      fi
      trust="$1"
      shift
      ;;
    --timeout)
      shift
      if test $# -eq 0; then
        error "value in seconds expected after --timeout; run $prog -h for details"
      fi
      timeout="$1"
      shift
      ;;
    --no-check)
      check=
      shift
      ;;
    -*)
      # It's a lot more likely for someone to misspell an option than to want
      # an installation directory starting with '-'.
      #
      diag "error: unknown option '$1'"
      diag "  info: run 'sh $prog -h' for usage"
      fail
      ;;
    *)
      idir="$1"
      shift
      if test $# -ne 0; then
        diag "error: unexpected argument '$@'"
        diag "  info: options must come before the <install-dir> argument"
        fail
      fi
      break
      ;;
  esac
done

# Unless --yes was specified, ask the user whether to continue.
#
prompt_continue ()
{
  while test -z "$yes"; do
    printf "Continue? [y/n] " 1>&2
    read yes
    case "$yes" in
      y | Y) yes=true ;;
      n | N) fail     ;;
          *) yes=     ;;
    esac
  done
}

# Calculate the SHA256 checksum of the specified file. Note that the csum
# variable should be set to a suitable checksum program (see checksum_init()).
#
checksum () # <file>
{
  local r
  r="$(run $csum "$1" | cut -d ' ' -f 1)"
  echo "$r"
}

checksum_init ()
{
  # See which SHA256 checksum program we will be using today. The options are:
  #
  #  sha256sum (Linux coreutils)
  #  sha256    (FreeBSD)
  #  shasum    (Perl tool, Mac OS)
  #
  # Note that with these options all three tools output the sum as the first
  # word.
  #
  if command -v sha256sum > /dev/null 2>&1; then
    csum="sha256sum -b"
  elif command -v sha256 > /dev/null 2>&1; then
    csum="sha256 -q"
  elif command -v shasum > /dev/null 2>&1; then
    csum="shasum -a 256 -b"
  else
    error "unable to execute sha256sum, sha256, or shasum: command not found"
  fi
}

# Download the specified file. Common options are --progress-bar or -sS.
#
download () # [<curl-options>] <url>
{
  run curl -fLO --connect-timeout "$connect_timeout" --max-time "$timeout" "$@"
}

# Check if the script is out of date. Prints diagnostics that normally goes
# as a prefix to the prompt.
#
# Note: must be called after checksum_init().
#
check_script ()
{
  # Using $0 as a script path should be good enough for our needs (we don't
  # expect anyone to run it via PATH or to source it).
  #
  prog_sum="$(checksum "$prog")"

  f="$manifest"
  download -sS "$url/$f"

  # Find our checksum line.
  #
  l="$(sed -n "s#^\([^ ]* \*.*/$stem-.*\.sh\)\$#\1#p" "$f")"

  if test -z "$l"; then
    error "unable to extract checksum for $stem.sh from $f"
  fi

  rm -f "$f"

  # Extract the checksum.
  #
  r="$(echo "$l" | sed -n 's#^\([^ ]*\) .*$#\1#p')"

  if test "$r" != "$prog_sum"; then

    # We can have two cases here: a new version is available but this script
    # is (presumably) still valid (would normally happen on public) or the
    # script is out of date for the same (snapshot) version (would normally
    # happen on stage). To find out which case it is we extract and compare
    # the versions.
    #
    v="$(echo "$l" | sed -n 's#^[^ ]* \*\([^/]*\)/.*$#\1#p')"
    f="$(echo "$l" | sed -n 's#^[^ ]* \*\(.*\)$#\1#p')"

    if test "$v" != "$ver"; then

      # Make it the prefix of the plan that follows.
      #
      diag
      diag "Install script for version $v is now available, download from:"
      diag
      diag "  $url/$f"
    else
      diag
      diag "Install script $prog is out of date:"
      diag
      diag "Old checksum: $prog_sum"
      diag "New checksum: $r"
      diag
      diag "Re-download from:"
      diag
      diag "  $url/$f"
      diag
      diag "Or use the --no-check option to suppress this check."
      diag
      fail
    fi
  fi
}

install ()
{
  if test -z "$idir"; then
    idir=/usr/local
  fi

  check_cmd curl
  check_cmd tar

  checksum_init

  # Check if the script is out of date.
  #
  if test "$check" = true; then
    check_script
  fi

  # Toolchain archive type: use .xz if we can, .gz otherwise.
  #
  ez=
  if command -v xz >/dev/null 2>&1; then
    ez=xz
    toolchain_sum="$toolchain_sum_xz"
  else
    check_cmd gzip
    ez=gz
    toolchain_sum="$toolchain_sum_gz"
  fi

  # Figure out if we need sudo. Unless the user made the choice for us, we
  # probe the installation directory to handle cases like Mac OS allowing
  # non-root users to modify /usr/local.
  #
  sudo_hint=
  if test -z "$sudo"; then

    if test -d "$idir"; then
      if ! touch "$idir/build2-write-probe" >/dev/null 2>&1; then
        sudo=sudo
        sudo_hint="required to install into $idir"
      else
        rm -f "$idir/build2-write-probe"
      fi
    else
      if ! mkdir -p "$idir" >/dev/null 2>&1; then
        sudo=sudo
        sudo_hint="required to create $idir"
      fi
    fi
  elif test "$sudo" = "false"; then
    sudo=
  fi

  if test -n "$sudo"; then
    check_cmd "$sudo" "$sudo_hint"
  fi

  # Check if what's specified with --cxx looks like a C compiler. This is not
  # bullet-proof so we only warn.
  #
  cxx_c=
  if test -n "$cxx"; then

    # Clang. Some examples (bad on the left, good on the right):
    #
    # clang                 clang++
    # clang-7               clang++-7
    # i686-linux-gnu-clang  i686-linux-gnu-clang++
    # /clang++-7/clang      /clang-7/clang++
    #
    if test -z "$cxx_c"; then
      cxx_c="$(echo "$cxx" | sed -n 's#^\(.*\)clang\([^/+]*\)$#\1clang++\2#p')"
    fi

    # GCC. Some examples (bad on the left, good on the right):
    #
    # gcc                 g++
    # gcc-7               g++-7
    # i686-linux-gnu-gcc  i686-linux-gnu-g++
    # /g++-7/gcc          /gcc-7/g++
    #
    if test -z "$cxx_c"; then
      cxx_c="$(echo "$cxx" | sed -n 's#^\(.*\)gcc\([^/]*\)$#\1g++\2#p')"
    fi
  fi

  # Print the plan and ask for confirmation.
  #
  diag
  diag "About to download, build, and install build2 toolchain $ver ($type)."
  diag
  diag "From:  $url"
  diag "Using: $cxx_name"
  if test -n "$cxx_c"; then
    diag
    diag "WARNING: $cxx looks like a C compiler, did you mean $cxx_c?"
  fi
  diag
  diag "Install directory: $idir/"
  diag "Build directory:   $owd/"
  diag
  diag "Package repository: $repo"
  diag
  diag "For options (change the installation directory, etc), run:"
  diag
  diag "  sh $prog -h"
  diag
  if test -f "$idir/bin/b"; then
  diag "WARNING: $idir/ already contains build2, consider uninstalling first."
  fi
  if test -d "$cdir"; then
  diag "WARNING: $cdir/ already exists and will be overwritten."
  fi
  if test -n "$sudo"; then
  diag "Note: to install into $idir/ will be using $sudo."
  fi
  diag
  prompt_continue

  # Clean up.
  #
  run rm -rf "$cdir"
  run rm -rf "$toolchain"

  # Get the toolchain package so that we can run its config.guess script.
  #
  if ! test -f "$toolchain.tar.$ez"; then
    download --progress-bar "$url/$ver/$toolchain.tar.$ez"
  fi

  # Verify the checksum.
  #
  r="$(checksum "$toolchain.tar.$ez")"
  if test "$r" != "$toolchain_sum"; then
    diag "error: $toolchain.tar.$ez checksum mismatch"
    diag "  info: calculated $r"
    diag "  info: expected   $toolchain_sum"
    diag "  info: remove $toolchain.tar.$ez to force re-download"
    fail
  fi

  diag "info: $toolchain.tar.$ez checksum verified successfully"

  # Unpack.
  #
  if test "$ez" = "xz"; then
    run xz -dk "$toolchain.tar.xz"
  else
    run gzip -dk "$toolchain.tar.gz"
  fi
  run tar -xf "$toolchain.tar"
  run rm -f "$toolchain.tar"

  run cd "$toolchain"

  # Determine the target.
  #
  tgt="$(run build2/config.guess)"
  sys="$(echo "$tgt" | sed -n 's/^[^-]*-[^-]*-\(.*\)$/\1/p')"

  diag "info: running on $tgt ($sys)"

  # Figure out which C++ compiler we are going to use.
  #
  cxx_hint="specify alternative C++ compiler to use with --cxx"
  if test -z "$cxx"; then
    case "$sys" in
      linux*)
        cxx="g++"
        ;;
      darwin*)
        cxx="clang++"
        cxx_hint="install Command Line Tools with 'xcode-select --install'"
        ;;
      freebsd*)
        cxx="clang++"
        ;;
      *)
        error "not guessing C++ compiler for $sys$, specify explicitly with --cxx"
        ;;
    esac
  fi
  check_cmd "$cxx" "$cxx_hint"

  # See if we can find GNU make and bootstrap in parallel.
  #
  make=
  make_ver=
  if command -v gmake > /dev/null 2>&1; then
    make=gmake
  elif command -v make > /dev/null 2>&1; then
    make=make
  fi

  if test -n "$make"; then
    # Note that if this is not a GNU make, then it may not recognize --version
    # (like BSD make) and fail.
    #
    make_ver="$("$make" --version 2>&1 | sed -n 's/^GNU Make \(.*\)$/\1/p')"
    if test -z "$make_ver"; then
      diag "info: $make is not GNU make, performing shell bootstrap"
      make=
    fi
  else
    diag "info: no GNU make found, performing shell bootstrap"
  fi

  # If we are using GNU make, try to determine how many jobs we should use.
  # Note that if the user specified --jobs|-j, then its value will be passed
  # to make by build.sh and so we don't need any of this.
  #
  if test -n "$make"; then

    make_jobs=

    if test -z "$jobs"; then
      case "$sys" in
        linux*)
          if ! make_jobs="$(nproc)"; then
            make_jobs=
          fi
          ;;
        darwin*)
          if ! make_jobs="$(sysctl -n hw.ncpu)"; then
            make_jobs=
          fi
          ;;
        freebsd*)
          if ! make_jobs="$(sysctl -n hw.ncpu)"; then
            make_jobs=
          fi
          ;;
      esac
      if test -z "$make_jobs"; then
        diag "info: unable to determine hardware concurrency, performing serial bootstrap"
        diag "info: consider manually specifying the number of jobs with --jobs|-j"
      fi
    fi
  fi

  # We don't have arrays in POSIX shell but we should be ok as long as none of
  # the option values contain spaces. Note also that the expansion must be
  # unquoted.
  #
  ops="--timeout $timeout"

  if test -n "$jobs"; then
    ops="$ops -j $jobs"
  fi

  if test -n "$sudo"; then
    ops="$ops --sudo $sudo"
  fi

  if test -n "$trust"; then
    ops="$ops --trust $trust"
  fi

  if test -n "$make"; then
    ops="$ops --make $make"

    if test -n "$make_jobs"; then
      ops="$ops --make -j$make_jobs"
    fi
  fi

  run ./build.sh $ops --install-dir "$idir" "$cxx"

  run cd "$owd"
  run rm -rf "$toolchain"

  # Print the report.
  #
  diag
  diag "Successfully installed build2 toolchain $ver ($type)."
  diag
  diag "Install directory:   $idir/"
  diag "Build configuration: $cdir/"
  diag
  diag "To uninstall, change to $owd/ and run:"
  diag
  diag "  sh $prog --uninstall"
  diag
  if test "$idir" != "/usr" -a "$idir" != "/usr/local"; then
  diag "Consider adding $idir/bin to the PATH environment variable:"
  diag
  diag '  export "PATH='"$idir/bin"':$PATH"'
  diag
  fi
}

# Extract the config.install.root value from config.build in the specified
# configuration directory. Set idir to that value unless a user explicitly
# specified the installation directory, in which case verify they match.
#
set_install_root () # <cdir>
{
  if ! test -d "$1"; then
    error "build configuration directory $1/ does not exist"
  fi

  local c
  c="$1/build/config.build"

  if ! test -f "$c"; then
    error "directory $1/ does not contain a build configuration"
  fi

  # Note that the value could be quoted.
  #
  local r
  r="$(sed -n "s#^config.install.root = '*\(.*\)/'*\$#\1#p" "$c")"

  if test -z "$r"; then
    error "unable to extract installation directory from $c"
  fi

  if test -z "$idir"; then
    idir="$r"
  elif test "$idir" != "$r"; then
    diag "error: detected installation directory does not match specified"
    diag "  info: detected  $r"
    diag "  info: specified $idir"
    fail
  fi
}

upgrade ()
{
  checksum_init

  # Check if the script is out of date.
  #
  if test "$check" = true; then
    check_script
  fi

  # First check if we already have the current version (i.e., patch upgrade).
  # Then previous version, unless empty (no upgrade possible).
  #
  # If this is a patch release, then we do the "dirty" upgrade. Otherwise --
  # staged.
  #
  if test -d "$cdir"; then
    kind=dirty
    ucdir="$cdir"
  elif test -d "$pcdir"; then
    if test -n "$pcver"; then
      kind=staged
      ucdir="$pcdir"
    else
      error "no upgrade is possible, perform the from-scratch installation"
    fi
  else
    error "no existing build configuration in $cdir/ or $pcdir/"
  fi

  set_install_root "$ucdir"

  # Print the plan and ask for confirmation.
  #
  diag
  diag "About to perform $kind upgrade of build2 toolchain to $ver ($type)."
  diag
  diag "Install directory:   $idir/"
  diag "Build configuration: $ucdir/"
  if ! test -f "$idir/bin/b"; then
  diag
  diag "WARNING: $idir/ does not seem to contain a build2 installation."
  fi
  diag
  diag "Package repository: $repo"
  diag
  prompt_continue

  # Add $idir/bin to PATH in case it is not already there.
  #
  PATH="$idir/bin:$PATH"
  export PATH

  # Translate our options to their bpkg versions, same as in build.sh from
  # build2-toolchain.
  #
  if test -n "$jobs"; then
    jobs="-j $jobs"
  fi

  fetch_ops="--fetch-timeout $timeout"
  build_ops="--fetch-timeout $timeout"

  if test "$trust" = "yes"; then
    fetch_ops="$fetch_ops --trust-yes"
  elif test "$trust" = "no"; then
    fetch_ops="$fetch_ops --trust-no"
  elif test -n "$trust"; then
    fetch_ops="$fetch_ops --trust $trust"
  fi

  # Note that we use bpkg-rep-fetch(1) to both add and only fetch this
  # repository if it's not the same as the existing.
  #
  if test "$kind" = dirty; then

    run cd "$cdir"
    run bpkg $fetch_ops fetch "$repo"
    run bpkg $jobs $build_ops build --for install --upgrade --recursive --yes --plan= build2 bpkg bdep
    run bpkg $jobs install build2 bpkg bdep
    run cd "$owd"

  else

    run cp -rp "$ucdir" "$cdir"

    run cd "$cdir"
    run bpkg $fetch_ops fetch "$repo"

    # Note: not installing bdep-stage since we don't need it.
    #
    # @@ TODO: after 0.9.0 we can pass config.bin.suffix to bpkg-pkg-build(1)
    # to avoid relinking on install. Also in *.bat.
    #
    run bpkg $jobs $build_ops build --for install --upgrade --recursive --yes --plan= build2 bpkg bdep
    run bpkg $jobs install config.bin.suffix=-stage config.install.data_root=root/stage build2 bpkg

    run which b-stage
    run which bpkg-stage

    run b-stage --version
    run bpkg-stage --version
    run cd "$owd"

    run cd "$ucdir"
    run bpkg $jobs uninstall build2 bpkg bdep
    run cd "$owd"

    run cd "$cdir"
    run bpkg-stage $jobs install build2 bpkg bdep
    run bpkg $jobs uninstall config.bin.suffix=-stage config.install.data_root=root/stage build2 bpkg
    run cd "$owd"
  fi

  run b --version
  run bpkg --version
  run bdep --version

  # Print the report. The new configuration is always in cdir.
  #
  diag
  diag "Successfully upgraded build2 toolchain to $ver ($type)."
  diag
  diag "Install directory:   $idir/"
  diag "Build configuration: $cdir/"
  if test "$ucdir" != "$cdir"; then
  diag
  diag "Old configuration:   $ucdir/"
  fi
  diag
}

uninstall ()
{
  set_install_root "$cdir"

  # Print the plan and ask for confirmation.
  #
  diag
  diag "About to uninstall build2 toolchain $ver ($type)."
  diag
  diag "Install directory: $idir/"
  diag "Build directory:   $owd/"
  diag
  if ! test -f "$idir/bin/b"; then
  diag "WARNING: $idir/ does not seem to contain a build2 installation."
  fi
  diag
  prompt_continue

  # Add $idir/bin to PATH in case it is not already there.
  #
  PATH="$idir/bin:$PATH"
  export PATH

  if test -n "$jobs"; then
    jobs="-j $jobs"
  fi

  # Note that if at some point we change the way build.sh installs things
  # (for example, by installing dependencies recursively), then this might
  # have to be adjusted as well.
  #
  run cd "$cdir"
  run bpkg $jobs uninstall --all
  run cd "$owd"
  run rm -rf "$cdir"

  # Print the report.
  #
  diag
  diag "Successfully uninstalled build2 toolchain $ver ($type)."
  diag
}

case "$mode" in
  install)   install                 ;;
  upgrade)   upgrade                 ;;
  uninstall) uninstall               ;;
  *)         error "unexpected mode" ;;
esac
