#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
#

# shell function library for Pulsar CI builds

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"

set -e
set -o pipefail

ARTIFACT_RETENTION_DAYS="${ARTIFACT_RETENTION_DAYS:-3}"

# lists all available functions in this tool
function ci_list_functions() {
  declare -F | awk '{print $NF}' | sort | grep -E '^ci_' | sed 's/^ci_//'
}

# prints thread dumps for all running JVMs
# used in CI when a job gets cancelled because of a job timeout
function ci_print_thread_dumps() {
  for java_pid in $(jps -q -J-XX:+PerfDisableSharedMem); do
    echo "----------------------- pid $java_pid -----------------------"
    cat /proc/$java_pid/cmdline | xargs -0 echo
    jcmd $java_pid Thread.print -l
    jcmd $java_pid GC.heap_info
  done
  return 0
}

# runs maven
function _ci_mvn() {
  mvn -B -ntp "$@"
}

# runs OWASP Dependency Check for all projects
function ci_dependency_check() {
  _ci_mvn -Pmain,skip-all,skipDocker,owasp-dependency-check initialize verify -pl '!pulsar-client-tools-test' "$@"
}

# installs a tool executable if it's not found on the PATH
function ci_install_tool() {
  local tool_executable=$1
  local tool_package=${2:-$1}
  if ! command -v $tool_executable &>/dev/null; then
    if [[ "$GITHUB_ACTIONS" == "true" ]]; then
      echo "::group::Installing ${tool_package}"
      sudo apt-get -y install ${tool_package} >/dev/null || {
        echo "Installing the package failed. Retrying..."
        sudo apt-get -y install ${tool_package}
      }
      echo '::endgroup::'
    else
      fail "$tool_executable wasn't found on PATH. You should first install $tool_package with your package manager."
    fi
  fi
}

# outputs the given message to stderr and exits the shell script
function fail() {
  echo "$*" >&2
  exit 1
}

# saves a given image (1st parameter) to the GitHub Actions Artifacts with the given name (2nd parameter)
function ci_docker_save_image_to_github_actions_artifacts() {
  local image=$1
  local artifactname="${2}.zst"
  ci_install_tool pv
  echo "::group::Saving docker image ${image} with name ${artifactname} in GitHub Actions Artifacts"
  # delete possible previous artifact that might exist when re-running
  timeout 1m gh-actions-artifact-client.js delete "${artifactname}" &>/dev/null || true
  docker save ${image} | zstd | pv -ft -i 5 | pv -Wbaf -i 5 | timeout 20m gh-actions-artifact-client.js upload --retentionDays=$ARTIFACT_RETENTION_DAYS "${artifactname}"
  echo "::endgroup::"
}

# loads a docker image from the GitHub Actions Artifacts with the given name (1st parameter)
function ci_docker_load_image_from_github_actions_artifacts() {
  local artifactname="${1}.zst"
  ci_install_tool pv
  echo "::group::Loading docker image from name ${artifactname} in GitHub Actions Artifacts"
  timeout 20m gh-actions-artifact-client.js download "${artifactname}" | pv -batf -i 5 | unzstd | docker load
  echo "::endgroup::"
}

# loads and extracts a zstd (.tar.zst) compressed tar file from the GitHub Actions Artifacts with the given name (1st parameter)
function ci_restore_tar_from_github_actions_artifacts() {
  local artifactname="${1}.tar.zst"
  ci_install_tool pv
  echo "::group::Restoring tar from name ${artifactname} in GitHub Actions Artifacts to $PWD"
  timeout 5m gh-actions-artifact-client.js download "${artifactname}" | pv -batf -i 5 | tar -I zstd -xf -
  echo "::endgroup::"
}

# stores a given command (with full arguments, specified after 1st parameter) output to GitHub Actions Artifacts with the given name (1st parameter)
function ci_store_tar_to_github_actions_artifacts() {
  local artifactname="${1}.tar.zst"
  shift
  if [[ "$GITHUB_ACTIONS" == "true" ]]; then
    ci_install_tool pv
    echo "::group::Storing $1 tar command output to name ${artifactname} in GitHub Actions Artifacts"
    # delete possible previous artifact that might exist when re-running
    timeout 1m gh-actions-artifact-client.js delete "${artifactname}" &>/dev/null || true
    "$@" | pv -ft -i 5 | pv -Wbaf -i 5 | timeout 10m gh-actions-artifact-client.js upload --retentionDays=$ARTIFACT_RETENTION_DAYS "${artifactname}"
    echo "::endgroup::"
  else
    local artifactfile="$(mktemp -t artifact.XXXX)"
    echo "Storing output for debugging in $artifactfile"
    "$@" | pv -ft -i 5 | pv -Wbaf -i 5 > $artifactfile
  fi
}

# copies test reports into test-reports and surefire-reports directory
# subsequent runs of tests might overwrite previous reports. This ensures that all test runs get reported.
function ci_move_test_reports() {
  (
    if [ -n "${GITHUB_WORKSPACE}" ]; then
      cd "${GITHUB_WORKSPACE}"
      mkdir -p test-reports
      mkdir -p surefire-reports
    fi
    # aggregate all junit xml reports in a single directory
    if [ -d test-reports ]; then
      # copy test reports to single directory, rename duplicates
      find . -path '*/target/surefire-reports/junitreports/TEST-*.xml' -print0 | xargs -0 -r -n 1 mv -t test-reports --backup=numbered
      # rename possible duplicates to have ".xml" extension
      (
        for f in test-reports/*~; do
          mv -- "$f" "${f}.xml"
        done 2>/dev/null
      ) || true
    fi
    # aggregate all surefire-reports in a single directory
    if [ -d surefire-reports ]; then
      (
        find . -type d -path '*/target/surefire-reports' -not -path './surefire-reports/*' |
          while IFS=$'\n' read -r directory; do
            echo "Copying reports from $directory"
            target_dir="surefire-reports/${directory}"
            if [ -d "$target_dir" ]; then
              # rotate backup directory names *~3 -> *~2, *~2 -> *~3, *~1 -> *~2, ...
              ( command ls -vr1d "${target_dir}~"* 2> /dev/null | awk '{print "mv "$0" "substr($0,0,length-1)substr($0,length,1)+1}' | sh ) || true
              # backup existing target directory, these are the results of the previous test run
              mv "$target_dir" "${target_dir}~1"
            fi
            # copy files
            cp -R --parents "$directory" surefire-reports
            # remove the original directory
            rm -rf "$directory"
          done
      )
    fi
  )
}

ci_snapshot_pulsar_maven_artifacts() {
  (
  if [ -n "$GITHUB_WORKSPACE" ]; then
    cd "$GITHUB_WORKSPACE"
  else
    fail "This script can only be run in GitHub Actions"
  fi
  mkdir -p target
  find $HOME/.m2/repository/org/apache/pulsar -name "*.jar" > /tmp/provided_pulsar_maven_artifacts
  )
}

ci_upload_unittest_coverage_files() {
  _ci_upload_coverage_files unittest "$1"
}

ci_upload_inttest_coverage_files() {
  _ci_upload_coverage_files_inttest inttest "$1" integration-tests
}

ci_upload_systest_coverage_files() {
  _ci_upload_coverage_files_inttest systest "$1" system-tests
}

_ci_upload_coverage_files_inttest() {
  local testtype="$1"
  local testgroup="$2"
  local job_name="$3"
  local store_deps=0
  local firsttestgroup="$(_ci_list_testgroups_with_coverage "${job_name}" | head -1)"
  if [[ "${firsttestgroup}" == "${testgroup}" ]]; then
    store_deps=1
  fi
  _ci_upload_coverage_files "${testtype}" "${testgroup}" $store_deps
}

_ci_upload_coverage_files() {
  (
  testtype="$1"
  testgroup="$2"
  store_deps="${3:-1}"
  echo "::group::Uploading $testtype coverage files"
  if [ -n "$GITHUB_WORKSPACE" ]; then
    cd "$GITHUB_WORKSPACE"
  else
    fail "This script can only be run in GitHub Actions"
  fi

  if [ ! -f /tmp/provided_pulsar_maven_artifacts ]; then
    fail "It is necessary to run '$0 snapshot_pulsar_maven_artifacts' before running any tests."
  fi

  set -x

  local classpathFile="target/classpath_${testtype}_${testgroup}"

  local execFiles=$(find . -path "*/target/jacoco*.exec" -printf "%P\n")
  if [[ -n "$execFiles" ]]; then
    # create temp file
    local completeClasspathFile=$(mktemp -t tmp.classpath.XXXX)

    ci_install_tool xmlstarlet

    local projects=$({
      for execFile in $execFiles; do
        echo $(dirname "$(dirname "$execFile")")
      done
    } | sort | uniq)

    # iterate the projects
    for project in $projects; do
      local artifactId=$(xmlstarlet sel -t -m _:project -v _:artifactId -n $project/pom.xml)
      # find the test scope classpath for the project
      mvn -f $project/pom.xml -DincludeScope=test -Dscan=false dependency:build-classpath  -B | { grep 'Dependencies classpath:' -A1 || true; } | tail -1 \
                                    | sed 's/:/\n/g' | { grep 'org/apache/pulsar' || true; } \
                                    | { tee -a $completeClasspathFile || true; } > target/classpath_$artifactId || true
    done

    cat $completeClasspathFile | sort | uniq > $classpathFile
    # delete temp file
    rm $completeClasspathFile

    # upload target/jacoco*.exec, target/classes and any dependent jar files that were built during the unit test execution
    # transform jacoco exec filenames by appending "_${testtype}_${testgroup}" to the filename part to make the files unique
    # so that they don't get overridden when all files are extracted to the same working directory before merging
    (
      cd /
      ci_store_tar_to_github_actions_artifacts coverage_and_deps_${testtype}_${testgroup} \
                tar -I zstd -cPf - \
                  --transform="flags=r;s|\\(/jacoco.*\\).exec$|\\1_${testtype}_${testgroup}.exec|" \
                  --transform="flags=r;s|\\(/tmp/jacocoDir/.*\\).exec$|\\1_${testtype}_${testgroup}.exec|" \
                  --exclude="*/META-INF/bundled-dependencies/*" \
                  --exclude="*/META-INF/versions/*" \
                  $GITHUB_WORKSPACE/target/classpath_* \
                  $(find "$GITHUB_WORKSPACE" -path "*/target/jacoco*.exec" -printf "%p\n%h/classes\n" | sort | uniq) \
                  $([ -d /tmp/jacocoDir ] && echo "/tmp/jacocoDir" ) \
                  $([[ $store_deps -eq 1 ]] && { cat $GITHUB_WORKSPACE/$classpathFile | sort | uniq | { grep -v -Fx -f /tmp/provided_pulsar_maven_artifacts || true; }; } || true)
    )
  fi
  echo "::endgroup::"
  )
}

ci_restore_unittest_coverage_files() {
  _ci_restore_coverage_files unittest unit-tests
}

ci_restore_inttest_coverage_files() {
  _ci_restore_coverage_files inttest integration-tests
}

ci_restore_systest_coverage_files() {
  _ci_restore_coverage_files systest system-tests
}

_ci_restore_coverage_files() {
  (
  test_type="$1"
  job_name="$2"
  cd /
  for testgroup in $(_ci_list_testgroups_with_coverage "${job_name}"); do
    ci_restore_tar_from_github_actions_artifacts coverage_and_deps_${test_type}_${testgroup} || true
  done
  )
}

_ci_list_testgroups_with_coverage() {
  local job_name="$1"
  yq e ".jobs.${job_name}.strategy.matrix.include.[] | select(.no_coverage != true) | .group" "$GITHUB_WORKSPACE/.github/workflows/pulsar-ci.yaml"
}

ci_delete_unittest_coverage_files() {
  _ci_delete_coverage_files unittest unit-tests
}

ci_delete_inttest_coverage_files() {
  _ci_delete_coverage_files inttest integration-tests
}

ci_delete_systest_coverage_files() {
  _ci_delete_coverage_files systest system-tests
}

_ci_delete_coverage_files() {
  (
  test_type="$1"
  job_name="$2"
  for testgroup in $(yq e ".jobs.${job_name}.strategy.matrix.include.[] | select(.no_coverage != true) | .group" "$GITHUB_WORKSPACE/.github/workflows/pulsar-ci.yaml"); do
    timeout 1m gh-actions-artifact-client.js delete coverage_and_deps_${test_type}_${testgroup}.tar.zst || true
  done
  )
}

# creates an aggregated jacoco xml report for all projects that contain a target/jacoco*.exec file
#
# the default maven jacoco report has multiple problems:
# - by default, jacoco:report goal will only report coverage for the current project. it is not suitable for Pulsar's
#   unit tests that test production code that resides in multiple modules.
#    - there's jacoco:report-aggregate that is supposed to resolve this. It has 2 issues:
#       - 0.8.8 version doesn't yet support the required "includeCurrentProject" feature
#       - the dependent projects must be built as part of the same mvn execution and belong to the same maven "reactor"
#          - this isn't compatible with the way how Pulsar CI builds in "Build and License check" job and reuses
#            the build results to run unit tests.
#
# This solution resolves the problem by using the Jacoco command line tool to generate the report.
# It assumes that all projects that contain a target/jacoco*.exec file will also contain compiled classfiles.
ci_create_test_coverage_report() {
  echo "::group::Create test coverage report"
  if [ -n "$GITHUB_WORKSPACE" ]; then
    cd "$GITHUB_WORKSPACE"
  else
    cd "$SCRIPT_DIR/.."
  fi

  local execFiles=$(find . -path "*/target/jacoco*.exec" -size +10c -printf "%P\n" )
  if [[ -n "$execFiles" ]]; then
    mkdir -p /tmp/jacocoDir
    if [ ! -f /tmp/jacocoDir/jacococli.jar ]; then
      local jacoco_version=$(mvn help:evaluate -Dscan=false -Dexpression=jacoco-maven-plugin.version -q -DforceStdout)
      curl -sL -o /tmp/jacocoDir/jacococli.jar "https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/${jacoco_version}/org.jacoco.cli-${jacoco_version}-nodeps.jar"
    fi

    ci_install_tool xmlstarlet
    # create mapping from project directory to project artifactId
    local projectToArtifactIdMapping=$(find -name pom.xml -printf "%P\n" |xargs -I{} bash -c 'echo -n "$(dirname $1) "; xmlstarlet sel -t -m _:project -v _:artifactId -n $1' -- {})

    # create temp files
    local completeClasspathFile=$(mktemp -t tmp.classpath.XXXX)
    local filterArtifactsFile=$(mktemp -t tmp.artifacts.XXXX)
    local classesDir=$(mktemp -d -t tmp.classes.XXXX)
    local sourcefilesFile=$(mktemp -t tmp.sources.XXXX)

    local projects=$({
      for execFile in $execFiles; do
        echo $(dirname "$(dirname "$execFile")")
      done
    } | sort | uniq)

    # projects that aren't considered as production code and their own src/main/java source code shouldn't be analysed
    local excludeProjectsPattern="testmocks|testclient|buildtools"

    # iterate projects
    for project in $projects; do
      local artifactId="$(printf "%s" "$projectToArtifactIdMapping" | grep -F "$project " | cut -d' ' -f2)"
      if [[ -d "$project/target/classes" && -d "$project/src/main/java" ]]; then
        mkdir -p "$classesDir/$project"
        cp -Rl "$project/target/classes" "$classesDir/$project"
        echo "$project/src/main/java" >> $sourcefilesFile
      fi
      echo "/$artifactId/" >> $filterArtifactsFile
      if [[ -n "$(echo "$project" | grep -v -E "$excludeProjectsPattern")" ]]; then
        if [ -f "target/classpath_$artifactId" ]; then
          echo "Found cached classpath for $artifactId."
          cat "target/classpath_$artifactId" >> $completeClasspathFile
        else
          echo "Resolving classpath for $project..."
          # find the test scope classpath for the project
          mvn -f $project/pom.xml -DincludeScope=test -Dscan=false dependency:build-classpath  -B | { grep 'Dependencies classpath:' -A1 || true; } | tail -1 \
                                        | sed 's/:/\n/g' | { grep 'org/apache/pulsar' || true; } \
                                        >> $completeClasspathFile || true
        fi
      else
        echo "Skipping analysing of $project"
      fi
    done

    # delete any possible embedded jar files in the classes directory
    find "$classesDir" -name "*.jar" -print -delete

    local excludeJarsPattern="bouncy-castle-bc|tests|$excludeProjectsPattern"

    local classfilesArgs="--classfiles $({
      {
        for classpathEntry in $(cat $completeClasspathFile | { grep -v -f $filterArtifactsFile || true; } | sort | uniq | { grep -v -E "$excludeJarsPattern" || true; }); do
            if [[ -f $classpathEntry && -n "$(unzip -Z1C $classpathEntry 'META-INF/bundled-dependencies/*' 'META-INF/versions/*' 2>/dev/null)" ]]; then
              # file must be processed by removing META-INF/bundled-dependencies and META-INF/versions
              local jartempfile=$(mktemp -t jarfile.XXXX --suffix=.jar)
              cp $classpathEntry $jartempfile
              zip -q -d $jartempfile 'META-INF/bundled-dependencies/*' 'META-INF/versions/*' &> /dev/null
              echo $jartempfile
            else
              echo $classpathEntry
            fi
        done
      }
      echo $classesDir
    } | tr '\n' ':' | sed -e 's/:$//' -e 's/:/ --classfiles /g')"

    local sourcefilesArgs="--sourcefiles $({
      # find the source file folders for the pulsar .jar files that are on the classpath
      for artifactId in $(cat $completeClasspathFile  | sort | uniq | { grep -v -E "$excludeJarsPattern" || true; } | perl -p -e 's|.*/org/apache/pulsar/([^/]*)/.*|$1|'); do
        local project="$(printf "%s" "$projectToArtifactIdMapping" | { grep $artifactId || true; } | cut -d' ' -f1)"
        if [[ -n "$project" && -d "$project/src/main/java" ]]; then
          echo "$project/src/main/java"
        fi
      done
      cat $sourcefilesFile
    } | tr '\n' ':' | sed -e 's/:$//' -e 's/:/ --sourcefiles /g')"

    rm $completeClasspathFile $filterArtifactsFile $sourcefilesFile

    set -x
    mkdir -p target/jacoco_test_coverage_report/html
    java -jar /tmp/jacocoDir/jacococli.jar report $execFiles \
          $classfilesArgs \
          --encoding UTF-8 --name "Apache Pulsar test coverage" \
          $sourcefilesArgs \
          --xml target/jacoco_test_coverage_report/jacoco.xml \
          --html target/jacoco_test_coverage_report/html \
          --csv target/jacoco_test_coverage_report/jacoco.csv
    set +x

    rm -rf "$classesDir"
  fi
  echo "::endgroup::"
}

# creates a jacoco xml report of the jacoco exec files produced in docker containers which have /tmp/jacocoDir mounted as /jacocoDir
# this is used to calculate test coverage for the apache/pulsar code that is run inside the containers in integration tests
# and system tests
ci_create_inttest_coverage_report() {
  echo "::group::Create int test coverage in containers report"
  if [[ -n "$(find /tmp/jacocoDir -name "*.exec" -print -quit)" ]]; then
    cd "$GITHUB_WORKSPACE"
    echo "Creating coverage report to target/jacoco_inttest_coverage_report"
    set -x
    mkdir -p target/jacoco_inttest_coverage_report
    # install jacococli.jar command line tool
    if [ ! -f /tmp/jacocoDir/jacococli.jar ]; then
      local jacoco_version=$(mvn help:evaluate -Dexpression=jacoco-maven-plugin.version -Dscan=false -q -DforceStdout)
      curl -sL -o /tmp/jacocoDir/jacococli.jar "https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/${jacoco_version}/org.jacoco.cli-${jacoco_version}-nodeps.jar"
    fi
    # extract the Pulsar jar files from the docker image that was used to run the tests in the docker containers
    # the class files used to produce the jacoco exec files are needed in the xml report generation
    if [ ! -d /tmp/jacocoDir/pulsar_lib ]; then
      mkdir /tmp/jacocoDir/pulsar_lib
      docker run --rm -u "$UID:${GID:-"$(id -g)"}" -v /tmp/jacocoDir/pulsar_lib:/pulsar_lib:rw ${PULSAR_TEST_IMAGE_NAME:-apachepulsar/java-test-image:latest} bash -c "cp -p /pulsar/lib/org.apache.pulsar-* /pulsar_lib; [ -d /pulsar/connectors ] && cp -R /pulsar/connectors /pulsar_lib || true"
      # remove jar file that causes duplicate classes issue
      rm /tmp/jacocoDir/pulsar_lib/org.apache.pulsar-bouncy-castle* || true
      # remove any bundled dependencies as part of .jar/.nar files
      find /tmp/jacocoDir/pulsar_lib '(' -name "*.jar" -or -name "*.nar" ')' -exec echo "Processing {}" \; -exec zip -q -d {} 'META-INF/bundled-dependencies/*' 'META-INF/versions/*' \; |grep -E -v "Nothing to do|^$" || true
    fi
    # projects that aren't considered as production code and their own src/main/java source code shouldn't be analysed
    local excludeProjectsPattern="testmocks|testclient|buildtools"
    # produce jacoco XML coverage report from the exec files and using the extracted jar files
    java -jar /tmp/jacocoDir/jacococli.jar report /tmp/jacocoDir/*.exec \
      --classfiles /tmp/jacocoDir/pulsar_lib --encoding UTF-8 --name "Pulsar Integration Tests - coverage in containers" \
      $(find -path "*/src/main/java" -printf "--sourcefiles %P " | grep -v -E "$excludeProjectsPattern") \
      --xml target/jacoco_inttest_coverage_report/jacoco.xml \
      --html target/jacoco_inttest_coverage_report/html \
      --csv target/jacoco_inttest_coverage_report/jacoco.csv
    set +x
  else
    echo "No /tmp/jacocoDir/*.exec files to process"
  fi
  echo "::endgroup::"
}

if [ -z "$1" ]; then
  echo "usage: $0 [ci_tool_function_name]"
  echo "Available ci tool functions:"
  ci_list_functions
  exit 1
fi
ci_function_name="ci_$1"
shift

if [[ "$(LC_ALL=C type -t "${ci_function_name}")" == "function" ]]; then
  eval "$ci_function_name" "$@"
else
  echo "Invalid ci tool function"
  echo "Available ci tool functions:"
  ci_list_functions
  exit 1
fi
