function moulti_tool_is_available {
	command -v "$1" > /dev/null 2>&1
}

function moulti_any_tool_is_available {
	local IFS tool
	IFS='|'
	for tool in $1; do
		moulti_tool_is_available "${tool}" && return 0
	done
	return 1
}

function moulti_check_requirements {
	local not_ok=0
	local summary='The following tools are required but appear to be missing:'
	local answer arg rc tool
	for arg; do
		if [[ "${arg}" =~ ^ret:([0-9]+):(.+)$ ]]; then
			# syntax: ret:{expected_return_code}:{arbitrary shell command}
			local expected_rc="${BASH_REMATCH[1]}"
			local command="${BASH_REMATCH[2]}"
			eval "${command}" > /dev/null 2>&1
			rc=$?
			if [ "$rc" != "${expected_rc}" ]; then
				not_ok=1
				printf -v summary -- '%s\n- %s (expected rc %d, got %d)' \
					"${summary}" "${command}" "${expected_rc}" "${rc}"
			fi
		elif [[ "${arg}" =~ \| ]]; then
			# syntax: {tool1}|{tool2}|...|{tooln}
			if ! moulti_any_tool_is_available "${arg}"; then
				not_ok=1
				printf -v summary -- '%s\n- %s' "${summary}" "${arg//|/ or }"
			fi
		else
			# syntax: {tool}
			if ! moulti_tool_is_available "${arg}"; then
				not_ok=1
				printf -v summary -- '%s\n- %s' "${summary}" "${arg}"
			fi
		fi
	done
	local step_id='moulti_check_requirements'
	if [ "${not_ok}" -eq 1 ]; then
		moulti buttonquestion delete "${step_id}" 2> /dev/null || true
		moulti buttonquestion add "${step_id}" \
			--title='[b]Unmet requirements[/]' \
			--class='error' \
			--top-text="${summary}" \
			--text='Do you want to continue anyway?' \
			--button yes error 'Yes, continue' \
			--button no success 'No, abort'
		answer=$(moulti buttonquestion get-answer --wait "${step_id}")
		if [ "${answer}" != 'yes' ]; then
			moulti buttonquestion update "${step_id}" --bottom-text='Execution aborted. Hit q to exit.'
			exit 1
		fi
	fi
	return "${not_ok}"
}

function moulti_python {
	# This will not age well:
	for name in python3 python3.{10..13} python; do
		if moulti_tool_is_available "${name}"; then
			"${name}" "$@"
			break
		fi
	done
}

if date --iso-8601=ns > /dev/null 2>&1; then
	function moulti_iso_date {
		# Keep only milliseconds and use '.' as separator between seconds and milliseconds:
		date --iso-8601=ns | sed -E 's/,([0-9]{3})[0-9]*/.\1/'
	}
elif gdate --iso-8601=ns > /dev/null 2>&1; then
	function moulti_iso_date {
		gdate --iso-8601=ns | sed -E 's/,([0-9]{3})[0-9]*/.\1/'
	}
elif perl -MPOSIX -MTime::HiRes -e '' 2> /dev/null; then
	function moulti_iso_date {
		# This Perl one-liner is about 10 times slower than GNU date but Perl is
		# more ubiquitous than GNU date.
		perl -MPOSIX -MTime::HiRes -e '
			($s, $us) = Time::HiRes::gettimeofday();
			@lt = localtime($s);
			$dt = strftime(q[%FT%T], @lt);
			$tz = strftime(q[%z], @lt);
			printf(qq[%s.%03d%s\n], $dt, $us / 1000, $tz);'
	}
else
	# Despite being obviously available, Python is third on the list
	# because some platforms ship non-optimized builds.
	function moulti_iso_date {
		moulti_python -c '
from datetime import datetime
now = datetime.now().astimezone()
print(now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + now.strftime("%z"))'
	}
fi

function moulti_duration {
	moulti_python -c '
from sys import argv
from datetime import datetime
date1 = datetime.strptime(argv[1], "%Y-%m-%dT%H:%M:%S.%f%z")
date2 = datetime.strptime(argv[2], "%Y-%m-%dT%H:%M:%S.%f%z")
duration  = str(date2 - date1)
print(duration[:-3])
' "$@"
}

# Examples:
# moulti_exec find . -type f -name '*.py' -ls
# moulti_exec other_bash_function arg1 arg2
# STEP_ADD_ARGS=(--max-height=0); STEP_ID=my_step ip -color address
# STEP_PASS_ARGS=(--read-size=64); STEP_UPDATE_ARGS=(--collapse); ping example.org
function moulti_exec {
	local id rc result
	# Generate a random step id if none was provided:
	id="${STEP_ID:-${RANDOM}${RANDOM}}"
	# Create a step; set the STEP_ADD_ARGS array to customise it:
	timestamp_start="$(moulti_iso_date)"
	moulti step add "${id}" --title="$*" --top-text="Started ${timestamp_start}" --bottom-text="still running..." --scroll-on-activity=-1 "${STEP_ADD_ARGS[@]}"
	# Run the given command and pipe its output to Moulti; set the STEP_PASS_ARGS array to fine-tune the piping (e.g. read size):
	"$@" < /dev/null 2>&1 | moulti pass "${id}" "${STEP_PASS_ARGS[@]}"
	# Harvest the command's return code and the time it ended:
	rc="${PIPESTATUS[0]}"
	timestamp_end="$(moulti_iso_date)"
	# Determine the new step color based on the return code:
	[ "${rc}" == 0 ] && result='success' || result='error'
	# Update the step; set the STEP_UPDATE_ARGS array to further customise it:
	duration=$(moulti_duration "${timestamp_start}" "${timestamp_end}")
	moulti step update "${id}" --bottom-text="Exited  ${timestamp_end} with RC ${rc} after ${duration}" --classes="${result}" --scroll-on-activity=false "${STEP_UPDATE_ARGS[@]}"
	[ "${rc}" == 0 ] && [ "${STEP_COLLAPSE_ON_SUCCESS}" ] && { moulti_delayed_collapse "${id}" "${STEP_COLLAPSE_ON_SUCCESS}" & }
	# Exit with the same return code as the given command:
	return "${rc}"
}

function moulti_delayed_collapse {
	sleep "${2:-5}"
	moulti step update "${1}" --collapsed
}

function moulti_type {
	local string="${*: -1}" # last argument
	string_length="${#string}"
	for (( i=1; i <= string_length; i++ )); do
		moulti "${@:1:$#-1}" "${string:0:i}" # all arguments but the last one, followed by the partial string
		# No sleep because this hack is already inefficient enough.
	done
}

if [ "$(uname)" == 'NetBSD' ]; then
	function stdbuf {
		# Ignore arguments and request line buffering for stdout:
		while [[ "$1" =~ ^- ]]; do shift; done
		STDBUF1='L' "$@"
	}
elif [ "$(uname)" == 'OpenBSD' ]; then
	if moulti_tool_is_available unbuffer; then
		function stdbuf {
			# Ignore arguments and call unbuffer:
			while [[ "$1" =~ ^- ]]; do shift; done
			unbuffer "$@"
		}
	fi
fi

MOULTI_NEW_STEP_PATTERN='^([-=#@]+)(\s*)(.+?)\2\1$'

function moulti_process_lines {
	exec {original_stdout}>&1
	local step_counter=0
	local step_id
	IFS=''
	while read -r line; do
		if [[ "${line}" =~ ${MOULTI_NEW_STEP_PATTERN} ]]; then
			((++step_counter))
			local prev_id="${step_id}"
			local next_id="$$_step_${step_counter}"
			step_id=$(moulti_make_step "${prev_id}" "${next_id}" "${line}" "${BASH_REMATCH[@]}" < /dev/null)
			if [ "${step_id}" ] && [ "${step_id}" != "${prev_id}" ]; then
				# Ensure the previous `moulti pass` gets EOF:
				exec 1>&-
				# Redirect stdout to `moulti pass our_new_step`
				exec > >(moulti pass "${step_id}")
			else
				step_id="${prev_id}"
			fi
		fi
		moulti_inspect_line "${line}" "${step_id}" < /dev/null
	done
	exec 1>&${original_stdout}
}

# Arguments:
# 1: previous step id
# 2: next step id (suggestion)
# 3: current line (complete, no CR/LF)
# 4: matched substring
# 5: capture #1
# 6: capture #2, etc.
# This function MUST output the id of the created step.
function moulti_make_step {
	# Default implementation: use the complete line as title:
	moulti step add "$2" --title="$3" --bottom-text=' ' --scroll-on-activity=-1 && echo "$2"
}

# Arguments:
# 1: current line (complete, no CR/LF)
# 2: current step id
function moulti_inspect_line {
	# Default implementation: systematically output the line:
	printf '%s\n' "$1"
}

[ "$0" != "${BASH_SOURCE[0]}" ] && return 0
printf 'This file is meant to be sourced in scripts that leverage Moulti:\n'
printf 'source %s\n' "${BASH_SOURCE[0]}"
