#!/bin/bash
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd)

# shellcheck source=fbcode/flow/scripts/lib/runtests-common.sh
. "$THIS_DIR/lib/runtests-common.sh"

# Use the assert functions below to expect specific exit codes.

assert_exit_on_line() {
  # Run all this in a subshell, so we can set -e without affecting the caller
  (
    set -e
    _assert_exit__line=$1; shift
    _assert_exit__ret=0
    _assert_exit__code=$1; shift
    "$@" ||  _assert_exit__ret=$?
    eval "$SAVED_OPTION" # Undo the `set -e` if necessary
    if [ "$_assert_exit__ret" -eq "$_assert_exit__code" ]; then
      return 0
    else
      echo \
        "\`$(basename "$1") ${*:2}\` expected to exit code $_assert_exit__code" \
        "but got $_assert_exit__ret (line $_assert_exit__line)"
      return 1
    fi
  )
  return $?
}

export EXIT_OK=0
export EXIT_ONE=1
export EXIT_ERRS=2
export EXIT_INVALID_FLOWCONFIG=8
export EXIT_SERVER_ALREADY_RUNNING=11
export EXIT_COULD_NOT_FIND_FLOWCONFIG=12
export EXIT_USAGE=64

assert_exit() {
  assert_exit_on_line "${BASH_LINENO[0]}" "$@"
}
assert_ok() {
  assert_exit_on_line "${BASH_LINENO[0]}" "$EXIT_OK" "$@"
}
assert_one() {
  assert_exit_on_line "${BASH_LINENO[0]}" "$EXIT_ONE" "$@"
}
assert_errors() {
  assert_exit_on_line "${BASH_LINENO[0]}" "$EXIT_ERRS" "$@"
}
assert_server_already_running() {
  assert_exit_on_line "${BASH_LINENO[0]}" "$EXIT_SERVER_ALREADY_RUNNING" "$@"
}

# Query utilities

query_at_pos() {
  local query=$1
  local file=$2
  local line=$3
  local col=$4
  shift 4
  local flags=("$@")

  printf "%s:%s:%s\n" "$file" "$line" "$col"
  echo "Flags:" "${flags[@]}"
  # shellcheck disable=SC2094
  assert_ok "$FLOW" "$query" "$file" "$line" "$col" --strip-root "${flags[@]}" < "$file"
  printf "\n"
}

# Utility to create queries that involve specific locations (line, column)
#
# This function performs 'query' on all locations (line, col) in 'file' that
# have a ^ in comments at location (line+1, col).
#
# E.g.
#   type A = number;
#   //   ^
#
# You can also pass in flags after the ^:
# //   ^ --evaluate-type-destructors
#
# Invoke this passing the query name, the file, and any additional flags to be
# applied to every query in the file, e.g.
#
#   query_in_file "type-at-pos" "file.js" --pretty --json
#
queries_in_file() {
  local query=$1
  local file=$2
  shift 2
  local arg_flags_array=("$@")

  awk '/^\/\/.*\^/{ print NR }' "$file" | while read -r line; do
    local linep="$line""p"
    # Compute the column of the query
    local col
    col=$(
      sed -n "$linep" "$file" | \
      awk -F'^' '{ print $1}' | \
      wc -c
    )
    col="$((col))" # trim wc's whitespaces
    # Read line flags into an array (space separation)
    local line_flags_array=()
    IFS=" " read -r -a line_flags_array <<< "$(
      sed -n "$linep" "$file" | \
      awk -F'^' '{ print $2 }'
    )"
    if [ -n "$col" ]; then
      # Get line above
      ((line--))
      # Aggregate flags
      local all_flags=("${arg_flags_array[@]}" "${line_flags_array[@]}")
      query_at_pos "$query" "$file" "$line" "$col" "${all_flags[@]}"
    fi
  done
}

show_skipping_stats() {
  printf "\\n========Skipping stats========\\n"
  grep -o "Merge skipped [0-9]\+ of [0-9]\+ modules" $1 | tail -n 1
  grep -o "Check will skip [0-9]\+ of [0-9]\+ files" $1 | tail -n 1
}

# start_flow_unsafe ROOT [OPTIONS]...
start_flow_unsafe () {
  local root=$1; shift

  if [ ! -d "$root" ]; then
    printf "Invalid root directory '%s'\\n" "$root" >&2
    return 1
  fi

  if [[ "$saved_state" -eq 1 ]]; then
    local flowconfig_name=".flowconfig"
    for ((i=1; i<=$#; i++)); do
      opt="${!i}"
      if [ "$opt" = "--flowconfig-name" ]
      then
        ((i++))
        flowconfig_name=${!i}
      fi
    done

    if create_saved_state "$root" "$flowconfig_name"
    then
      PATH="$THIS_DIR/scripts/tests_bin:$PATH" \
      "$FLOW" start "$root" \
        $flowlib --wait \
        --wait-for-recheck "$wait_for_recheck" \
        --saved-state-fetcher "local" \
        --saved-state-no-fallback \
        --file-watcher "$file_watcher" \
        --log-file "$abs_log_file" \
        --monitor-log-file "$abs_monitor_log_file" \
        --long-lived-workers "$long_lived_workers" \
        "$@"
      return $?
    else
        printf "Failed to generate saved state\\n" >&2
        return 1
    fi
  else
    # start server and wait
    PATH="$THIS_DIR/scripts/tests_bin:$PATH" \
    "$FLOW" start "$root" \
      $flowlib --wait \
      --wait-for-recheck "$wait_for_recheck" \
      --file-watcher "$file_watcher" \
      --log-file "$abs_log_file" \
      --monitor-log-file "$abs_monitor_log_file" \
      --long-lived-workers "$long_lived_workers" \
      "$@"
    return $?
  fi
}

# start_flow_unsafe ROOT [OPTIONS]...
start_flow () {
  assert_ok start_flow_unsafe "$@"
}


# This function runs in the background so it shouldn't output anything to
# stdout or stderr. It should only communicate through its return value
runtest() {
    dir=$1
    dir=${dir%*/}
    name=${dir##*/}
    exp_file="${name}.exp"

    # On Windows we skip some tests as symlinks not available
    if [ "$OSTYPE" = "msys" ]  &&
      ([ "$name" = "symlink" ] ||
       [ "$name" = "node_tests" ])
    then
        return $RUNTEST_SKIP
    elif [[ -z $filter || $name =~ $filter ]]
    then
        if ([ ! -e "$dir/$exp_file" ] || [[ ! -e "$dir/.flowconfig" && ! -e "$dir/.testconfig" ]])
        then
            if [ "$name" = "auxiliary" ] ||
               [ "$name" = "callable" ]
            then
                return $RUNTEST_SKIP;
            else
                return $RUNTEST_MISSING_FILES;
            fi
        fi

        # Each test should write its output to a unique directory so that
        # parallel runs of the same test don't stomp on each other (Facebook
        # internally runs a stress test to look for flaky tests). If a test
        # fails, we'll then copy the files back to the source directory.
        OUT_PARENT_DIR=$(mktemp -d /tmp/flow_test.XXXXXX)
        OUT_DIR="$OUT_PARENT_DIR/$name"
        mkdir "$OUT_DIR"

        # deletes the temp directory
        function cleanup {
          rm -rf "$OUT_PARENT_DIR"
        }
        function force_cleanup {
          "$FLOW" stop "$OUT_DIR" 1> /dev/null 2>&1
          cleanup
          exit 1
        }
        trap 'cleanup' RETURN
        trap 'force_cleanup' EXIT

        # Some tests mutate the source directory, so make a copy first and run
        # from there. the . in "$dir/." copies the entire directory, including
        # hidden files like .flowconfig.
        cp -R "$dir/." "$OUT_DIR"
        cp "$dir/../fs.sh" "$OUT_PARENT_DIR" 2>/dev/null || :
        mv "$OUT_DIR/$exp_file" "$OUT_PARENT_DIR"

        export FLOW_TEMP_DIR="$OUT_PARENT_DIR"

        out_file="$name.out"
        log_file="$name.log"
        monitor_log_file="$name.monitor_log"
        err_file="$name.err"
        diff_file="$name.diff"
        abs_out_file="$OUT_PARENT_DIR/$name.out"
        abs_log_file="$OUT_PARENT_DIR/$name.log"
        abs_monitor_log_file="$OUT_PARENT_DIR/$name.monitor_log"
        abs_err_file="$OUT_PARENT_DIR/$name.err"
        abs_diff_file="$OUT_PARENT_DIR/$name.diff"
        return_status=$RUNTEST_SUCCESS

        export FLOW_LOG_FILE="$abs_log_file"
        export FLOW_MONITOR_LOG_FILE="$abs_monitor_log_file"

        # run the tests from inside $OUT_DIR
        pushd "$OUT_DIR" >/dev/null

        # get config flags.
        # for now this is kind of ad-hoc:
        #
        # 1. The default flow command can be overridden here by supplying the
        # entire command on a line that begins with "cmd:". (Note: for writing
        # incremental tests, use the option below). A line beginning with
        # "stdin:" can additionally supply arguments to pass to the command via
        # standard input. We start the server before running the command and
        # stop the server after we get a result.
        #
        # 2. Integration tests that interleave flow commands with file system
        # commands (for testing incremental checks, e.g.) are written by
        # supplying the name of a shell script on a line that begins with
        # "shell:". The shell script is passed the location of the flow binary
        # as argument ($1). We start the server before running the script and
        # stop the server after the script exits.
        #
        auto_start=true
        flowlib=" --no-flowlib"
        shell=""
        cmd="full-check"
        stdin=""
        stderr_dest="$abs_err_file"
        ignore_stderr=true
        cwd=""
        start_args=""
        file_watcher="none"
        wait_for_recheck="true"

        if [ -e ".testconfig" ]
        then
            # auto_start
            if [ "$(awk '$1=="auto_start:"{print $2}' .testconfig)" == "false" ]
            then
                auto_start=false
            fi
            # cwd (current directory)
            cwd="$(awk '$1=="cwd:"{print $2}' .testconfig)"
            # ignore_stderr
            if [ "$(awk '$1=="ignore_stderr:"{print $2}' .testconfig)" == "false" ]
            then
                ignore_stderr=false
                stderr_dest="$abs_out_file"
            fi
            # stdin
            stdin="$(awk '$1=="stdin:"{print $2}' .testconfig)"
            # shell
            config_shell="$(awk '$1=="shell:"{print $2}' .testconfig)"
            if [ "$config_shell" != "" ]
            then
                cmd=""
                shell="$config_shell"
            fi
            # file_watcher
            config_fw="$(awk '$1=="file_watcher:"{print $2}' .testconfig)"
            if [ "$config_fw" != "" ]
            then
              file_watcher="$config_fw"
            fi
            # start_args
            config_start_args="$(awk '$1=="start_args:"{$1="";print}' .testconfig)"
            if [ "$config_start_args" != "" ]
            then
                start_args="$config_start_args"
            fi
            # cmd
            config_cmd="$(awk '$1=="cmd:"{$1="";print}' .testconfig)"
            config_cmd="${config_cmd## }" # trim leading space
            if [ "$config_cmd" != "" ]
            then
                cmd="$config_cmd"
            fi

            if [[ "$saved_state" -eq 1 ]] && \
              [ "$(awk '$1=="skip_saved_state:"{print $2}' .testconfig)" == "true" ]
            then
                return $RUNTEST_SKIP
            fi

            if [[ "$saved_state" -eq 0 ]] && \
              [ "$(awk '$1=="saved_state_only:"{print $2}' .testconfig)" == "true" ]
            then
                return $RUNTEST_SKIP
            fi

            if [ -z "$FLOW_GIT_BINARY" ] && [ "$(awk '$1=="git:"{print $2}' .testconfig)" == "true" ]
            then
                return $RUNTEST_SKIP
            fi

            # wait_for_recheck
            if [ "$(awk '$1=="wait_for_recheck:"{print $2}' .testconfig)" == "false" ]
            then
                wait_for_recheck="false"
            fi
        fi

        if [ "$cwd" != "" ]; then
            pushd "$cwd" >/dev/null
        fi

        # if .flowconfig sets no_flowlib, don't pass the cli flag
        if [ -f .flowconfig ] && grep -q "no_flowlib" .flowconfig; then
            flowlib=""
        fi

        if [ -f .flowconfig ] && ! grep -q -E "all=(true|false)" .flowconfig; then
          return $RUNTEST_MISSING_ALL_OPTION
        fi

        if [[ "$check_only" -eq 1 ]] && [[ "$cmd" != "full-check" ]]; then
          return $RUNTEST_SKIP
        fi

        # Helper function to generate saved state. If anything goes wrong, it
        # will fail
        create_saved_state () {
          local root="$1"
          local flowconfig_name="$2"
          (
            set -e
            # start server and wait
            "$FLOW" start "$root" \
              $flowlib --wait \
              --wait-for-recheck "$wait_for_recheck" \
              --lazy-mode none \
              --file-watcher "$file_watcher" \
              --flowconfig-name "$flowconfig_name" \
              --log-file "$abs_log_file" \
              --monitor-log-file "$abs_monitor_log_file" \
              --long-lived-workers "$long_lived_workers" \

            local SAVED_STATE_FILENAME="$root/.flow.saved_state"
            local CHANGES_FILENAME="$root/.flow.saved_state_file_changes"
            assert_ok "$FLOW" save-state \
              --root "$root" \
              --out "$SAVED_STATE_FILENAME" \
              --flowconfig-name "$flowconfig_name"
            assert_ok "$FLOW" stop --flowconfig-name "$flowconfig_name" "$root"
            touch "$CHANGES_FILENAME"
          )  > /dev/null 2>&1
          local RET=$?
          return $RET
        }

        # run test
        if [ "$cmd" == "full-check" ]
        then
            if [[ "$saved_state" -eq 1 ]]; then
              return $RUNTEST_SKIP
            else
              # default command is full-check with configurable --no-flowlib
              "$FLOW" full-check . \
                $flowlib \
                --strip-root --show-all-errors \
                --long-lived-workers "$long_lived_workers" \
                 1>> "$abs_out_file" 2>> "$stderr_dest"
              st=$?
            fi
            if [ $ignore_stderr = true ] && [ -n "$st" ] && [ $st -ne 0 ] && [ $st -ne 2 ]; then
              printf "flow full-check return code: %d\\n" "$st" >> "$stderr_dest"
              return_status=$RUNTEST_ERROR
            fi
        elif [ "$(echo "$cmd" | awk '{print $1}')" == "annotate-exports" ]
        then

          # parse flags after 'cmd: annotate-exports'
          config_cmd_args="$(echo "$cmd" | awk '{$1="";print}')"
          cmd_args=("$config_cmd_args")

          input_file="input.txt"
          # LC_ALL=C ensures stable sorting between linux & macos
          find . -name "*.js" -o -name "*.js.flow" | env LC_ALL=C sort > "$input_file"

          (echo ""; echo "=== Codemod annotate-exports ==="; echo "") >> "$abs_out_file"

          # keep copies of the original files
          xargs -I {} cp {} {}.orig < "$input_file"

          # shellcheck disable=SC2086
          codemod_out=$(\
            "$FLOW" "codemod" "annotate-exports" \
                $flowlib \
                ${cmd_args[*]} \
                --strip-root \
                --quiet \
                --input-file "$input_file" \
                --write \
                . \
                2>> "$stderr_dest"
          )
          st=$?

          # Keep copies of codemod-ed files
          xargs -I {} cp {} {}.codemod < "$input_file"

          # Compare codemod-ed with original
          while read -r file; do
            diff --strip-trailing-cr \
              --label "$file.orig" --label "$file.codemod" \
              "$file.orig" "$file" > /dev/null
            code=$?
            if [ $code -ne 0 ]; then
              (echo ">>> $file"; cat "$file"; echo "") >> "$abs_out_file"
            fi
          done <"$input_file"

          (echo "$codemod_out"; echo "") >> "$abs_out_file"

          (echo ""; echo "=== Autofix exports ==="; echo "") >> "$abs_out_file"

          # Restore original versions
          xargs -I {} cp {}.orig {} < "$input_file"

          # Run autofix version
          start_flow . --quiet
          while read -r file; do
            "$FLOW" "autofix" "exports" \
                --strip-root \
                --in-place \
                "$file" \
                2>> "$stderr_dest"
          done <"$input_file"
          "$FLOW" stop . 1> /dev/null 2>&1

          # Keep copies of autofix-ed files
          xargs -I {} cp {} {}.autofix < "$input_file"

          # Compare autofix-ed with original
          while read -r file; do
            diff --strip-trailing-cr \
              --label "$file.orig" --label "$file.autofix" \
              "$file.orig" "$file" > /dev/null
            code=$?
            if [ $code -ne 0 ]; then
              (echo ">>> $file"; cat "$file.autofix"; echo "") >> "$abs_out_file"
            fi
          done <"$input_file"

          # Compare codemod-ed and autofix-ed files
          (echo ""; echo "=== Diff between codemod-ed & autofix-ed ===") >> "$abs_out_file"
          while read -r file; do
            diff_result=$(\
              diff --strip-trailing-cr \
                --label "$file.codemod" --label "$file.autofix" \
                "$file.codemod" "$file.autofix"\
              )
            code=$?
            if [ $code -ne 0 ]; then
              (echo ">>> $file"; echo "$diff_result"; echo "") >> "$abs_out_file"
            fi
          done <"$input_file"

          if [ $ignore_stderr = true ] && [ -n "$st" ] && [ $st -ne 0 ] && [ $st -ne 2 ]; then
            printf "flow codemod return code: %d\\n" "$st" >> "$stderr_dest"
            return_status=$RUNTEST_ERROR
          fi
        else
            # otherwise, run specified flow command, then kill the server

            if [ $auto_start = true ]; then
              start_flow_unsafe . $start_args > /dev/null 2>> "$abs_err_file"
              code=$?
              if [ $code -ne 0 ]; then
                # flow failed to start
                printf "flow start exited code %s\\n" "$code" >> "$abs_out_file"
                return_status=$RUNTEST_ERROR
              fi
            fi

            if [ $return_status -ne $RUNTEST_ERROR ]; then
              if [ "$shell" != "" ]; then
                # run test script in subshell so it inherits functions
                (
                  set -e # The script should probably use this option
                  export PATH="$THIS_DIR/scripts/tests_bin:$PATH"
                  if [ "$flowlib" == " --no-flowlib" ]; then
                    export NO_FLOWLIB=1
                  fi
                  source "$shell" "$FLOW"
                ) 1> "$abs_out_file" 2> "$stderr_dest"
                code=$?
                if [ $code -ne 0 ]; then
                  printf "%s exited code %s\\n" "$shell" "$code" >> "$abs_out_file"
                  return_status=$RUNTEST_ERROR
                fi
              else
              # If there's stdin, then direct that in
              # cmd should NOT be double quoted...it may contain many commands
              # and we do want word splitting
                  if [ "$stdin" != "" ]
                  then
                      cmd="$FLOW $cmd < $stdin 1> $abs_out_file 2> $stderr_dest"
                  else
                      cmd="$FLOW $cmd 1> $abs_out_file 2> $stderr_dest"
                  fi
                  eval "$cmd"
              fi

              # stop server, even if we didn't start it
              "$FLOW" stop . 1> /dev/null 2>&1
            fi
        fi

        if [ "$cwd" != "" ]; then
            popd >/dev/null
        fi

        # leave $OUT_DIR
        popd >/dev/null

        if [ $return_status -eq $RUNTEST_SUCCESS ]; then
          pushd "$OUT_PARENT_DIR" >/dev/null
          # When diffing the .exp against the .out, replace <VERSION> in the
          # .exp with the actual version, so the diff shows which version we
          # were expecting, but the .exp doesn't need to be updated for each
          # release.
          diff -u --strip-trailing-cr \
            --label "$exp_file" --label "$out_file" \
            <(awk '{gsub(/<VERSION>/, "'"$VERSION"'"); print $0}' "$exp_file") \
            "$out_file" \
            > "$diff_file" 2>&1
          popd >/dev/null
        fi

        if [ $return_status -ne $RUNTEST_SUCCESS ]; then
            [ -s "$abs_out_file" ] && mv "$abs_out_file" "$dir" || rm -f "$dir/$out_file"
            [ -s "$abs_log_file" ] && mv "$abs_log_file" "$dir" || rm -f "$dir/$log_file"
            [ -s "$abs_monitor_log_file" ] && mv "$abs_monitor_log_file" "$dir" || rm -f "$dir/$monitor_log_file"
            [ -s "$abs_err_file" ] && mv "$abs_err_file" "$dir" || rm -f "$dir/$err_file"
            return $return_status
        elif [ -s "$abs_diff_file" ]; then
            mv "$abs_out_file" "$dir"
            [ -s "$abs_log_file" ] && mv "$abs_log_file" "$dir" || rm -f "$dir/$log_file"
            [ -s "$abs_monitor_log_file" ] && mv "$abs_monitor_log_file" "$dir" || rm -f "$dir/$monitor_log_file"
            [ -s "$abs_err_file" ] && mv "$abs_err_file" "$dir" || rm -f "$dir/$err_file"
            mv "$abs_diff_file" "$dir"
            return $RUNTEST_FAILURE
        else
            rm -f "$dir/$out_file"
            rm -f "$dir/$log_file"
            rm -f "$dir/$monitor_log_file"
            rm -f "$dir/$err_file"
            rm -f "$dir/$diff_file"
            return $RUNTEST_SUCCESS
        fi
    else
        return $RUNTEST_SKIP
    fi
}

index=${1%:*}
dir=${1#*:}

runtest "$dir"
code=$?
trap - EXIT

echo "$index" "$code"
exit "$code"
