#!/usr/bin/env bash
#-------------------------------------------------------------------------------
# Copyright (C) British Crown (Met Office) & Contributors.
#
# This file is part of Rose, a framework for meteorological suites.
#
# Rose 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.
#
# Rose 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 Rose. If not, see <http://www.gnu.org/licenses/>.
#-------------------------------------------------------------------------------
# NAME
#     test_header
#
# SYNOPSIS
#     . t/lib/bash/test_header
#
# DESCRIPTION
#     Provide bash shell functions for writing tests for "rose" commands to
#     output in Perl's TAP format. Add "set -eu". Create a temporary working
#     directory $TEST_DIR and change to it. Automatically increment test number.
# FUNCTIONS
#     tests N
#         echo "1..$N".
#     skip N REASON
#         echo "ok $((++T)) # skip REASON" N times, where T is the test number.
#     skip_all REASON
#         echo "1..0 # SKIP $REASON" and exit.
#     pass TEST_KEY
#         echo "ok $T - $TEST_KEY" where T is the current test number.
#     fail TEST_KEY
#         echo "not ok $T - $TEST_KEY" where T is the current test number.
#     run_pass TEST_KEY COMMAND ...
#         Run $COMMAND. pass/fail $TEST_KEY if $COMMAND returns true/false.
#         Write STDOUT and STDERR in $TEST_KEY.out and $TEST_KEY.err.
#     run_fail TEST_KEY COMMAND ...
#         Run $COMMAND. pass/fail $TEST_KEY if $COMMAND returns false/true.
#         Write STDOUT and STDERR in $TEST_KEY.out and $TEST_KEY.err.
#     file_cmp TEST_KEY FILE_ACTUAL [FILE_EXPECT]
#         Compare contents in $FILE_ACTUAL and $FILE_EXPECT. pass/fail
#         $TEST_KEY if contents are identical/different. If $FILE_EXPECT is "-"
#         or not defined, compare $FILE_ACTUAL with STDIN to this function.
#         Uses `diff -i` if DIFF_CASE_INSENSITIVE is set.
#     file_cmp_any TEST_KEY FILE_ACTUAL [FILE_EXPECT]
#         As file_cmp, but FILE_EXPECT should consist of more than one
#         contents set to compare against, separated by a line matching
#         /^__filesep__$/. Iff any contents match, the test passes.
#     file_test TEST_KEY FILE [OPTION]
#         pass/fail $TEST_KEY if "test $OPTION $FILE" returns 0/1. $OPTION is
#         -e if not specified.
#     file_grep TEST_KEY PATTERN FILE
#         Run "grep -q PATTERN FILE". pass/fail $TEST_KEY accordingly.
#     file_grep_fail TEST_KEY PATTERN FILE
#         Run "grep -q PATTERN FILE". pass $TEST_KEY if $PATTERN is not present
#         and fail otherwise.
#     bad_smtpd_init
#         Start a server daemon that will not reply correctly to SMTP.
#         Write host:port setting to the variable TEST_SMTPD_HOST. Write
#         pid of daemon to TEST_SMTPD_PID.
#     bad_smtpd_kill
#         Kill the faux-SMTP server daemon process.
#     mock_smtpd_init
#         Start a mock SMTP server daemon for testing. Write host:port setting
#         to the variable TEST_SMTPD_HOST. Write pid of daemon to
#         TEST_SMTPD_PID. Write log to TEST_SMTPD_LOG.
#     mock_smtpd_kill
#         Kill the mock SMTP server daemon process.
#     poll COMMAND
#         Run COMMAND in 1 second intervals for a minute until COMMAND returns
#         a non-zero value.
#     port_is_busy $PORT
#         Return 0 if $PORT is busy or 1 if $PORT is not busy.
#     rose_ws_init $NS $UTIL
#         Start a Rose web service server. Test server OK. Write to these shell
#         variables on success:
#         * TEST_ROSE_WS_PID - PID of the service server
#         * TEST_ROSE_WS_PORT - Port of the service server on localhost.
#         * TEST_ROSE_WS_URL - URL of service server.
#     rose_ws_kill
#         Kill a web service server started by "rose_ws_init" and remove
#         generated log and status files.
#     rose_ws_json_greps TEST_KEY JSON-FILE EXPECTED [EXPECTED ...]
#         Load JSON content in argument 1
#         Each remaining argument is an expected content in the JSON data in
#         the form: [keys, value] where "keys" is a list containing keys or
#         indexes to an expected item in the data structure, and the value is
#         an expected value. A key in keys can be a simple dict key or an
#         array index. It can also be a dict {attr_key: attr_value, ...}. In
#         which case, the expected data item is under a list of dicts, where a
#         unique dict in the list contains all elements attr_key: attr_value.
#     get_reg
#         Generates a unique Cylc registration name.
#         exports FLOW, FLOW_RUN_DIR
#     purge [FLOW]
#         Runs `cylc clean` on the provided reg (else uses the $FLOW env var)
#         only if none of the subtests failed. Otherwise it passes so that
#         the test files are left behind for debugging.
#
# VARIABLES
#     TEST_DIR
#         Tests will run in this temporary directory.
#     TEST_KEY_BASE
#         The base name without the "*.t" extension of the test file.
#     TEST_NUMBER
#         Number of tests already run.
#     TEST_SOURCE_DIR
#         The directory containing of the current test file.
#-------------------------------------------------------------------------------
set -eu

test_finally() {
    trap '' 'EXIT'
    trap '' 'INT'
    if [[ -n "${TEST_DIR:-}" ]]; then
        cd ~
        rm -rf "${TEST_DIR}"
    fi
    if [[ -n "${TEST_SMTPD_PID:-}" ]]; then
        kill "${TEST_SMTPD_PID}"
    fi
    # Allow custom clean up
    if declare -F 'my_test_finally' 1>'/dev/null' 2>&1; then
        my_test_finally "$@"
    fi
}
trap 'test_finally EXIT' 'EXIT'
trap 'test_finally INT' 'INT'

TEST_NUMBER=0
FAILURES=0

tests() {
    echo "1..$1"
}

skip() {
    local N_SKIPS=$1
    shift 1
    local I=0
    while ((I++ < N_SKIPS)); do
        echo "ok $((++TEST_NUMBER)) # skip $*"
    done
}

skip_all() {
    echo "1..0 # SKIP $*"
    exit
}

pass() {
    echo "ok $((++TEST_NUMBER)) - $*"
}

fail() {
    echo "not ok $((++TEST_NUMBER)) - $*"
    ((++FAILURES))
    if [[ -f "${TEST_KEY}.err" ]]; then
        # output the last 10 lines of stderr for debug
        tail -n 10 "${TEST_KEY}.err" >&2
    fi
}

run_pass() {
    local TEST_KEY=$1
    shift 1
    if ! "$@" 1>"$TEST_KEY.out" 2>"$TEST_KEY.err"; then
        fail "$TEST_KEY"
        return
    fi
    pass "$TEST_KEY"
}

run_fail() {
    local TEST_KEY=$1
    shift 1
    if "$@" 1>"$TEST_KEY.out" 2>"$TEST_KEY.err"; then
        fail "$TEST_KEY"
        return
    fi
    pass "$TEST_KEY"
}

file_cmp() {
    local TEST_KEY=$1
    local FILE_ACTUAL=$2
    local FILE_EXPECT=${3:--}
    local diff_opts=(-u)
    if [[ -n ${DIFF_CASE_INSENSITIVE:-} ]]; then
        diff_opts+=(-i)
    fi
    if diff "${diff_opts[@]}" "${FILE_EXPECT}" "${FILE_ACTUAL}" >&2; then
        pass "$TEST_KEY"
        return
    fi
    fail "$TEST_KEY"
}

file_cmp_any() {
    local TEST_KEY=$1
    local FILE_ACTUAL=$2
    local FILE_EXPECT=${3:--}
    csplit --prefix="$TEST_KEY-cmp-any-csplit" "$FILE_EXPECT" \
        /^__filesep__$/ '{*}'
    for SPLIT_FILENAME in "$TEST_KEY-cmp-any-csplit"*; do
        sed -i "/^__filesep__$/d" "$SPLIT_FILENAME"
        if cmp -s "$SPLIT_FILENAME" "$FILE_ACTUAL"; then
            pass "$TEST_KEY"
            return
        fi
    done
    for SPLIT_FILENAME in "$TEST_KEY-cmp-any-csplit"*; do
        diff -u "$SPLIT_FILENAME" "$FILE_ACTUAL" >&2
    done
    fail "$TEST_KEY"
}

file_test() {
    local TEST_KEY=$1
    local FILE=$2
    local OPTION=${3:--e}
    if test "$OPTION" "$FILE"; then
        pass "$TEST_KEY"
    else
        fail "$TEST_KEY"
    fi
}

file_grep() {
    local TEST_KEY=$1
    local PATTERN=$2
    local FILE=$3
    if grep -q -e "$PATTERN" "$FILE"; then
        pass "$TEST_KEY"
        return
    fi
    fail "$TEST_KEY"
}

file_pcregrep() {
    local TEST_KEY="$1"
    local PATTERN="$2"
    local FILE="$3"
    if _pcregrep "${PATTERN}" "${FILE}"; then
        pass "${TEST_KEY}"
        return
    fi
    fail "${TEST_KEY}"
}

_pcregrep() {
    python3 - "$@" <<'__PYTHON__'
import re
import sys

pattern, filename = sys.argv[1:3]
sys.exit(int(not re.search(pattern, open(filename).read(), re.M | re.S)))
__PYTHON__
}

file_grep_fail() {
    local TEST_KEY=$1
    local PATTERN=$2
    local FILE=$3
    if grep -q -e "$PATTERN" "$FILE"; then
        fail "$TEST_KEY"
        return
    fi
    pass "$TEST_KEY"
}

bad_smtpd_init() {
    local SMTPD_PORT=
    for SMTPD_PORT in 8026 8126 8226 8326 8426 8526 8626 8726 8826 8926; do
        local SMTPD_HOST=localhost:$SMTPD_PORT
        local SMTPD_LOG="$TEST_DIR/badsmtpd.log"
        # Piping the code directly to python does not work, for some reason.
        cat >"$TEST_DIR/smtpd.py" <<__PYTHON__
import socket, sys
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
soc.bind(('localhost', $SMTPD_PORT))
sys.stdout.write("bound\n")
sys.stdout.flush()
soc.listen(5)
while True:
    client_soc, address = soc.accept()
    # Antisocially cut off the connection!
    client_soc.close()
__PYTHON__
       python3 "$TEST_DIR/smtpd.py" 1>"$SMTPD_LOG" 2>&1 &
       local SMTPD_PID=$!
       while ! grep -q '^bound$' "$SMTPD_LOG" 2>/dev/null; do
            if ps $SMTPD_PID 1>/dev/null 2>&1; then
                sleep 1
            else
                rm -f "$SMTPD_LOG"
                unset SMTPD_HOST SMTPD_LOG SMTPD_PID
                break
            fi
        done
        if [[ -n ${SMTPD_PID:-} ]]; then
            TEST_SMTPD_HOST=$SMTPD_HOST
            TEST_SMTPD_PID=$SMTPD_PID
            TEST_SMTPD_LOG=$SMTPD_LOG
            break
        fi
    done
}

bad_smtpd_kill() {
    if [[ -n ${TEST_SMTPD_PID:-} ]] && ps $TEST_SMTPD_PID >/dev/null 2>&1; then
        kill $TEST_SMTPD_PID
        wait $TEST_SMTPD_PID 2>/dev/null || true
        unset TEST_SMTPD_HOST TEST_SMTPD_PID
    fi
}

mock_smtpd_init() {
    local SMTPD_PORT=
    for SMTPD_PORT in 8025 8125 8225 8325 8425 8525 8625 8725 8825 8925; do
        local SMTPD_HOST=localhost:$SMTPD_PORT
        local SMTPD_LOG="$TEST_DIR/smtpd.log"
        python3 -u -m smtpd -n -c DebuggingServer -d -n "$SMTPD_HOST" \
            1>"$SMTPD_LOG" 2>&1 &
        local SMTPD_PID=$!
        while ! grep -q 'DebuggingServer started' "$SMTPD_LOG" 2>/dev/null; do
            if ps $SMTPD_PID 1>/dev/null 2>&1; then
                sleep 1
            else
                rm -f "$SMTPD_LOG"
                unset SMTPD_HOST SMTPD_LOG SMTPD_PID
                break
            fi
        done
        if [[ -n ${SMTPD_PID:-} ]]; then
            # shellcheck disable=SC2034
            TEST_SMTPD_HOST=$SMTPD_HOST
            TEST_SMTPD_PID=$SMTPD_PID
            # shellcheck disable=SC2034
            TEST_SMTPD_LOG=$SMTPD_LOG
            break
        fi
    done
}

mock_smtpd_kill() {
    if [[ -n ${TEST_SMTPD_PID:-} ]] && ps $TEST_SMTPD_PID >/dev/null 2>&1; then
        kill $TEST_SMTPD_PID
        wait $TEST_SMTPD_PID 2>/dev/null || true
        unset TEST_SMTPD_HOST TEST_SMTPD_LOG TEST_SMTPD_PID
    fi
}

poll() {
    local TIMEOUT=$(($(date +%s) + 60)) # wait 1 minute
    while (($(date +%s) < TIMEOUT)) && eval "$@"; do
        sleep 1
    done
}

port_is_busy() {
    # use Python rather than netcat/netstat which aren't as portable
    python3 -c '
import socket
import sys

print(sys.argv[1])
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    assert sock.connect_ex(("localhost", int(sys.argv[1]))) == 0
    ' "$1" 2>/dev/null
}

TEST_ROSE_WS_PID=
TEST_ROSE_WS_PORT=
TEST_ROSE_WS_URL=
rose_ws_init() {
    local NS="$1"
    local UTIL="$2"
    local PORT="$((RANDOM + 10000))"
    while port_is_busy "${PORT}"; do
        PORT="$((RANDOM + 10000))"
    done

    TEST_KEY="${TEST_KEY_BASE}-${NS}-${UTIL}"
    "${NS}" "${UTIL}" '-R' 'start' "${PORT}" \
        0<'/dev/null' 1>"${NS}-${UTIL}.out" 2>"${NS}-${UTIL}.err" &
    TEST_ROSE_WS_PID="$!"
    T_INIT="$(date '+%s')"
    while ! port_is_busy "${PORT}" && (($(date '+%s') < T_INIT + 60)); do
        sleep 1
    done
    TEST_ROSE_WS_PORT="${PORT}"
    if port_is_busy "${TEST_ROSE_WS_PORT}"; then
        pass "${TEST_KEY}"
        TEST_ROSE_WS_URL="http://${HOSTNAME}:${TEST_ROSE_WS_PORT}/${NS}-${UTIL}"
    else
        fail "${TEST_KEY}"
        rose_ws_kill
    fi
}

rose_ws_kill() {
    if [[ -n "${TEST_ROSE_WS_PID}" ]]; then
        kill "${TEST_ROSE_WS_PID}" 2>'/dev/null'
        wait 2>'/dev/null'
    fi
    if [[ -n "${TEST_ROSE_WS_PORT}" ]]; then
        rm -fr "${HOME}/.metomi/"*"-${HOSTNAME:-0.0.0.0}-${TEST_ROSE_WS_PORT}"* 2>'/dev/null'
    fi
    TEST_ROSE_WS_PID=
    TEST_ROSE_WS_PORT=
    # shellcheck disable=SC2034
    TEST_ROSE_WS_URL=
}

rose_ws_json_greps() {
    local TEST_KEY="$1"
    shift 1
    run_pass "${TEST_KEY}" python3 - "$@" <<'__PYTHON__'
import ast
import json
import sys

data = json.load(open(sys.argv[1]))
for item in sys.argv[2:]:
    keys, value = ast.literal_eval(item)
    datum = data
    try:
        for key in keys:
            if isinstance(key, dict):
                for datum_item in datum:
                    if all([datum_item.get(k) == v for k, v in key.items()]):
                        datum = datum_item
                        break
                else:
                    raise KeyError
            else:
                datum = datum[key]
        if datum != value:
            raise ValueError((item, datum))
    except IndexError:
        raise IndexError(item)
    except KeyError:
        raise KeyError(item)
__PYTHON__
    if [[ -s "${TEST_KEY}.err" ]]; then
        cat "${TEST_KEY}.err" >&2
    fi
}

get_reg () {
    local FLOW_UID
    FLOW_UID="$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c6)"
    FLOW="rtb.$ROSE_TEST_TIME_INIT/${TEST_KEY_BASE}/${FLOW_UID}"
    FLOW_RUN_DIR="$HOME/cylc-run/$FLOW"
    echo "${FLOW}"
    export FLOW FLOW_RUN_DIR
}

purge () {
    local FLOW="${1:-$FLOW}"
    if [[ -z "$FLOW" ]]; then
        echo 'no flow to purge' >&2
        return 1
    elif ((FAILURES == 0)); then
        cylc clean "${FLOW}"
    fi
}

mkdir -p "$HOME/cylc-run"

ROSE_TEST_HOME=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
export ROSE_TEST_HOME
DIFFTOOL="$(rose config '--default=diff -u' t difftool)"
TEST_KEY_BASE="$(basename "$0" .t)"
# shellcheck disable=SC2034
TEST_SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
TEST_DIR="$(realpath "$(mktemp -d)")"
cd "$TEST_DIR"

set +e
