#!/bin/bash

#----------------------------------------------------------------------
# Test suite for InterIMAP
# Copyright © 2019 Guilhem Moulin <guilhem@fripost.org>
#
# 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 <http://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

set -ue
PATH=/usr/bin:/bin
export PATH

if [ $# -eq 0 ] || [ $# -gt 2 ]; then
    printf "Usage: %s TESTFILE [TESTNAME]\\n" "$0" >&2
    exit 1
fi

BASEDIR="$(dirname -- "$0")"
TESTDIR="$BASEDIR/$1"
TESTNAME="${2-$1}"
if [ ! -d "$TESTDIR" ]; then
    printf "ERROR: Not a directory: %s\\n" "$TESTDIR" >&2
    exit 1
fi

ROOTDIR="$(mktemp --tmpdir="${TMPDIR:-/dev/shm}" --directory "$1.XXXXXXXXXX")"
declare -a DOVECOT_SERVER=()
trap cleanup EXIT INT TERM
cleanup() {
    local pid c
    for c in "${DOVECOT_SERVER[@]}"; do
        if [ ! -f "$c" ] || ! env -i PATH="/usr/bin:/bin" doveadm -c "$c" stop; then
            pid="$(< "${c%/*}/run/master.pid")"
            kill -TERM "$pid" || printf "kill(1) exited with status %d\\n" "$?" >&2
        fi
    done
    rm -rf -- "$ROOTDIR"
}

_STDOUT="$ROOTDIR/stdout"
_STDERR="$ROOTDIR/stderr"
TMPDIR="$ROOTDIR/tmp"
STDERR="$(mktemp --tmpdir="$ROOTDIR" "stderr.XXXXXXXXXX")"
mkdir -- "$TMPDIR" "$ROOTDIR/home"
declare -a REMOTES=()

# Set environment for the given user
environ_set() {
    local user="$1" home
    eval home="\$HOME_$user"
    ENVIRON=(
        PATH="$PATH"
        USER="$user"
        HOME="$home"
        XDG_CONFIG_HOME="$home/.config"
        XDG_DATA_HOME="$home/.local/share"
    )
}

# Prepare the test harness
prepare() {
    declare -a ENVIRON=()
    local src cfg target u home n proto
    if [ -f "$TESTDIR/remotes" ] || [ -L "$TESTDIR/remotes" ]; then
        for cfg in $(seq 1 "$(< "$TESTDIR/remotes")"); do
            REMOTES+=( "remote$cfg" )
        done
    else
        REMOTES+=( "remote" )
    fi
    # copy dovecot config
    for u in "local" "${REMOTES[@]}"; do
        home="$ROOTDIR/$u/home"
        export "HOME_$u"="$home"
        environ_set "$u"

        mkdir -pm0700 -- "$home/.dovecot"
        cat >"$home/.dovecot/config" <<-EOF
			log_path = $HOME_local/mail.log
			mail_home = $home
			mail_location = dbox:~/inbox:LAYOUT=index
			mailbox_list_index = yes
			ssl = no
			listen = 127.0.0.1, ::1
			namespace inbox {
			    inbox = yes
			}
		EOF
        if [ -f "$TESTDIR/$u.conf" ] || [ -L "$TESTDIR/$u.conf" ]; then
            cat >>"$home/.dovecot/config" <"$TESTDIR/$u.conf"
        fi
        cp -aT -- "$BASEDIR/snippets/dovecot" "$home/.dovecot/conf.d"

        proto="$(env -i "${ENVIRON[@]}" doveconf -c "$home/.dovecot/config" -h protocols)"
        if [ -n "$proto" ]; then
            cat >>"$home/.dovecot/config" <<-EOF
				# https://wiki.dovecot.org/HowTo/Rootless
				base_dir = $home/.dovecot/run
				default_internal_user  = $(id -un)
				default_internal_group = $(id -gn)
				default_login_user     = $(id -un)

				service anvil {
				    chroot =
				}
				service imap-login {
				    chroot =
				}
				service stats {
				    chroot =
				}

				passdb {
				    args = scheme=PLAIN username_format=%u $home/.dovecot/users
				    driver = passwd-file
				}
				userdb {
				    args = username_format=%u $home/.dovecot/users
				    driver = passwd-file
				}
			EOF

            env -i PATH="/usr/bin:/bin" /usr/sbin/dovecot -c "$home/.dovecot/config"
            DOVECOT_SERVER+=( "$home/.dovecot/config" )
            printf "%s:%s:::::\\n" "$u" "$(xxd -l16 -p </dev/urandom)" >"$home/.dovecot/users"
        fi

        mkdir -pm0755 -- "$home/.local/bin"
        cat >"$home/.local/bin/doveadm" <<-EOF
			#!/bin/sh
			exec env -i ${ENVIRON[@]@Q} \\
			    doveadm -c ${home@Q}/.dovecot/config "\$@"
		EOF
        chmod +x -- "$home/.local/bin/doveadm"
    done

    # copy interimap and pullimap configuration
    mkdir -pm0700 -- "$HOME_local/.local/share/interimap" "$HOME_local/.local/share/pullimap"
    mkdir -pm0700 -- "$HOME_local/.config/interimap"      "$HOME_local/.config/pullimap"
    echo "deliver-rcpt = local" >>"$HOME_local/.config/pullimap/config"
    for u in "${REMOTES[@]}"; do
        n="${u#remote}"
        eval home="\$HOME_$u"

        cat >>"$HOME_local/.config/interimap/config$n" <<-EOF
			database = $u.db
			#logfile = $HOME_local/interimap$n.log
		EOF
        if [ -f "$TESTDIR/interimap$n.conf" ] || [ -L "$TESTDIR/interimap$n.conf" ]; then
            cat <"$TESTDIR/interimap$n.conf" >>"$HOME_local/.config/interimap/config$n"
        fi
        if [ -f "$TESTDIR/pullimap.conf" ] || [ -L "$TESTDIR/pullimap.conf" ]; then
            cat <"$TESTDIR/pullimap.conf" >>"$HOME_local/.config/pullimap/config"
        fi

        cat >>"$HOME_local/.config/interimap/config$n" <<-EOF

			[local]
			type = tunnel
			command = exec ${HOME_local@Q}/.local/bin/doveadm exec imap
			null-stderr = NO
		EOF
        if [ -f "$TESTDIR/interimap$n.local" ] || [ -L "$TESTDIR/interimap$n.local" ]; then
            cat <"$TESTDIR/interimap$n.local" >>"$HOME_local/.config/interimap/config$n"
        fi

        if [ -s "$home/.dovecot/users" ]; then
            cat <<-EOF
				username = $u
				password = $(awk -F: -vu="$u" '$1 == u {print $2}' <"$home/.dovecot/users")
			EOF
        else
            cat <<-EOF
				type = tunnel
				command = exec ${home@Q}/.local/bin/doveadm exec imap
				null-stderr = NO
			EOF
        fi >"$HOME_local/.$u.conf"
        if [ -f "$TESTDIR/interimap$n.remote" ] || [ -L "$TESTDIR/interimap$n.remote" ]; then
            cat <"$TESTDIR/interimap$n.remote" >>"$HOME_local/.$u.conf"
        fi

        { printf "\\n[remote]\\n";  cat <"$HOME_local/.$u.conf"; } >>"$HOME_local/.config/interimap/config$n"
        { printf "\\n[%s]\\n" "$u"; cat <"$HOME_local/.$u.conf"; } >>"$HOME_local/.config/pullimap/config"
    done
}
prepare

# Wrappers for interimap(1) and doveadm(1)
interimap() { _interimap_cmd "interimap" "$@"; }
pullimap()  { _interimap_cmd "pullimap"  "$@"; }
_interimap_cmd() {
    declare -a ENVIRON=()
    local script="$1" rv=0
    shift
    environ_set "local"
    env -i "${ENVIRON[@]}" perl -I./lib -T "./$script" "$@" 2>"$STDERR" || rv=$?
    cat <"$STDERR" >&2
    return $rv
}
interimap_init() {
    local u="${1-remote}"
    local db="$XDG_DATA_HOME/interimap/$u.db"
    local cfg="config${u#remote}"

    test \! -e "$db"          || error "Database already exists" 1
    interimap --config "$cfg" || error "Couldn't initialize interimap" 1
    test -f "$db"             || error "Database is still missing" 1
    grep -Fx "Creating new schema in database file $db" <"$STDERR" || error "DB wasn't created" 1
}
doveadm() {
    if [ $# -le 2 ] || [ "$1" != "-u" ]; then
        echo "Usage: doveadm -u USER ..." >&2
        exit 1
    fi
    local u="$2" home
    eval home="\$HOME_$u"
    shift 2
    "$home/.local/bin/doveadm" "$@"
}
sqlite3() {
    command sqlite3 -init /dev/null "$@"
}

# Sample (random) message
sample_message() {
    local date="$(date +"%s.%N")"
    cat <<-EOF
		From: <$(xxd -ps -l6 /dev/urandom)@example.net>
		To: <me@example.net>
		Date: $(date -R -d@"$date")
		Message-ID: <$date@example.net>

	EOF
    local len="$(shuf -i1-4096 -n1)"
    xxd -ps -c30 -l"$len" /dev/urandom # 3 to 8329 bytes
}

# Wrapper for dovecot-lda(1)
deliver() {
    declare -a argv
    while [ $# -gt 0 ] && [ "$1" != "--" ]; do
        argv+=( "$1" )
        shift
    done
    if [ $# -gt 0 ] && [ "$1" = "--" ]; then
        shift
    fi
    doveadm "${argv[@]}" exec dovecot-lda -e "$@"
}

# Dump test results
dump_test_result() {
    local below=">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
    local above="<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"
    local src u home
    declare -a ENVIRON=()
    for u in "local" "${REMOTES[@]}"; do
        environ_set "$u"
        eval home="\$HOME_$u"
        printf "%s dovecot configuration:\\n%s\\n" "$u" "$below"
        env -i "${ENVIRON[@]}" doveconf -c "$home/.dovecot/config" -n
        printf "%s\\n\\n" "$above"
    done

    for u in "${REMOTES[@]}"; do
        printf "interimap configuration (local <-> $u):\\n%s\\n" "$below"
        cat <"$HOME_local/.config/interimap/config${u#remote}"
        printf "%s\\n\\n" "$above"
    done

    printf "mail.log:\\n%s\\n" "$below"
    cat -- "$HOME_local/mail.log" 2>/dev/null || true
    printf "%s\\n\\n" "$above"

    printf "standard output:\\n%s\\n" "$below"
    cat <"$_STDOUT"
    printf "%s\\n\\n" "$above"

    printf "standard error:\\n%s\\n" "$below"
    cat <"$_STDERR"
    printf "%s\\n\\n" "$above"
}

# Check mailbox consistency between the local/remote server and interimap's database
check_mailbox_status() {
    local mailbox="$1" lns="inbox" lsep lprefix rns="inbox" rsep rprefix
    lsep="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/separator")"
    lprefix="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/prefix")"
    rsep="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/separator")"
    rprefix="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/prefix")"

    local blob="x'$(printf "%s" "$mailbox" | tr "$lsep" "\\0" | xxd -c256 -ps)'"
    local rmailbox="$(printf "%s" "$mailbox" | tr "$lsep" "$rsep")"
    check_mailbox_status2 "$blob" "$lprefix$mailbox" "remote" "$rprefix$rmailbox"
}
check_mailbox_status2() {
    local blob="$1" lmailbox="$2" u="$3" rmailbox="$4"
    local lUIDVALIDITY lUIDNEXT lHIGHESTMODSEQ rUIDVALIDITY rUIDNEXT rHIGHESTMODSEQ
    read lUIDVALIDITY lUIDNEXT lHIGHESTMODSEQ rUIDVALIDITY rUIDNEXT rHIGHESTMODSEQ < <(
        sqlite3 "$XDG_DATA_HOME/interimap/$u.db" <<-EOF
			.mode csv
			.separator " " "\\n"
			SELECT l.UIDVALIDITY, l.UIDNEXT, l.HIGHESTMODSEQ, r.UIDVALIDITY, r.UIDNEXT, r.HIGHESTMODSEQ
			FROM mailboxes m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx
			WHERE mailbox = $blob
		EOF
    )
    lHIGHESTMODSEQ="$(printf "%llu" "$lHIGHESTMODSEQ")"
    rHIGHESTMODSEQ="$(printf "%llu" "$rHIGHESTMODSEQ")"
    local MESSAGES
    read MESSAGES < <( sqlite3 "$XDG_DATA_HOME/interimap/$u.db" <<-EOF
			.mode csv
			.separator " " "\\n"
			SELECT COUNT(*)
			FROM mailboxes a JOIN mapping b ON a.idx = b.idx
			WHERE mailbox = $blob
		EOF
    )
    check_mailbox_status_values "local" "$lmailbox" $lUIDVALIDITY $lUIDNEXT $lHIGHESTMODSEQ $MESSAGES
    check_mailbox_status_values "$u"    "$rmailbox" $rUIDVALIDITY $rUIDNEXT $rHIGHESTMODSEQ $MESSAGES

    local a b
    a="$(doveadm -u "local" -f "flow" mailbox status "messages unseen vsize" -- "$lmailbox" | \
            sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')"
    b="$(doveadm -u "$u" -f "flow" mailbox status "messages unseen vsize" -- "$rmailbox" | \
            sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')"
    if [ "$a" != "$b" ]; then
        echo "Mailbox $lmailbox status differs: \"$a\" != \"$b\"" >&2
        exit 1
    fi
}
check_mailbox_status_values() {
    local user="$1" mailbox="$2" UIDVALIDITY="$3" UIDNEXT="$4" HIGHESTMODSEQ="$5" MESSAGES="$6" x xs v k
    xs="$(doveadm -u "$user" -f "flow" mailbox status "uidvalidity uidnext highestmodseq messages" -- "$mailbox" | \
            sed -nr '/.*\s+(\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+\s+\w+=[0-9]+)$/ {s//\1/p;q}')"
    [ -n "$xs" ] || exit 1
    for x in $xs; do
        k="${x%%=*}"
        case "${k^^[a-z]}" in
            UIDVALIDITY) v="$UIDVALIDITY";;
            UIDNEXT) v="$UIDNEXT";;
            HIGHESTMODSEQ) v="$HIGHESTMODSEQ";;
            MESSAGES) v="$MESSAGES";;
            *) echo "Uh? $x" >&2; exit 1
        esac
        if [ "${x#*=}" != "$v" ]; then
            echo "$user($mailbox): ${k^^[a-z]} doesn't match! ${x#*=} != $v" >&2
            exit 1
        fi
    done
}
check_mailboxes_status() {
    local mailbox
    for mailbox in "$@"; do
        check_mailbox_status "$mailbox"
    done
}

# Check mailbox list constency between the local and remote servers
check_mailbox_list() {
    local m i lns="inbox" lsep lprefix rns="inbox" rsep rprefix sub=
    lsep="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/separator")"
    lprefix="$(doveconf -c "$HOME_local/.dovecot/config" -h "namespace/$lns/prefix")"
    rsep="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/separator")"
    rprefix="$(doveconf -c "$HOME_remote/.dovecot/config" -h "namespace/$lns/prefix")"
    if [ $# -gt 0 ] && [ "$1" = "-s" ]; then
        sub="-s"
        shift
    fi

    declare -a lmailboxes=() rmailboxes=()
    if [ $# -eq 0 ]; then
        lmailboxes=( "${lprefix}*" )
        rmailboxes=( "${rprefix}*" )
    else
        for m in "$@"; do
            lmailboxes+=( "$lprefix$m" )
            rmailboxes+=( "$rprefix${m//"$lsep"/"$rsep"}" )
        done
    fi

    mapfile -t lmailboxes < <( doveadm -u "local" mailbox list $sub -- "${lmailboxes[@]}" )
    for ((i = 0; i < ${#lmailboxes[@]}; i++)); do
        lmailboxes[i]="${lmailboxes[i]#"$lprefix"}"
    done

    mapfile -t rmailboxes < <( doveadm -u "remote" mailbox list $sub -- "${rmailboxes[@]}" )
    for ((i = 0; i < ${#rmailboxes[@]}; i++)); do
        rmailboxes[i]="${rmailboxes[i]#"$rprefix"}"
        rmailboxes[i]="${rmailboxes[i]//"$rsep"/"$lsep"}"
    done

    local IFS=$'\n'
    diff -u --label="local/mailboxes" --label="remote/mailboxes" \
        <( printf "%s" "${lmailboxes[*]}" | sort ) <( printf "%s" "${rmailboxes[*]}" | sort )
}

# Wrapper for `grep -c`
xcgrep() {
    local m="$1" n
    shift
    if ! n="$(grep -c "$@")" || [ $m -ne $n ]; then
        error "\`grep -c ${*@Q}\` failed ($m != $n)" 1
    fi
}
error() {
    local err="${1+": $1"}" i=${2-0}
    printf "ERROR$err on file %s line %d\\n" "${BASH_SOURCE[i+1]}" ${BASH_LINENO[i]} >&2
    exit 1
}
ptree_abort() {
    local pid
    for pid in "$@"; do
        # kill a process and its children
        pkill -TERM -P "$pid" || printf "pkill(1) exited with status %d\\n" "$?" >&2
        kill  -TERM    "$pid" || printf  "kill(1) exited with status %d\\n" "$?" >&2
    done
    wait
}
step_start() { printf "%s%s..." "${INDENT-}" "$1" >&3; }
step_done() { passed >&3; }
failed() {
    [ -t 1 ] && printf " \\x1B[1;31mFAILED\\x1B[0m\\n" || echo " FAILED"
}
passed() {
    [ -t 1 ] && printf " \\x1B[1;32mPASSED\\x1B[0m\\n" || echo " PASSED"
}

# Run test in a sub-shell
declare -a ENVIRON=()
environ_set "local"
export TMPDIR TESTDIR STDERR "${ENVIRON[@]}"
export -f environ_set doveadm interimap interimap_init pullimap _interimap_cmd
export -f sqlite3 sample_message deliver ptree_abort step_start step_done passed
export -f check_mailbox_status check_mailbox_status_values check_mailbox_status2
export -f check_mailboxes_status check_mailbox_list xcgrep error
[ "$TESTNAME" = "..." ] || printf "%s%s..." "${INDENT-}" "$TESTNAME"
if ! bash -ue "$TESTDIR/t" 3>&1 >"$_STDOUT" 2>"$_STDERR"; then
    failed
    [ "${QUIET-n}" = "y" ] || dump_test_result
    exit 1
else
    [ "$TESTNAME" = "..." ] || passed
    if grep -Paq "\\x00" -- "$_STDOUT" "$_STDERR"; then
        printf "\\tWARN: binary output (outstanding \\0)!\\n"
    fi
    exit 0
fi