#!/bin/bash

# ? ABOUT: Functions defined here aid with common functionality during tests.

# ! ATTENTION: Functions prefixed with `__` are intended for internal use within this file only, not in tests.

# ! -------------------------------------------------------------------
# ? >> Miscellaneous initialization functionality

# shellcheck disable=SC2155

# Load additional BATS libraries for more functionality.
#
# ## Note
#
# This function is internal and should not be used in tests.
function __load_bats_helper() {
  load "${REPOSITORY_ROOT}/test/test_helper/bats-support/load"
  load "${REPOSITORY_ROOT}/test/test_helper/bats-assert/load"
  load "${REPOSITORY_ROOT}/test/helper/sending"
  load "${REPOSITORY_ROOT}/test/helper/log_and_filtering"
}

__load_bats_helper

# Properly handle the container name given to tests. This makes the whole
# test suite more robust as we can be sure that the container name is
# properly set. Sometimes, we need to provide an explicit container name;
# this function eases the pain by either providing the explicitly given
# name or `CONTAINER_NAME` if it is set.
#
# @param ${1} = explicit container name [OPTIONAL]
#
# ## Attention
#
# Note that this function checks whether the name given to it starts with
# the prefix `dms-test_`. One must adhere to this naming convention.
#
# ## Panics
#
# If neither an explicit non-empty argument is given nor `CONTAINER_NAME`
# is set.
#
# ## "Calling Convention"
#
# This function should be called the following way:
#
#     local SOME_VAR=$(__handle_container_name "${X:-}")
#
# Where `X` is an arbitrary argument of the function you're calling.
#
# ## Note
#
# This function is internal and should not be used in tests.
function __handle_container_name() {
  if [[ -n ${1:-} ]] && [[ ${1:-} =~ ^dms-test_ ]]; then
    printf '%s' "${1}"
    return 0
  elif [[ -n ${CONTAINER_NAME+set} ]]; then
    printf '%s' "${CONTAINER_NAME}"
    return 0
  else
    echo 'ERROR: (helper/common.sh) Container name was either provided explicitly without the required "dms-test_" prefix, or CONTAINER_NAME is not set for implicit usage' >&2
    exit 1
  fi
}

# ? << Miscellaneous initialization functionality
# ! -------------------------------------------------------------------
# ? >> Functions to execute commands inside a container


# Execute a command inside a container with an explicit name.
#
# @param ${1} = container name
# @param ...  = command to execute
function _exec_in_container_explicit() {
  local CONTAINER_NAME=${1:?Container name must be provided when using explicit}
  shift 1
  docker exec "${CONTAINER_NAME}" "${@}"
}

# Execute a command inside the container with name ${CONTAINER_NAME}.
#
# @param ...  = command to execute
function _exec_in_container() {
  _exec_in_container_explicit "${CONTAINER_NAME:?Container name must be provided}" "${@}"
}

# Execute a command inside a container with an explicit name. The command is run with
# BATS' `run` so you can check the exit code and use `assert_`.
#
# @param ${1} = container name
# @param ...  = command to execute
function _run_in_container_explicit() {
  local CONTAINER_NAME=${1:?Container name must be provided when using explicit}
  shift 1
  run _exec_in_container_explicit "${CONTAINER_NAME}" "${@}"
}

# Execute a command inside the container with name ${CONTAINER_NAME}. The command
# is run with BATS' `run` so you can check the exit code and use `assert_`.
#
# @param ...  = command to execute
function _run_in_container() {
  _run_in_container_explicit "${CONTAINER_NAME:?Container name must be provided}" "${@}"
}

# Execute a command inside the container with name ${CONTAINER_NAME}. Moreover,
# the command is run by Bash with `/bin/bash -c`.
#
# @param ...  = command to execute with Bash
function _exec_in_container_bash() { _exec_in_container /bin/bash -c "${@}" ; }

# Execute a command inside the container with name ${CONTAINER_NAME}. The command
# is run with BATS' `run` so you can check the exit code and use `assert_`. Moreover,
# the command is run by Bash with `/bin/bash -c`.
#
# @param ...  = Bash command to execute
function _run_in_container_bash() { _run_in_container /bin/bash -c "${@}" ; }

# Run a command in Bash and filter the output given a regex.
#
# @param ${1} = command to run in Bash
# @param ${2} = regex to filter [OPTIONAL]
#
# ## Attention
#
# The regex is given to `grep -E`, so make sure it is compatible.
#
# ## Note
#
# If no regex is provided, this function will default to one that strips
# empty lines and Bash comments from the output.
function _run_in_container_bash_and_filter_output() {
  local COMMAND=${1:?Command must be provided}
  local FILTER_REGEX=${2:-^[[:space:]]*$|^ *#}

  _run_in_container_bash "${COMMAND} | grep -E -v '${FILTER_REGEX}'"
  assert_success
}

# ? << Functions to execute commands inside a container
# ! -------------------------------------------------------------------
# ? >> Functions about executing commands with timeouts

# Repeats a given command inside a container until the timeout is over.
#
# @param ${1} = timeout
# @param ${2} = container name
# @param ...  = test command for container
function _repeat_in_container_until_success_or_timeout() {
  local TIMEOUT="${1:?Timeout duration must be provided}"
  local CONTAINER_NAME="${2:?Container name must be provided}"
  shift 2

  _repeat_until_success_or_timeout \
    --fatal-test "_container_is_running ${CONTAINER_NAME}" \
    "${TIMEOUT}" \
    _exec_in_container "${@}"
}

# Repeats a given command until the timeout is over.
#
# @option --fatal-test <COMMAND EVAL STRING> = additional test whose failure aborts immediately
# @param ${1} = timeout
# @param ...  = test to run
function _repeat_until_success_or_timeout() {
  local FATAL_FAILURE_TEST_COMMAND

  if [[ "${1:-}" == "--fatal-test" ]]; then
    FATAL_FAILURE_TEST_COMMAND="${2:?Provided --fatal-test but no command}"
    shift 2
  fi

  local TIMEOUT=${1:?Timeout duration must be provided}
  shift 1

  if ! [[ "${TIMEOUT}" =~ ^[0-9]+$ ]]; then
    echo "First parameter for timeout must be an integer, received \"${TIMEOUT}\""
    return 1
  fi

  local STARTTIME=${SECONDS}

  until "${@}"; do
    if [[ -n ${FATAL_FAILURE_TEST_COMMAND} ]] && ! eval "${FATAL_FAILURE_TEST_COMMAND}"; then
      echo "\`${FATAL_FAILURE_TEST_COMMAND}\` failed, early aborting repeat_until_success of \`${*}\`" >&2
      return 1
    fi

    sleep 1

    if [[ $(( SECONDS - STARTTIME )) -gt ${TIMEOUT} ]]; then
      echo "Timed out on command: ${*}" >&2
      return 1
    fi
  done
}

# Like `_repeat_until_success_or_timeout` . The command is run with BATS' `run`
# so you can check the exit code and use `assert_`.
#
# @param ${1} = timeout
# @param ...  = test command to run
function _run_until_success_or_timeout() {
  local TIMEOUT=${1:?Timeout duration must be provided}
  shift 1

  if [[ ! ${TIMEOUT} =~ ^[0-9]+$ ]]; then
    echo "First parameter for timeout must be an integer, received \"${TIMEOUT}\""
    return 1
  fi

  local STARTTIME=${SECONDS}

  # shellcheck disable=SC2154
  until run "${@}" && [[ ${status} -eq 0 ]]; do
    sleep 1

    if (( SECONDS - STARTTIME > TIMEOUT )); then
      echo "Timed out on command: ${*}" >&2
      return 1
    fi
  done
}

# ? << Functions about executing commands with timeouts
# ! -------------------------------------------------------------------
# ? >> Functions to wait until a condition is met

# Wait until a port is ready.
#
# @param ${1} = port
# @param ${2} = container name [OPTIONAL]
function _wait_for_tcp_port_in_container() {
  local PORT=${1:?Port number must be provided}
  local CONTAINER_NAME=$(__handle_container_name "${2:-}")

  _repeat_until_success_or_timeout \
    --fatal-test "_container_is_running ${CONTAINER_NAME}" \
    "${TEST_TIMEOUT_IN_SECONDS}" \
    _exec_in_container_bash "nc -z 0.0.0.0 ${PORT}"
}

# Wait for SMTP port (25) to become ready.
#
# @param ${1} = name of the container [OPTIONAL]
function _wait_for_smtp_port_in_container() {
  local CONTAINER_NAME=$(__handle_container_name "${1:-}")
  _wait_for_tcp_port_in_container 25
}

# Wait until the SMTP port (25) can respond.
#
# @param ${1} = name of the container [OPTIONAL]
function _wait_for_smtp_port_in_container_to_respond() {
  local CONTAINER_NAME=$(__handle_container_name "${1:-}")

  local COUNT=0
  until [[ $(_exec_in_container timeout 10 /bin/bash -c 'echo QUIT | nc localhost 25') == *'221 2.0.0 Bye'* ]]; do
    if [[ ${COUNT} -eq 20 ]]; then
      echo "Unable to receive a valid response from 'nc localhost 25' within 20 seconds"
      return 1
    fi

    sleep 1
    (( COUNT += 1 ))
  done
}

# Checks whether a service is running inside a container (${1}).
#
# @param ${1} = service name
# @param ${2} = container name [OPTIONAL]
function _should_have_service_running_in_container() {
  local SERVICE_NAME="${1:?Service name must be provided}"
  local CONTAINER_NAME=$(__handle_container_name "${2:-}")

  _run_in_container /usr/bin/supervisorctl status "${SERVICE_NAME}"
  assert_success
  assert_output --partial 'RUNNING'
}

# Wait until a service is running.
#
# @param ${1} = name of the service to wait for
# @param ${2} = container name [OPTIONAL]
function _wait_for_service() {
  local SERVICE_NAME="${1:?Service name must be provided}"
  local CONTAINER_NAME=$(__handle_container_name "${2:-}")

  _repeat_until_success_or_timeout \
    --fatal-test "_container_is_running ${CONTAINER_NAME}" \
    "${TEST_TIMEOUT_IN_SECONDS}" \
    _should_have_service_running_in_container "${SERVICE_NAME}"
}

# An account added to `postfix-accounts.cf` must wait for the `changedetector` service
# to process the update before Dovecot creates the mail account and associated storage dir.
#
# @param ${1} = mail account name
# @param ${2} = container name
function _wait_until_account_maildir_exists() {
  local MAIL_ACCOUNT=${1:?Mail account must be provided}
  local CONTAINER_NAME=$(__handle_container_name "${2:-}")

  local LOCAL_PART="${MAIL_ACCOUNT%@*}"
  local DOMAIN_PART="${MAIL_ACCOUNT#*@}"
  local MAIL_ACCOUNT_STORAGE_DIR="/var/mail/${DOMAIN_PART}/${LOCAL_PART}"

  _repeat_in_container_until_success_or_timeout 60 "${CONTAINER_NAME}" \
    /bin/bash -c "[[ -d ${MAIL_ACCOUNT_STORAGE_DIR} ]]"
}

# Wait until the mail queue is empty inside a container (${1}).
#
# @param ${1} = container name [OPTIONAL]
function _wait_for_empty_mail_queue_in_container() {
  local CONTAINER_NAME=$(__handle_container_name "${1:-}")
  local TIMEOUT=${TEST_TIMEOUT_IN_SECONDS}

  # shellcheck disable=SC2016
  _repeat_in_container_until_success_or_timeout \
    "${TIMEOUT}" \
    "${CONTAINER_NAME}" \
    /bin/bash -c '[[ $(mailq) == "Mail queue is empty" ]]'
}


# ? << Functions to wait until a condition is met
# ! -------------------------------------------------------------------
# ? >> Miscellaneous helper functions

# Adds a mail account and waits for the associated files to be created.
#
# @param ${1} = mail account name
# @param ${2} = password [OPTIONAL]
# @param ${3} = container name [OPTIONAL]
function _add_mail_account_then_wait_until_ready() {
  local MAIL_ACCOUNT=${1:?Mail account must be provided}
  local MAIL_PASS="${2:-password_not_relevant_to_test}"
  local CONTAINER_NAME=$(__handle_container_name "${3:-}")

  # Required to detect a new account and create the maildir:
  _wait_for_service changedetector "${CONTAINER_NAME}"

  _run_in_container setup email add "${MAIL_ACCOUNT}" "${MAIL_PASS}"
  assert_success

  _wait_until_account_maildir_exists "${MAIL_ACCOUNT}"
}

# Reloads the postfix service.
#
# @param ${1} = container name [OPTIONAL]
function _reload_postfix() {
  local CONTAINER_NAME=$(__handle_container_name "${1:-}")

  # Reloading Postfix config after modifying it within 2 seconds will cause Postfix to delay reading `main.cf`:
  # WORKAROUND: https://github.com/docker-mailserver/docker-mailserver/pull/2998
  _exec_in_container touch -d '2 seconds ago' /etc/postfix/main.cf
  _exec_in_container postfix reload
}

# Get the IP of the container (${1}).
#
# @param ${1} = container name [OPTIONAL]
function _get_container_ip() {
  local TARGET_CONTAINER_NAME=$(__handle_container_name "${1:-}")
  docker inspect --format '{{ .NetworkSettings.IPAddress }}' "${TARGET_CONTAINER_NAME}"
}

# Check if a container is running.
#
# @param ${1} = container name [OPTIONAL]
function _container_is_running() {
  local TARGET_CONTAINER_NAME=$(__handle_container_name "${1:-}")
  [[ $(docker inspect -f '{{.State.Running}}' "${TARGET_CONTAINER_NAME}") == 'true' ]]
}

# Checks if the directory exists and then how many files it contains at the top-level.
#
# @param ${1} = directory
# @param ${2} = number of files that should be in ${1}
function _count_files_in_directory_in_container() {
  local DIRECTORY=${1:?No directory provided}
  local NUMBER_OF_LINES=${2:?No line count provided}

  _should_have_content_in_directory "${DIRECTORY}" '-type f'
  _should_output_number_of_lines "${NUMBER_OF_LINES}"
}

# Checks if the directory exists and then list the top-level content.
#
# @param ${1} = directory
# @param ${2} = Additional options to `find`
function _should_have_content_in_directory() {
  local DIRECTORY=${1:?No directory provided}
  local FIND_OPTIONS=${2:-}

  _run_in_container_bash "[[ -d ${DIRECTORY} ]] && find ${DIRECTORY} -mindepth 1 -maxdepth 1 ${FIND_OPTIONS} -printf '%f\n'"
  assert_success
}

# A simple wrapper for netcat (`nc`). This is useful when sending
# "raw" e-mails or doing IMAP-related work.
#
# @param ${1} = the file that is given to `nc`
# @param ${1} = custom parameters for `nc` [OPTIONAL] (default: 0.0.0.0 25)
function _nc_wrapper() {
  local FILE=${1:?Must provide name of template file}
  local NC_PARAMETERS=${2:-0.0.0.0 25}

  [[ -v CONTAINER_NAME ]] || return 1

  _run_in_container_bash "nc ${NC_PARAMETERS} < /tmp/docker-mailserver-test/${FILE}"
}

# A simple wrapper for a test that checks whether a file exists.
#
# @param ${1} = the path to the file inside the container
function _file_exists_in_container() {
  _run_in_container_bash "[[ -f ${1} ]]"
  assert_success
}

# A simple wrapper for a test that checks whether a file does not exist.
#
# @param ${1} = the path to the file (that should not exists) inside the container
function _file_does_not_exist_in_container() {
  _run_in_container_bash "[[ -f ${1} ]]"
  assert_failure
}

# ? << Miscellaneous helper functions
# ! -------------------------------------------------------------------