#!/usr/bin/bash -e
# Copyright © 2018-2019 Nicolas Mailhot <nim@fedoraproject.org>,
#                       Jan Chaloupka   <jchaloup@redhat.com>
#
# 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 3 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 <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

usage() {
cat >&2 << EOF_USAGE
Usage: $0 <action> [ [-h] ]
                   [ [-i <go import path> ] ]
                   [ [-y] ] [ [-p <prefix>] [-g <go path>] ]
                   [ [-w] ] [ [-b <go build path>] ]
                   [ [-d <directory>] [-t <tree root>] [-r <regex>] ]
                   [ [-e <extension>] [-s <sourcedir>] [-o <file>] <file> ]
                   [ [-v <version>] ] [ [-a <attribute>] ]

<action>             should be one of: install, check, provides, requires

Most actions accept the same set of arguments, and will silently ignore those
that do not apply to a specific action. Unless specified otherwise, all
arguments are optional.

“install”-specific arguments:

-i <go import path>  is a mandatory argument
-e <extension>       add files ending in <extension> to the default
                     installation set,
                     can be specified several times
-s <sourcedir>       read expanded and prepared Go sources in <sourcedir>/src
                     <sourcedir> should be populated in %prep
-o <file>            output file lists to file,
                     default value if not set: devel.file-list
-O <dir>             create file list in <dir>
<file>               add <file> to the default installation set,
                     can be specified several times

“check”-specific arguments:

-i <go import path>  is a mandatory argument
-y                   check the files installed in the system Go path, not the
                     files in the current work directory,
                     this option is usually used with -p and -g,
-w                   check the files in the current work directory, not the
                     files installed in the system Go path,
                     this option is usually used with -b,
                     this is the default check mode if neither -y nor -w are
                     specified

“provides”-specific arguments:

-v <version string>: tag the provides with <version string>
-a <attribute>:      an attribute to add to the provides, for example
                     -a "(commit=XXXX)"
                     -a "(branch=YYYY)"
                     -a "(tag=rx.y.z-alpha1)"
                     can be specified several times

Common arguments:

-i <go import path>  a Go import path of the target package,
                     mandatory for: install and check,
                     ignored by: provides and requires
-h                   print this help
-p <prefix>:         an optionnal prefix path such as %{buildroot}
-g <go path>:        the root of the Go source tree
                     default value if not set: /usr/share/gocode
-b <bindir>          read binaries already produced in <bindir>
                     used by: install and check,
                     ignored by: provides and requires
-d <directory>       a directory that should be ignored during processing,
                     relative to the import path root,
                     non recursive,
                     can be specified several times
-t <tree root>       the root of a directory tree that should be ignored during
                     processing,
                     relative to the import path root,
                     recursive,
                     can be specified several times
-r <regex>           a regex matching elements that should be ignored during
                     processing,
                     relative to the import path root,
                     can be specified several times
EOF_USAGE
exit 1
}

multigoipath() {
cat >&2 << EOF_MULTIGOIPATH
Error: Attempt to install to multiple Go import paths. Aborting…

The installation target of “$0 install” is a unique Go import
path tree. Some of the presets rpm passes to this command in the %install stage
rely on this unicity.

If you need to install files to different Go import path trees you MUST invoque
this command once per target.

Even though the import path value was originally intended to be implicit in Go,
it usually leaks in multiple project files (documentation, unit tests, non-Go
files and so on).

Therefore you SHOULD always:
 — determine the canonical import path name of the project you want to install,
 — change every self-reference in the project files, to this canonical name,
   removing alternative leftovers (usually done, in the %prep stage of rpm)
 — and only then deploy the result to the canonical import path using
   “$0 install -i <canonical-import-path>”

You MAY use symbolic links to maintain compatibility with codebases that refer
to this project via alternative, non canonical names (due to past renamings
or forkings). That will have the same effect as the HTTP redirects some
project upstreams are fond of. It WILL break the same way on the explicit
self-references that may exist in the files of this project.

Therefore, you SHOULD preferably patch those codebases to use the project
canonical import path, like you did for this codebase in %prep.

If you can not patch away references to an alternative import path name in some
third-party codebases, but wish to avoid import path naming problems, you
SHOULD:
 — prepare, in %prep, a separate copy of the project files, with every self
   reference changed to the alternative name
 — and only then deploy, from this separate copy, to the alternative name
   “$0 install -i <alternative-import-path> \
                               -s <separate-source>”

This later pattern is not recommended as it helps entrenching naming confusion.
EOF_MULTIGOIPATH
exit 1
}

alreadyexists() {
cat >&2 << EOF_ALREADYEXISTS
Error: Attempt to overwrite an existing installation. Aborting…

“$0 install” aims at deploying clean Go source project files in
the %install stage of rpm.

You SHOULD have prepared the project files in the rpm %prep stage and resolved
any file conflict before calling “$0 install”. That means a
specific Go import path is usually deployed in a single call.

If you wish to deploy the same import path using multiple
“$0 install” calls you MUST make sure they use consistent
versionning and exclusion arguments. Those arguments are recorded on disk to
allow reliable provides computation in later rpm stages.

If used outside rpm, you MUST ensure some other way no conflicting installation
already exists. “$0 install” has no way to remove cleanly such
an installation. Installing blindly over another deployment would leave in
place, all the files, not present in the new installation, resulting in an
unreliable mix.
EOF_ALREADYEXISTS
exit 1
}

action=''
prefix=''
checkin="workdir"
sourcedir="${PWD}"
bindir="${GO_BUILD_DIR:-${PWD}/_build}"
goldflags=''
gopath=/usr/share/gocode
filelist='devel.file-list'
filelistroot="${PWD}"
goipath=''
declare -A metadata
flags_d=()
flags_t=()
flags_r=()
flags_e=()
declare -A imetadata
iflags_d=()
iflags_t=()
iflags_r=()
iflags_e=()
vinstall=install
vln=ln

if [[ $# -eq 0 ]] ; then
  usage
else case $1 in
    install|check|provides|requires) action=$1 ;;
    *)                               usage     ;;
  esac
fi

shift

if ! options=$(getopt -n $0 -o hi:yp:g:wb:d:t:r:e:s:o:O:l:V:T:C:B:vaz: \
                      -l help,go-import-path: \
                      -l system-files,prefix:,go-path: \
                      -l workdir-files,bindir: \
                      -l ignore-directory: \
                      -l ignore-tree: \
                      -l ignore-regex: \
                      -l include-extension: \
                      -l sourcedir: \
                      -l output:,output-root \
                      -l version:,tag:,commit:,branch: \
                      -l verbose: \
                      -- "$@") ; then
    usage
fi

eval set -- "$options"

while [ $# -gt 0 ] ; do
  case $1 in
    -h|--help)                      usage                                ;;
    -i|--go-import-path)            if [[ ("${goipath}" != "") && \
                                          ("${goipath}" != "${2}") ]] ; then
                                      multigoipath
                                    else
                                      goipath="${2}"
                                    fi                            ; shift;;
    -y|--system-files)              checkin="system"                     ;;
    -p|--prefix)                    prefix=$(realpath -sm "${2}") ; shift;;
    -g|--go-path)                   gopath="$2"                   ; shift;;
    -w|--workdir-files)             checkin="workdir"                    ;;
    -b|--bindir)                    bindir="${2}"                 ; shift;;
    -d|--ignore-directory)          iflags_d+=( "${2}" )          ; shift;;
    -t|--ignore-tree)               iflags_t+=( "${2}" )          ; shift;;
    -r|--ignore-regex)              iflags_r+=( "${2}" )          ; shift;;
    -e|--include-extension)         iflags_e+=( "${2}" )          ; shift;;
    -s|--sourcedir )                sourcedir="${2}"              ; shift;;
    -o|--output)                    filelist="${2}"               ; shift;;
    -O|--output-root)               filelistroot="${2}"           ; shift;;
    -l|--ldflags)                   ldflags="${2}"                ; shift;;
    -V|--version)                   imetadata["version"]="${2}"   ; shift;;
    -T|--tag)                       imetadata["tag"]="${2}"       ; shift;;
    -C|--commit)                    imetadata["commit"]="${2}"    ; shift;;
    -B|--branch)                    imetadata["branch"]="${2}"    ; shift;;
    -v|--verbose)                   vinstall="install -v"
                                    vln="ln -v"                          ;;
    -a)                             ;;      # ignored, only used by the rpm macro side
    -z)                             shift;; # ignored, only used by the rpm macro side
    (--)          shift; break ;;
    (-*)          usage ;;
    (*)           break ;;
  esac
  shift
done

dedupearray() {
local -n arrayref="${1}"
if [[ "${#arrayref[@]}" != "0" ]] ; then
  local temparray=( "${arrayref[@]}" )
  arrayref=()
  while read -r -d $'\0' l ; do
    arrayref+=( "${l}" )
  done < <(printf "%s\0" "${temparray[@]}" | sort -z -u)
fi
}

fixuserflags() {
if [[ -z "${goipath}" ]] ; then
  echo "No import path was specified using -i, exiting"
  exit 1
fi
flags_d=( "${flags_d[@]##${goipath}/}" )
flags_d=( "${flags_d[@]/#/${goipath}/}" )
flags_d=( "${flags_d[@]%/.}" )
dedupearray flags_d
flags_t=( "${flags_t[@]##${goipath}/}" )
flags_t=( "${flags_t[@]/#/${goipath}/}" )
flags_t=( "${flags_t[@]%/.}" )
dedupearray flags_t
dedupearray flags_r
dedupearray flags_e
}

expandflags() {
echo ${flags_d[@]/#/ -d } ${flags_t[@]/#/ -t } ${flags_r[@]/#/ -r } ${flags_e[@]/#/ -e }
}

popmetadata() {
unset metadata
declare -g -A metadata
for k in "${!imetadata[@]}"; do
  metadata["${k}"]=${imetadata[${k}]}
done
flags_d=( "${iflags_d[@]}" )
flags_t=( "${iflags_t[@]}" )
flags_r=( "${iflags_r[@]}" )
flags_e=( "${iflags_e[@]}" )
}

savemetadata() {
medadatafile="${1}"
rm -f "${medadatafile}"
touch "${medadatafile}"
for m in version tag commit branch ; do
  if [[ -n "${metadata[${m}]}" ]] ; then
    echo "${m}:${metadata[${m}]}" >> "${medadatafile}"
  fi
done
[[ "${#flags_d[@]}" != "0" ]] && printf 'excludedir:%s\n'   "${flags_d[@]}" >> "${medadatafile}" || :
[[ "${#flags_t[@]}" != "0" ]] && printf 'excludetree:%s\n'  "${flags_t[@]}" >> "${medadatafile}" || :
[[ "${#flags_r[@]}" != "0" ]] && printf 'excluderegex:%s\n' "${flags_r[@]}" >> "${medadatafile}" || :
}

readmetadata() {
medadatafile="${1}"
popmetadata
for m in version tag commit branch ; do
  v=$(grep "^${m}\:" "${medadatafile}" | head -1)
  v="${v#${m}:}"
  [[ -n "${v}" ]] && metadata["${m}"]="${v}"
done
while read -r -d $'\n' l ; do
  flags_d+=( "${l#excludedir:}" )
done < <(grep "^excludedir:" "${medadatafile}")
dedupearray flags_d
while read -r -d $'\n' l ; do
  flags_t+=( "${l#excludetree:}" )
done < <(grep "^excludetree:" "${medadatafile}")
dedupearray flags_t
while read -r -d $'\n' l ; do
  flags_r+=( "${l#excluderegex:}" )
done < <(grep "^excluderegex:" "${medadatafile}")
dedupearray flags_r
}

installfile() {
local goipath="${1}"
local file="${2}"
for proot in "$(realpath -sm ${PWD})" \
             "${gopath}/src/${goipath}" \
             "${prefix}${gopath}/src/${goipath}"; do
  [[ ${file}/ == ${proot}/* ]] && file=".${file#${proot}}"
done
file=$(realpath -sm "${file}")
local workdir="$(realpath -sm .)"
if [[ ${file}/ == ${workdir}/* ]] ; then
  local dest="${prefix}${gopath}/src/${goipath}${file#${workdir}}"
else
  local dest="${prefix}${file}"
fi
local destdir=$(dirname "${dest}")
if [[ ! -d "${destdir}" ]] ; then
  installfile "${goipath}" "${destdir#${prefix}}"
fi
if [[ (! -e $file) || (-d "${file}" && ! -L "${file}") ]] ; then
  ${vinstall} -m 0755 -d "${dest}"
  if [[ -d "${file}" && ! -L "${file}" ]] ; then
    find "${file}" -maxdepth 1 -iname '*.md' -type f -print0 | \
      while read -r -d $'\0' docfile ; do
        installfile "${goipath}" "${docfile}"
      done
  fi
  local fllprefix="%dir"
else
  if [[ -L "${file}" ]] ; then
    ${vln} -s $(readlink "${file}") "${dest}"
    touch -h -r          "${file}"  "${dest}"
  else
    if [[ -e "${dest}" ]] ; then
      checksum=$(sha256sum "${file}")
      checksum="${checksum%% *}"
      destchecksum=$(sha256sum "${dest}")
      destchecksum="${destchecksum%% *}"
      [[ "${checksum}" != "${destchecksum=}" ]] && alreadyexists
    fi
    ${vinstall} -m 0644 -p "${file}"  "${dest}"
  fi
  [[ "${file}" == *.md ]] && local fllprefix="%doc"
fi
echo "${fllprefix:+${fllprefix} }\"${dest#${prefix}}\"" >> "${filelist}"
}

listfiles() {
local goipath="${1}"
GOPATH="${sourcedir}" GO111MODULE=off \
  golist --to-install --package-path ${goipath} $(expandflags)
}

checks() {
local goipath="${1}"
GOPATH="${workroot}${GOPATH+:${GOPATH}}" GO111MODULE=off \
  golist --provided --with-tests --package-path ${goipath} $(expandflags) |\
    while read -r -d $'\n' dir ; do
      pushd "${workroot}/src/${dir}" >/dev/null
        echo "${dir}"
             GOPATH="${workroot}${GOPATH:+:${GOPATH}}:${gopath}" \
               PATH="${workbin:+${workbin}:}${PATH}" \
        GO111MODULE=off \
          go test ${GO_TEST_FLAGS} -ldflags "${LDFLAGS:+${LDFLAGS} }-extldflags '${GO_TEST_EXT_LD_FLAGS}'"
      popd >/dev/null
    done
}

provides() {
local goipath="${1}"
(
  echo "golang-ipath(${goipath})"
  GOPATH="${prefix}${gopath}" GO111MODULE=off \
    golist --provided --package-path "${goipath}" $(expandflags) \
           --skip-self --template 'golang({{.}})\n'
) | while IFS= read -r prov ; do
  echo "${prov}${metadata[version]:+ = ${metadata[version]}}"
  for m in "${!metadata[@]}" ; do
    if [[ "${m}" != "version" ]] ; then
      echo "${prov}(${m}=${metadata[${m}]})${metadata[version]:+ = ${metadata[version]}}"
    fi
  done
done
}

requires() {
GOPATH="${prefix}${gopath}" GO111MODULE=off \
  golist --imported  --package-path "${1}" $(expandflags) \
         --skip-self --template 'golang({{.}})\n'
}

# Action-specific preparation
case $action in
  install)        popmetadata
                  fixuserflags
                  filelist="${filelistroot}/${filelist}"
                  ${vinstall} -m 0755   -d "${prefix}${gopath}/src"
                  echo "Installing: ${goipath}"
                  pushd "${sourcedir}/src/${goipath}" >/dev/null
                    savemetadata            .goipath
                    touch -r "${sourcedir}" .goipath
                    (
                      listfiles "${goipath}"
                      realpath -e -s --relative-base=. "$@" go.mod .goipath
                    ) | sort -u | while IFS= read -r file ; do
                      installfile "${goipath}" "${file}"
                    done
                    rm .goipath
                  popd >/dev/null
                  sort -u -o "${filelist}" "${filelist}" ;;
  check)          popmetadata
                  fixuserflags
                  if [[ ${checkin} == "system" ]] ; then
                    workroot="${prefix}${gopath}"
                    unset workbin
                  else
                    workroot="${sourcedir}"
                    workbin="${bindir}"
                  fi
                  echo "Testing    in: ${workroot}/src"
                  echo "         PATH: ${workbin:+${workbin}:}${PATH}"
                  echo "       GOPATH: ${workroot}${GOPATH:+:${GOPATH}}:${gopath}"
                  echo "  GO111MODULE: off"
                  echo "      command: go test ${GO_TEST_FLAGS} -ldflags \"${LDFLAGS:+${LDFLAGS} }-extldflags '${GO_TEST_EXT_LD_FLAGS}'\""
                  echo "      testing: ${goipath}"
                  checks "${goipath}" ;;
  provides)       while read lockfile ; do
                    dir=$(dirname "${lockfile}")
                    goipath="${dir#${prefix}${gopath}/src/}"
                    readmetadata "${lockfile}"
                    provides "${goipath}"
                  done ;;
  requires)       while read lockfile ; do
                    dir=$(dirname "${lockfile}")
                    goipath="${dir#${prefix}${gopath}/src/}"
                    readmetadata "${lockfile}"
                    requires "${goipath}"
                    echo "go-filesystem"
                  done ;;
esac
