#!/bin/bash

# Copyright 2020, 2021 eomanis
#
# This file is part of pulse-autoconf.
#
# pulse-autoconf is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# pulse-autoconf 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 pulse-autoconf.  If not, see <http://www.gnu.org/licenses/>.

# TODO Boolean option whether to move streams using the fallback devices
#      on startup
# TODO Stop abusing the cookie as PulseAudio server instance ID, this
#      might not work if the server is running with
#      module-native-protocol-unix option "auth-cookie-enabled=0"
# TODO Implement trigger mechanism to replace periodic polling of the
#      PulseAudio server
# TODO Manual page
# TODO Preset "EchoCancellationPlacebo": Instead of remapping dummy
#      devices, directly create the dummy devices as "sink_main" or
#      "source_main"

set -o nounset
set -o noclobber
set -o errexit
shopt -qs inherit_errexit

# Semantic versioning
declare -r versionMajor=1
declare -r versionMinor=5
declare -r versionPatch=1
declare -r versionLabel=""

# Prints the version string to STDOUT
getVersion () {
	echo -n "${versionMajor}.${versionMinor}.${versionPatch}"
	test "$versionLabel" != "" && echo "-$versionLabel" || echo ""
}

# isFunction command
#
# Returns with code 0 if the given command refers to a function
# (and not, for example, to a shell builtin or an executable)
isFunction () {
	local command="$1"; shift
	local commandType

	! commandType="$(type -t "$command")" && return 1
	! test "function" = "$commandType" && return 1
	return 0
}

# Returns the paths to all existing valid system-level configuration
# files, in ascending order of priority
#
# The paths are returned in a newline-separated list
getConfigurationFilesSystem () {
	getConfigurationFiles "/usr/lib" "/etc" "/run"
}

# Returns the paths to all existing valid user-level configuration
# files, in ascending order of priority
#
# The paths are returned in a newline-separated list
getConfigurationFilesUser () {
	getConfigurationFiles ~/".config"
}

# getConfigurationFiles baseDir [otherBaseDir...]
#
# Returns the paths to all existing valid configuration files, in
# ascending order of priority, in the given directories
#
# The paths are returned in a newline-separated list
# The directories must be given without trailing slash
getConfigurationFiles () {
	local suffix=".conf"
	local pathPrefix
	local configDir
	local configFile
	local baseDir

	while test $# -gt 0; do
		baseDir="$1"; shift
		pathPrefix="${baseDir}/pulse-autoconf/pulse-autoconf"
		configDir="${pathPrefix}.d"
		configFile="${pathPrefix}$suffix"

		if test -d "$configDir"; then
			find "$configDir" -mindepth 1 -maxdepth 1 -type f -name "*$suffix" -print | sort
		fi
		if test -f "$configFile"; then
			echo "$configFile"
		fi
	done
}

# printTemplateConfigurationFile
#
# Prints an initial configuration file to STDOUT
printTemplateConfigurationFile () {

	echo "# Configuration file for pulse-autoconf
# ======================================================================

# Created by pulse-autoconf $(getVersion)

"
	echo -n '# Preset selection
# ----------------------------------------------------------------------

# The desired preset, i.e. the configuration that should be maintained
# in the PulseAudio server
# Default: "EchoCancellation"
# Uncomment the preset you wish to use
#preset="EchoCancellation"
#preset="EchoCancellationWithSourcesMix"
#preset="EchoCancellationPlacebo"
#preset="None"


# Echo cancellation options
# ----------------------------------------------------------------------

# The parameters that should be used for module-echo-cancel
#ecParams=()
#ecParams+=(aec_method=webrtc)
#ecParams+=(use_master_format=1)
#ecParams+=(aec_args="analog_gain_control=0\\ digital_gain_control=1\\ experimental_agc=1\\ noise_suppression=1\\ voice_detection=1\\ extended_filter=1")
# Uncomment this line if the virtual echo cancellation devices use a
# lower sample rate than your audio hardware, e.g. only 32000 Hz instead
# of 44100 Hz or 48000 Hz:
#ecParams+=(rate=48000)

# Echo cancellation master finding: Patterns for device names in
# descending order of priority
# Patterns have the format "prefix:string"
# Available pattern prefixes:
#   "exact"         - Name is this exact string
#   "notexact"      - Name is not this exact string
#   "startswith"    - Name starts with this string
#   "notstartswith" - Name does not start with this string
#   "endswith"      - Name ends with this string
#   "notendswith"   - Name does not end with this string
#   "grep"          - Name matches "grep --regexp string"
#   "notgrep"       - Name matches "grep --invert-match --regexp string"
#   "egrep"         - Name matches "grep --extended-regexp --regexp string"
#   "notegrep"      - Name matches "grep --extended-regexp --invert-match --regexp string"
# To match any device you can use "startswith:"
#ecSinkMasters=()
#ecSinkMasters+=("startswith:") # Any sink
#ecSourceMasters=()
#ecSourceMasters+=("notendswith:.monitor") # Exclude monitor sources
# Uncomment this line to make pulse-autoconf choose a monitor source
# if no other source is available:
#ecSourceMasters+=("startswith:") # Any source
# Just so you know and do not worry, pulse-autoconf *never* chooses
# its sink master'"'"'s monitor as source master (module-echo-cancel
# does not allow specifying a sink and its own monitor as masters)

# Echo cancellation master finding: Whether to prefer newer devices over
# older devices
# This controls the order in which devices are matched against the
# patterns when determining a master device
# If true, newer devices are tested for a pattern match before older
# devices
# If false, older devices are tested before newer devices
# Default: true
#ecSinkMastersPreferNewer=false
#ecSourceMastersPreferNewer=false


# Loopback device options
# ----------------------------------------------------------------------

# The parameters that should be used for module-loopback
#loopbackParams=()
#loopbackParams+=(latency_msec=60)
#loopbackParams+=(adjust_time=6)


# Other options
# ----------------------------------------------------------------------

# For further options have a look at this function in the source code of
# pulse-autoconf:
# setDefaultSettings () {
#     (...)
# }
'
}

# Sources all existing configuration files, also re-populates the
# $configFilesMonitored associative array
loadSettingsFromConfigFiles () {
	local -i exitCode
	local configFile

	reloadConfig=false

	# Source the system-level configuration files
	while read -rs configFile; do
		echo " INFO  Sourcing configuration file \"$configFile\"" >&2
		# shellcheck source=/dev/null
		source -- "$configFile" && exitCode=$? || exitCode=$?
		if test "$exitCode" -ne 0; then
			echo "ERROR  Could not source configuration file \"$configFile\"" >&2
			exit "$exitCode"
		fi
	done < <(getConfigurationFilesSystem)

	# Source the user-level configuration files (these are monitored for
	# changes)
	# Set all entries in the "monitored configuration files" map to
	# "File not found"
	for configFile in "${!configFilesMonitored[@]}"; do
		configFilesMonitored["$configFile"]=""
	done
	while read -rs configFile; do
		echo " INFO  Sourcing configuration file \"$configFile\"" >&2
		# shellcheck source=/dev/null
		source -- "$configFile" && exitCode=$? || exitCode=$?
		if test "$exitCode" -ne 0; then
			echo "ERROR  Could not source configuration file \"$configFile\"" >&2
			exit "$exitCode"
		fi
		configFilesMonitored["$configFile"]="$(getFileStatus "$configFile")"
	done < <(getConfigurationFilesUser)
}

# getFileStatus pathToFile
#
# Prints a status string for the given file composed of the file's size
# in bytes and its modification time in seconds since Epoch, separated
# by a single blank
# Prints nothing if the file does not exist
getFileStatus () {
	local file="$1"; shift
	local fileSizeBytes
	local fileModTime

	if test -e "$file"; then
		if ! fileSizeBytes="$(getFileSizeBytes "$file")" 2> /dev/null; then
			fileSizeBytes=""
		fi
		if ! fileModTime="$(getFileModTimeSecsSinceEpoch "$file")" 2> /dev/null; then
			fileModTime=""
		fi
		echo "$fileSizeBytes $fileModTime"
	fi
}

# getFileSizeBytes pathToFile
#
# Prints the given file's size, in bytes
getFileSizeBytes () {
	LC_ALL=C stat -c '%s' "$1"
}

# getFileModTimeSecsSinceEpoch pathToFile
#
# Prints the given file's modification time, in seconds since Epoch,
# with greatest possible decimal precision, using the dot "." as decimal
# separator
getFileModTimeSecsSinceEpoch () {

	# Force locale "C" to make stat use the dot as decimal separator
	LC_ALL=C stat -c '%.Y' "$1"
}

# Validates the global settings; exits with code 1 if pulse-audio
# cannot run with the current settings
validateSettings () {

	if ! test "$verbose" = true && ! test "$verbose" = false; then
		echo " WARN  \$verbose must be either \"true\" or \"false\", was \"$verbose\", using \"false\" instead" >&2
		verbose=false
	fi

	if ! test "$ecUseDummySource" = true && ! test "$ecUseDummySource" = false; then
		echo " WARN  \$ecUseDummySource must be either \"true\" or \"false\", was \"$ecUseDummySource\", using \"true\" instead" >&2
		ecUseDummySource=true
	fi

	if ! test "$ecUseDummySink" = true && ! test "$ecUseDummySink" = false; then
		echo " WARN  \$ecUseDummySink must be either \"true\" or \"false\", was \"$ecUseDummySink\", using \"true\" instead" >&2
		ecUseDummySink=true
	fi

	if ! test "$ecSinkMastersPreferNewer" = true && ! test "$ecSinkMastersPreferNewer" = false; then
		echo " WARN  \$ecSinkMastersPreferNewer must be either \"true\" or \"false\", was \"$ecSinkMastersPreferNewer\", using \"true\" instead" >&2
		ecSinkMastersPreferNewer=true
	fi

	if ! test "$ecSourceMastersPreferNewer" = true && ! test "$ecSourceMastersPreferNewer" = false; then
		echo " WARN  \$ecSourceMastersPreferNewer must be either \"true\" or \"false\", was \"$ecSourceMastersPreferNewer\", using \"true\" instead" >&2
		ecSourceMastersPreferNewer=true
	fi

	# Validate the configured preset
	presetFunction="setup$preset"
	if ! isFunction "$presetFunction"; then
		echo " WARN  Unknown preset \"$preset\", using preset \"None\" instead" >&2
		preset="None"
		# Print all available presets
		local validPreset
		echo -n " INFO  Available presets:" >&2
		while read -rs validPreset; do
			echo -n " \"$validPreset\"" >&2
		done < <(declare -F | sed -nre 's/^declare -f //;s/^setup(.+)$/\1/p')
		echo >&2
	fi
}

# (Re)loads the configuration and validates the resulting settings
reloadConfig() {

	setDefaultSettings
	loadSettingsFromConfigFiles
	validateSettings
}

# Returns with code 0 if the size or modification time of any of the
# files in $configFilesMonitored (associative array) have changed since
# the last call of reloadConfig()
isUserConfigurationModified () {
	local configFile

	for configFile in "${!configFilesMonitored[@]}"; do
		if test "${configFilesMonitored["$configFile"]}" != "$(getFileStatus "$configFile")"; then
			return 0
		fi
	done
	return 1
}

# Returns with code 0 if the PulseAudio server needs to be set up again
# Calls preset-specific code if the preset implements it
#
# The preset-specific code is always called, even if the decision
# whether to reload the preset is pre-determined by e.g. $reloadPreset
isSetupRequired () {
	local instanceIdNew
	local presetFunction
	local -i returnCode=1

	# New PulseAudio instance?
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" != "$instanceId"; then
		returnCode=0
	fi

	# Preset re-application has been requested from elsewhere?
	if $reloadPreset; then
		reloadPreset=false
		returnCode=0
	fi

	# User-level configuration has been modified?
	if isUserConfigurationModified; then
		$verbose && echo "DEBUG  Configuration change detected, reloading configuration and re-applying preset" >&2
		reloadConfig=true
		returnCode=0
	fi

	# Preset's isSetupRequired function (if implemented) requests preset
	# reload?
	presetFunction="isSetupRequired$preset"
	if isFunction "$presetFunction"; then
		if "$presetFunction"; then
			returnCode=0
		fi
	fi

	return "$returnCode"
}

# Applies a preset to the PulseAudio server while putting any loaded
# modules' IDs into the global $loadedModules array
# Returns with the preset function's return code
setup () {
	local presetFunction
	local -i presetFunctionReturnCode

	$verbose && echo "DEBUG  Setup" >&2

	# Reload the configuration from the configuration files if requested
	if $reloadConfig; then
		reloadConfig=false
		reloadConfig
	fi

	# Store the PulseAudio server's instance ID
	instanceId="$(getInstanceId)"

	# Try to acquire the lock for the PulseAudio server instance
	if ! getInstanceLock; then
		if ! $instanceLockFailed; then
			echo " WARN  Another pulse-autoconf instance seems to be managing the PulseAudio server, not applying preset \"$preset\"" >&2
		fi
		instanceLockFailed=true
		return 1
	fi
	instanceLockFailed=false

	# Apply the configured preset to the running PulseAudio server
	echo " INFO  Applying preset \"$preset\"" >&2
	presetFunction="setup$preset"
	"$presetFunction" && presetFunctionReturnCode="$?" || presetFunctionReturnCode="$?"
	if test "$presetFunctionReturnCode" != 0 ; then
		echo " WARN  Failed to apply preset \"$preset\"" >&2
	fi
	return "$presetFunctionReturnCode"
}

# Unloads the modules loaded by the preset in reverse order of loading
# The loaded modules' IDs are read from the $loadedModules array
teardown () {
	local -i index
	local instanceIdNew

	$verbose && echo "DEBUG  Teardown" >&2

	# Store the IDs of the streams that are currently using the fallback
	# sink or source
	storeStreamsOnFallbackDevices

	# Ensure that the PulseAudio server instance is the same from
	# setup()
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" = "$instanceId"; then
		# Unload all loaded modules in reverse order of loading
		for (( index = ${#modulesLoaded[@]} - 1; index >= 0; index-- )); do
			# If unloading a module fails, still attempt to unload the
			# rest of them
			# Also suppress the error message "Failure: No such entity"
			# when attempting to unload stuff that does not exist
			# anymore
			pactl unload-module "${modulesLoaded[$index]}" 2> /dev/null || true
			unset "modulesLoaded[$index]"
		done
	else
		# Different PulseAudio instance, do not attempt to unload any modules
		$verbose && echo "DEBUG  Instance ID changed from \"$instanceId\" to \"$instanceIdNew\"" >&2
		if test "$instanceId" != ""; then
			echo " WARN  PulseAudio restart detected, not unloading modules" >&2
		fi
		modulesLoaded=()
	fi

	if ! releaseInstanceLock; then
		echo " WARN  Failed to release the lock for the PulseAudio server instance" >&2
	fi
}

# loadModule arguments...
#
# Performs a call of
#   pactl load-module <arguments...>
# and adds the returned module instance ID to the global modulesLoaded
# array
loadModule () {
	local moduleId
	local -i exitCode

	$verbose && echo "DEBUG  Loading module: pactl load-module $*" >&2
	#$verbose && read -sp "Enter to continue... " >&2; echo "" >&2
	moduleId="$(pactl load-module "$@")" && exitCode=$? || exitCode=$?
	if test "$exitCode" -eq 0; then
		modulesLoaded+=("$moduleId")
	else
		echo " WARN  A \"pactl load-module\" call failed with exit code $exitCode. Further arguments: $*" >&2
	fi
	return $exitCode
}

# createDummySinkIfRequired sinkName
#
# If the given sink name is the name of the dummy sink, and if the
# dummy sink does not exist yet, creates the dummy sink
createDummySinkIfRequired () {
	local sinkName="$1"; shift

	if test "$sinkName" = "${sinkDummy[0]}"; then
		createNullSinkIfRequired "${sinkDummy[0]}" "${sinkDummy[1]}"
	fi
}

# createDummySourceIfRequired sourceName
#
# If the given source name is the name of the dummy source, and if the
# dummy source does not exist yet, creates the dummy source
createDummySourceIfRequired () {
	local sourceName="$1"; shift

	if test "$sourceName" = "${sourceDummy[0]}"; then
		createNullSourceIfRequired "${sourceDummy[0]}" "${sourceDummy[1]}"
	fi
}

# createNullSinkIfRequired sinkName sinkDescription
#
# Creates a null sink having the given name and description if no sink
# with that name exists yet
createNullSinkIfRequired () {
	local sinkName="$1"; shift
	local sinkDescription="$1"; shift

	if ! getDevice sinks "" false "exact:$sinkName" &> /dev/null; then
		$verbose && echo "DEBUG  Creating null sink \"$sinkName\"" >&2
		if ! loadModule module-null-sink "${nullSinkParams[@]}" sink_name="$sinkName" sink_properties="device.description=$sinkDescription"; then
			echo " WARN  Failed to create null sink \"$sinkName\"" >&2
			return 1
		fi
	fi
}

# createNullSourceIfRequired sourceName sourceDescription
#
# Creates a null source having the given name if no source with that
# name exists yet
createNullSourceIfRequired () {
	local sourceName="$1"; shift
	local sourceDescription="$1"; shift

	if ! getDevice sources "" false "exact:$sourceName" &> /dev/null; then
		$verbose && echo "DEBUG  Creating null source \"$sourceName\"" >&2
		if ! loadModule module-null-source "${nullSourceParams[@]}" source_name="$sourceName" description="$sourceDescription"; then
			echo " WARN  Failed to create null source \"$sourceName\"" >&2
			return 1
		fi
	fi
}

# Returns the currently running PulseAudio server instance's instance ID
getInstanceId () {
	LC_ALL=C pactl info | sed -nre 's/^Cookie: (.*)$/\1/p'
}

# Returns the current fallback sink (a.k.a. default sink)
getFallbackSink () {
	LC_ALL=C pactl info | sed -nre 's/^Default Sink: (.*)$/\1/p'
}

# Returns the current fallback source (a.k.a. default source)
getFallbackSource () {
	LC_ALL=C pactl info | sed -nre 's/^Default Source: (.*)$/\1/p'
}

# Stores the IDs of the streams that are currently playing to the
# fallback sink or recording from the fallback source into the global
# array variables
#   streamsPlayingToFallbackSink,
#   streamsRecordingFromFallbackSource,
# respectively
storeStreamsOnFallbackDevices () {
	local fallbackSink
	local fallbackSource
	local streamPlayingToFallbackSink
	local streamRecordingFromFallbackSource

	# Clear stream stores
	streamsPlayingToFallbackSink=()
	streamsRecordingFromFallbackSource=()

	# Store streams that are playing to the fallback sink or recording
	# from the fallback source
	if ! $stopped; then
		# Store streams playing to the fallback sink
		fallbackSink="$(getFallbackSink)"
		while read -rs streamPlayingToFallbackSink; do
			streamsPlayingToFallbackSink+=("$streamPlayingToFallbackSink")
		done < <( getStreamIds sink-inputs "$fallbackSink" )
		$verbose && echo "DEBUG  IDs of streams playing to \"$fallbackSink\": ${streamsPlayingToFallbackSink[*]}" >&2
		# Store streams recording from the fallback source
		fallbackSource="$(getFallbackSource)"
		while read -rs streamRecordingFromFallbackSource; do
			streamsRecordingFromFallbackSource+=("$streamRecordingFromFallbackSource")
		done < <( getStreamIds source-outputs "$fallbackSource" )
		$verbose && echo "DEBUG  IDs of streams recording from \"$fallbackSource\": ${streamsRecordingFromFallbackSource[*]}" >&2
	fi
}

# Moves the streams whose IDs are stored in the global array variables
#   streamsPlayingToFallbackSink
#   streamsRecordingFromFallbackSource,
# to the current fallback sink or source, respectively
restoreStreamsOnFallbackDevices () {
	local fallbackSink
	local fallbackSource
	local moveToFallbackSink
	local moveToFallbackSource

	# Restore playback streams to the fallback sink
	fallbackSink="$(getFallbackSink)"
	for moveToFallbackSink in "${streamsPlayingToFallbackSink[@]}"; do
		! streamExists sink-inputs "$moveToFallbackSink" && continue
		$verbose && echo "DEBUG  Moving playback stream with ID \"$moveToFallbackSink\" to sink \"$fallbackSink\"" >&2
		# Silently ignore failures caused by attempts to move special
		# recording streams such as peak detectors
		pactl move-sink-input "$moveToFallbackSink" "$fallbackSink" 2> /dev/null || true
	done
	# Restore recording streams to the fallback source
	fallbackSource="$(getFallbackSource)"
	for moveToFallbackSource in "${streamsRecordingFromFallbackSource[@]}"; do
		! streamExists source-outputs "$moveToFallbackSource" && continue
		$verbose && echo "DEBUG  Moving recording stream with ID \"$moveToFallbackSource\" to source \"$fallbackSource\"" >&2
		pactl move-source-output "$moveToFallbackSource" "$fallbackSource" 2> /dev/null || true
	done
}

# getNewlineList [arguments...]
#
# Prints all arguments to STDOUT, each argument terminated with a line
# feed
getNewlineList () {

	while test $# -gt 0; do
		echo "$1"; shift
	done
}

# Returns the first available sink that matches a pattern from the
# ecSinkMasters array, which is the sink that should be used as
# sink_master= when setting up echo cancellation
getEcSinkMaster () {

	getFirstAvailableDevice sinks \
		"$(getNewlineList "${ecSinkMastersIgnore[@]}" "${ecSinkMastersIgnorePreset[@]}")" \
		"$ecSinkMastersPreferNewer" "${ecSinkMasters[@]}" && return 0

	# No sink master found: Return the dummy sink if it is enabled
	# It will be automatically created if required
	if $ecUseDummySink; then
		echo "${sinkDummy[0]}"
		return 0
	fi
	return 1
}

# getEcSourceMaster ignoredSink
#
# Returns the first available source that is NOT the monitor of the
# given sink, and that matches a pattern from the ecSourceMasters array,
# which is the source that should be used as source_master= when setting
# up echo cancellation
getEcSourceMaster () {
	local ignoredSink="$1"; shift
	local ignoredSinkMonitorIfPresent=()

	if test "$ignoredSink" != ""; then
		ignoredSinkMonitorIfPresent+=("$ignoredSink".monitor)
	fi

	getFirstAvailableDevice sources \
		"$(getNewlineList "${ecSourceMastersIgnore[@]}" "${ecSourceMastersIgnorePreset[@]}" "${ignoredSinkMonitorIfPresent[@]}")" \
		"$ecSourceMastersPreferNewer" "${ecSourceMasters[@]}" && return 0

	# No source master found: Return the dummy source if it is enabled
	# It will be automatically created if required
	if $ecUseDummySource; then
		echo "${sourceDummy[0]}"
		return 0
	fi
	return 1
}

# getFirstAvailableDevice deviceType ignoredDevices preferNewer [pattern]...
#
# For each pattern, attempts to find a PulseAudio sink/source that
# matches the pattern
# On the first successful match, prints the PulseAudio sink/source to
# STDOUT and returns with code 0
# If none of the given patterns match a sink or source prints nothing
# and returns with code 1
#
# The deviceType argument must be one of "sinks", "sources"
# The ignoredDevices argument may contain the (exact) names of devices
# that should be ignored, separated by line breaks; if no device should
# be ignored, this argument should be the empty string
# It is used when e.g. determining an echo cancellation master source,
# to deny the monitor of an already-determined echo cancellation master
# sink, and also to exclude virtual devices created by the presets
# The preferNewer argument controls the order in which the devices are
# matched against the patterns, if it is "true" then newer devices will
# be matched before older devices
getFirstAvailableDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; shift
	local preferNewer="$1"; shift
	local pattern

	while test $# -gt 0; do
		pattern="$1"; shift

		if getDevice "$type" "$ignoredDevices" "$preferNewer" "$pattern"; then
			return 0
		fi
	done
	return 1
}

# getDevice deviceType ignoredDevices preferNewer pattern
#
# If a sink or source matching the given pattern exists, prints the
# first such sink/source's name to STDOUT and returns with code 0
#
# The deviceType argument must be one of "sinks", "sources"
# The ignoredDevices argument may contain the (exact) names of devices
# that should be ignored, separated by line breaks; if no device should
# be ignored, this argument should be the empty string
# The preferNewer argument controls the order in which the devices are
# matched against the patterns, if it is "true" then newer devices will
# be matched before older devices
getDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; shift
	local preferNewer; if test "$1" = "true"; then preferNewer="true"; else preferNewer="false"; fi; shift
	local pattern="$1"; shift
	local IFS=$'\t'$'\n'
	local prefix
	local payload
	local deviceId
	local deviceName
	local deviceOther

	prefix="$(echo "$pattern" | sed -nre 's/^([a-z]+:).*$/\1/p')"
	payload="${pattern:${#prefix}}"
	#$verbose && echo "DEBUG  pattern=\"$pattern\", prefix=\"$prefix\", payload=\"$payload\"" >&2
	#$verbose && echo "DEBUG  Ignoring $(echo "$ignoredDevices" | wc -l) device(s)" >&2

	while read -rs deviceId deviceName deviceOther; do

		# Ignore devices in the ignoreDevices list
		echo "$ignoredDevices" | grep --quiet --line-regexp --fixed-strings --regexp "$deviceName" && continue

		if test "$prefix" = "exact:"; then
			test "$deviceName" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notexact:"; then
			test "$deviceName" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "startswith:"; then
			test "${deviceName: 0: ${#payload}}" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notstartswith:"; then
			test "${deviceName: 0: ${#payload}}" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "endswith:"; then
			test "${deviceName: $(( ${#deviceName} - ${#payload} )): ${#deviceName}}" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notendswith:"; then
			test "${deviceName: $(( ${#deviceName} - ${#payload} )): ${#deviceName}}" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "grep:"; then
			echo "$deviceName" | grep --regexp "${payload}" && return 0
		elif test "$prefix" = "notgrep:"; then
			echo "$deviceName" | grep --invert-match --regexp "${payload}" && return 0
		elif test "$prefix" = "egrep:"; then
			echo "$deviceName" | grep --extended-regexp --regexp "${payload}" && return 0
		elif test "$prefix" = "notegrep:"; then
			echo "$deviceName" | grep --extended-regexp --invert-match --regexp "${payload}" && return 0
		else
			echo " WARN  Unknown device name pattern prefix \"$prefix\", must be one of \"exact:\", \"notexact:\", \"startswith:\", \"notstartswith:\", \"endswith:\", \"notendswith:\", \"grep:\", \"notgrep:\", \"egrep:\", \"notegrep:\": \"$pattern\"" >&2
		fi
	done < <( pactl list short "$type" | { if $preferNewer; then tac; else cat; fi; } )
	return 1
}

# streamExists streamType streamId
#
# Returns with code 0 if there is a stream of the given type and having
# the given stream ID
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
streamExists () {
	local type="$1"; shift
	local id="$1"; shift
	local IFS=$'\t'$'\n'
	local streamId
	local streamOther

	while read -rs streamId streamOther; do
		if test "$streamId" = "$id"; then
			return 0
		fi
	done < <( pactl list short "$type" )
	return 1
}

# getStreamIds streamType deviceName
#
# Returns the IDs of the source-outputs or sink-inputs that use the
# sink/source with the given name, in a newline-separated list
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
getStreamIds () {
	local type="$1"; shift
	local name="$1"; shift
	local IFS=$'\t'$'\n'
	local deviceType
	local deviceId
	local streamId
	local streamDeviceId
	local streamOther

	# Get the device's ID
	# "sink-inputs" -> "sinks", "source-outputs" -> "sources"
	if test "sink-inputs" = "$type"; then
		deviceType="sinks"
	elif test "source-outputs" = "$type"; then
		deviceType="sources"
	else
		echo "ERROR  Unknown stream type \"${type}\", must be either \"sink-inputs\" or \"source-outputs\"" >&2
		return 1
	fi
	deviceId="$(getDeviceId "$deviceType" "$name")"
	#$verbose && echo "DEBUG  ID of sink/source \"$name\" is \"$deviceId\"" >&2

	# shellcheck disable=SC2034  # Unused variable "streamOther"
	# required to separate trailing data from variable "streamDeviceId"
	while read -rs streamId streamDeviceId streamOther; do
		#$verbose && echo "DEBUG  streamId=\"$streamId\", streamDeviceId=\"$streamDeviceId\", streamOther=\"$streamOther\"" >&2
		if test "$streamDeviceId" = "$deviceId"; then
			echo "$streamId"
		fi
	done < <( pactl list short "$type" )
}

# getDeviceId deviceType deviceName
#
# Returns the ID of the sink or source that has the given name
#
# The deviceType argument must be one of "sinks", "sources"
getDeviceId () {
	local type="$1"; shift
	local name="$1"; shift
	local IFS=$'\t'$'\n'
	local deviceId
	local deviceName
	local deviceOther

	# shellcheck disable=SC2034  # Unused variable "deviceOther"
	# required to separate trailing data from variable "deviceName"
	while read -rs deviceId deviceName deviceOther; do
		if test "$deviceName" = "$name"; then
			echo "$deviceId"
			return 0
		fi
	done < <( pactl list short "$type" )
	return 1
}

# Immediately resumes the main loop if it is waiting on its "sleep" call
resumeMainLoop () {
	local sleepPidCopy="$sleepPid"

	if test "$sleepPidCopy" != ""; then
		kill -s TERM "$sleepPidCopy" 2> /dev/null || true
	fi
}

# Sets the "stop" flag and immediately resumes the main loop if it is
# waiting on its "sleep" call
stop () {

	echo " INFO  Terminating main loop" >&2
	stopped=true
	resumeMainLoop
}

# Reloads the application configuration and re-applies the preset
#
# Immediately resumes the main loop if it is waiting on its "sleep"
# call, tears down the current preset, reloads the complete
# configuration and then re-applies the (now possibly different) preset
# (handler/trap function for signals that should cause a configuration
# reload)
handleSignalReloadConfig () {

	$verbose && echo "DEBUG  Reloading configuration and re-applying preset" >&2
	reloadPreset=true
	reloadConfig=true
	resumeMainLoop
}

# Sets the "stop" flag and then calls the teardown function
handleExit () {

	stopped=true
	teardown
}

# Attempts to acquire the pulse-autoconf instance lock for the current
# PulseAudio server instance
# Only a single pulse-autoconf may be messing with a PulseAudio server
# at once
# Returns with code 0 if the lock has been acquired
getInstanceLock() {
	local pid=$$
	local pidFileNew
	local pidFromFile

	# Get the current PID file
	if ! pidFileNew="$(getPidFile)" || test "$pidFileNew" = ""; then
		echo "ERROR  Unable to determine the current PID file's path" >&2
		return 1
	fi
	if test "$pidFile" != "" && test "$pidFileNew" != "$pidFile"; then
		# We appear to have the lock on an obsolete PID file
		# Maybe the PulseAudio server has been restarted, which causes
		# the file's name to change
		# Release the obsolete lock
		releaseInstanceLock || true
	fi

	# Unset the current PID file, so that we do not think we have the
	# lock if anything goes wrong
	pidFile=""

	# Try to obtain the lock
	if writePidFile "$pid" "$pidFileNew"; then
		# PID file did not exist and has been written successfully
		pidFile="$pidFileNew"
		return 0
	elif pidFromFile="$(getPidFromFile "$pidFileNew")"; then
		# PID file exists
		if test "$pidFromFile" = "$pid"; then
			# This is our PID file, we already have the lock
			pidFile="$pidFileNew"
			return 0
		else
			# Other PID or bullshit in file
			if kill -0 -- "$pidFromFile" 2> /dev/null; then
				# There is a process with the PID in this file: Somebody
				# else has the lock
				return 1
			else
				# No process found that uses the PID from the file
				echo " WARN  Deleting stale PID file containing PID \"$pidFromFile\": \"$pidFileNew\"" >&2
				if test -f "$pidFileNew"; then
					rm -f "$pidFileNew" 2> /dev/null || true
				fi
				if writePidFile "$pid" "$pidFileNew"; then
					pidFile="$pidFileNew"
					return 0
				else
					return 1
				fi
			fi
		fi
	else
		# Something else
		#  - File could not be read
		#  - Race where the file has been deleted while this function
		#    was running
		return 1
	fi
}

# Releases the lock for the current PulseAudio server instance if it is
# held
# Returns with code 0 if this pulse-autoconf instance does not hold the
# lock afterwards
releaseInstanceLock () {
	local pid=$$
	local pidFromFile

	if test "$pidFile" = ""; then
		# We do not have the lock
		return 0
	fi

	# Try to release the lock
	if ! test -e "$pidFile"; then
		# PID file does not exist, nobody has the lock
		pidFile=""
		return 0
	elif pidFromFile="$(getPidFromFile "$pidFile")"; then
		# PID file exists
		if test "$pidFromFile" = "$pid"; then
			# This is our PID file, we have the lock and can release it
			rm -f "$pidFile"
			pidFile=""
			return 0
		else
			# Somebody else has the lock
			pidFile=""
			return 0
		fi
	else
		# Something else
		#  - File could not be read
		#  - Race where the file has been deleted while this function
		#    was running
		# Whatever it is, assume we do not have the lock anymore
		pidFile=""
		return 0
	fi
}

# Prints the path of the file that pulse-autoconf should check for when
# determining whether there are other pulse-autoconf instances that are
# using the same PulseAudio server instance
getPidFile () {

	# Figure out the path to the PID file
	if ! pidFileDir="$(getPidFileDir)" || test "$pidFileDir" = ""; then
		echo "ERROR  Unable to determine the PID file parent directory" >&2
		return 1
	fi
	if ! pidFileName="$(getPidFileName)" || test "$pidFileName" = ""; then
		echo "ERROR  Unable to determine the PID file's name" >&2
		return 1
	fi
	echo "$pidFileDir"/"$pidFileName"
}


# Prints the path to the directory where pulse-autoconf should check for
# other instances using the same PulseAudio instance
getPidFileDir () {
	local baseDir

	if ! test -z ${XDG_RUNTIME_DIR+x} && test -d "$XDG_RUNTIME_DIR"; then
		echo "$XDG_RUNTIME_DIR"/pulse-autoconf
		return 0
	fi

	if baseDir="/run/user/$(id -u)" && test -d "$baseDir"; then
		echo "$baseDir"/pulse-autoconf
		return 0
	fi

	# TODO Use a session-independent directory instead?
	# $XDG_RUNTIME_DIR is a session-specific directory, but
	# pulse-autoconf's single-instance scope is the PulseAudio server
	# instance; the session does not matter
	# TODO Implement more fallbacks?

	return 1
}

# Prints the name of the file in getPidFileDir() that pulse-autoconf
# should check for when determining whether there are other
# pulse-autoconf instances that are using the same PulseAudio server
# instance
getPidFileName () {
	getInstanceId | sed -re 's/[^a-zA-Z0-9]/_/g'
}

# getPidFromFile path
#
# Prints the first max. 40 characters of the given file's first text
# text line to STDOUT, terminated by a line break
# Non-digit characters are replaced by underscores
# Prints nothing and returns with code 1 if the file does not exist
# Prints nothing and returns with code 0 if the file is empty
getPidFromFile () {
	local path="$1"; shift
	local pid

	if ! test -f "$path"; then
		return 1
	fi
	while read -rsn 40 pid || test "$pid" != ""; do
		break
	done < <(cat "$path")
	if ! test -z ${pid+x}; then
		echo "$pid" | sed -re 's/[^0-9]/_/g'
	fi
}

# writePidFile pid filePath
#
# Writes the given PID followed by a line break to the given file if no
# such file exists
# Returns with code 0 if the file has been written
# ASSUMES THAT THE SHELL OPTION "noclobber" IS SET!
writePidFile () {
	local pid="$1"; shift
	local path="$1"; shift
	local parentPath

	# This test is technically not required as the "noclobber" shell
	# option atomically prevents an existing lock file from being
	# overwritten
	# Its sole reason for existence is that, in the majority of cases,
	# it prevents the shell's error message about an attempt to clobber
	# an existing file, which can not easily be silenced
	if test -e "$path"; then
		return 1
	fi

	parentPath="$(dirname "$path")"
	mkdir --parents "$parentPath"
	echo "$pid" > "$path" 2> /dev/null
}


# Special actions
# ----------------------------------------------------------------------

# runActionEditConfig [customEditor] [customEditorArgument]...
#
# Opens the primary configuration file in a text editor
# Creates a new configuration file if it does not exist yet
runActionEditConfig () {
	local configFile=~/.config/pulse-autoconf/pulse-autoconf.conf
	local editor

	# Find the text editor executable
	# shellcheck disable=SC2153  # $EDITOR is not a misspelling of $editor
	if test $# -gt 0; then
		editor="$1"; shift
	elif test ! -z ${EDITOR+x}; then
		editor="$EDITOR"
	else
		echo "ERROR  \$EDITOR is not set, please specify a text editor executable" >&2
		exit 1
	fi
	if ! type "$editor" &> /dev/null; then
		echo "ERROR  Text editor \"$editor\" does not exist or is not executable" >&2
		exit 1
	fi

	# Create a configuration file if it does not exist
	if ! test -e "$configFile"; then
		echo " INFO  Configuration file does not exist, creating it" >&2
		# Create the configuration file's parent directories if they do
		# not exist
		mkdir --parents "$(dirname "$configFile")"
		printTemplateConfigurationFile > "$configFile"
	fi

	# Launch the text editor with the arguments and the configuration
	# file's path
	echo " INFO  Editing configuration file with \"$editor\": \"$configFile\"" >&2
	$verbose && echo "DEBUG  Launching text editor: \"$editor\" $* \"$configFile\"" >&2
	"$editor" "$@" "$configFile"
}

# runActionListSinksAndSources [showMonitors] [sleepTime]
#
# Prints a table-style listing of the sinks and sources that are
# currently present in the PulseAudio server to STDOUT
#
# showMonitors can be "true" or "false" and controls whether monitor
# sources are shown, default (if not given) is "false"
#
# sleepTime can be anything that is understood by the "sleep" command;
# if given, and if it is not the empty string, then this action will
# enter an infinite clear-and-print loop using this sleep time
runActionListSinksAndSources () {
	local showMonitors=false
	local sleepTime=""
	local sinksAndSourcesPrevious=""
	local sinksAndSources

	if test $# -gt 0; then
		showMonitors="$1"; shift
	fi
	if test $# -gt 0; then
		sleepTime="$1"; shift
	fi

	echo " INFO  Listing sinks and sources" >&2
	if test "$sleepTime" != ""; then
		while true; do
			if ! sinksAndSources="$(listSinksAndSources "$showMonitors")"; then
				return 1
			fi
			if test "$sinksAndSources" != "$sinksAndSourcesPrevious"; then
				sinksAndSourcesPrevious="$sinksAndSources"
				clear
				echo "$sinksAndSources"
			fi
			if ! sleep "$sleepTime"; then
				# Prevent the loop from running without sleep time if a
				# bad sleepTime argument has been given
				sleep "2s"
				# Trigger a redraw, so that the warning printed by sleep
				# does not accumulate in the terminal
				sinksAndSourcesPrevious=""
			fi
		done
	else
		listSinksAndSources "$showMonitors"
	fi
}

# listSinksAndSources [showMonitors]
#
# Prints a table-style listing of the sinks and sources that are
# currently present in the PulseAudio server to STDOUT
#
# showMonitors can be "true" or "false" and controls whether monitor
# sources are shown, default (if not given) is "false"
listSinksAndSources () {
	local showMonitors=false
	local fallbackSinkKeyword
	local fallbackSourceKeyword

	if test $# -gt 0; then
		if ! test "$1" = true && ! test "$1" = false; then
			echo "ERROR  Argument \"showMonitors\" must be either \"true\" or \"false\", was \"$1\"" >&2
			return 1
		fi
		showMonitors="$1"; shift
	fi
	# Retrieve the current fallback sink and source, and craft them into
	# keywords that can be used in a basic sed expression, i.e. escape
	# a bunch of special characters
	# After that, prepend and append a tabulator
	fallbackSinkKeyword="$(getFallbackSink | sed -e 's/[]\/$*.^[]/\\&/g')"
	fallbackSinkKeyword="	$fallbackSinkKeyword	"
	fallbackSourceKeyword="$(getFallbackSource | sed -e 's/[]\/$*.^[]/\\&/g')"
	fallbackSourceKeyword="	$fallbackSourceKeyword	"
	{	echo "ID	F	Sink	Driver	Sample Specification	State"
		echo "--	-	------------------------	------------------	--------------------	---------"
		# The first sed invocation inserts a new column in front of the
		# Name column, and the second sed invocation inserts an asterisk
		# in that new column if the line contains the default sink
		pactl list short sinks \
			| sed -re 's/^([^\t]*)\t/\1\t\t/' \
			| sed -e "s/$fallbackSinkKeyword/*$fallbackSinkKeyword/"
		echo ""
		if $showMonitors; then
			echo "ID	F	Source	Driver	Sample Specification	State"
			echo "--	-	------------------------	------------------	--------------------	---------"
			pactl list short sources \
				| sed -re 's/^([^\t]*)\t/\1\t\t/' \
				| sed -e "s/$fallbackSourceKeyword/*$fallbackSourceKeyword/"
		else
			echo "ID	F	Source (monitors hidden)	Driver	Sample Specification	State"
			echo "--	-	------------------------	------------------	--------------------	---------"
			pactl list short sources | grep --invert-match ".monitor	" \
				| sed -re 's/^([^\t]*)\t/\1\t\t/' \
				| sed -e "s/$fallbackSourceKeyword/*$fallbackSourceKeyword/"
		fi
	} | column --table --separator "	" \
		--table-columns "ID,F,Name,Driver,Sample Specification,State" \
		--table-right "ID" \
		--table-truncate "Name" \
		--table-empty-lines \
		--table-noheadings \
		--output-width "$(tput cols)"
}


# Built-in preset: EchoCancellation
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellation preset
# Also updates the global variables $ecSinkMaster and $ecSourceMaster,
# which are read by the EchoCancellation preset
isSetupRequiredEchoCancellation () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor )

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# isModuleMissing [moduleId]...
#
# Tests if the given module IDs are present in the PulseAudio server
# Returns with 0 if one or more IDs are missing
isModuleMissing () {
	local allModules
	local loadedModule

	allModules="$(pactl list short | sed -nre 's/^([0-9]+)\t.*$/\1/p')"
	while test $# -gt 0; do
		loadedModule="$1"; shift
		if ! echo "$allModules" | grep  --quiet --line-regexp --fixed-strings --regexp "$loadedModule"; then
			return 0
		fi
	done
	return 1
}

# Updates the global variables $ecSinkMaster and $ecSourceMaster, which
# are read by the EchoCancellation preset
# Returns with code 0 if any of the variables have changed
getNewEchoCancellationMasters () {
	local ecSinkMasterNew
	local ecSourceMasterNew
	local -i returnCode=1

	# Determine the new echo cancellation sink and source masters
	if ! ecSinkMasterNew="$(getEcSinkMaster)"; then
		ecSinkMasterNew=""
	fi
	if ! ecSourceMasterNew="$(getEcSourceMaster "$ecSinkMasterNew")"; then
		ecSourceMasterNew=""
	fi

	# Check whether the new masters differ from the ones from the
	# previous call of this method
	if test "$ecSinkMasterNew" != "$ecSinkMaster"; then
		$verbose && echo "DEBUG  Echo cancellation sink master changed from \"$ecSinkMaster\" to \"$ecSinkMasterNew\"" >&2
		ecSinkMaster="$ecSinkMasterNew"
		returnCode=0
	fi
	if test "$ecSourceMasterNew" != "$ecSourceMaster"; then
		$verbose && echo "DEBUG  Echo cancellation source master changed from \"$ecSourceMaster\" to \"$ecSourceMasterNew\"" >&2
		ecSourceMaster="$ecSourceMasterNew"
		returnCode=0
	fi

	return "$returnCode"
}

# setupEchoCancellation
#
# Preset that maintains echo cancellation between a master source and
# sink
setupEchoCancellation () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		echo " INFO  Could not find a sink master, not setting up echo cancellation" >&2
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		echo " INFO  Could not find a source master, not setting up echo cancellation" >&2
		return 0
	fi
	echo " INFO  Echo cancellation sink master is \"$ecSinkMaster\"" >&2
	echo " INFO  Echo cancellation source master is \"$ecSourceMaster\"" >&2

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Set up echo cancellation between the master sink and source
	$verbose && echo "DEBUG  Setting up echo cancellation" >&2
	loadModule module-echo-cancel "${ecParams[@]}" \
		  sink_master="$ecSinkMaster"     sink_name="${sinkMain[0]}"     sink_properties="device.description=${sinkMain[1]}" \
		source_master="$ecSourceMaster" source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new virtual echo cancellation sink and source as fallbacks
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: EchoCancellationWithSourcesMix
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellationWithSourcesMix
# preset
isSetupRequiredEchoCancellationWithSourcesMix () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSinkMastersIgnorePreset+=("${sinkEffects[0]}")
	ecSinkMastersIgnorePreset+=("${sinkMix[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sourceEc[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor)
	ecSourceMastersIgnorePreset+=("${sinkEffects[0]}".monitor)
	ecSourceMastersIgnorePreset+=("${sinkMix[0]}".monitor)

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# setupEchoCancellationWithSourcesMix
#
# Preset that maintains echo cancellation between a master source and
# sink, and that provides a way to mix arbitrary sound effects into the
# fallback source's audio via a special virtual sink "sink_fx"
# Intended for streaming setups, or if you just want to annoy the hell
# out of the other participants of a voice chat or video call by playing
# obnoxious sound effects or music on your microphone stream
setupEchoCancellationWithSourcesMix () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		echo " INFO  Could not find a sink master, not setting up echo cancellation" >&2
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		echo " INFO  Could not find a source master, not setting up echo cancellation" >&2
		return 0
	fi
	echo " INFO  Echo cancellation sink master is \"$ecSinkMaster\"" >&2
	echo " INFO  Echo cancellation source master is \"$ecSourceMaster\"" >&2

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Set up echo cancellation between the master sink and source
	$verbose && echo "DEBUG  Setting up echo cancellation" >&2
	loadModule module-echo-cancel "${ecParams[@]}" \
		  sink_master="$ecSinkMaster"     sink_name="${sinkMain[0]}"   sink_properties="device.description=${sinkMain[1]}" \
		source_master="$ecSourceMaster" source_name="${sourceEc[0]}" source_properties="device.description=${sourceEc[1]}"

	# Create virtual output devices
	$verbose && echo "DEBUG  Creating virtual output devices" >&2
	loadModule module-null-sink "${nullSinkParams[@]}" sink_name="${sinkEffects[0]}" sink_properties="device.description=${sinkEffects[1]}"
	loadModule module-null-sink "${nullSinkParams[@]}" sink_name="${sinkMix[0]}"     sink_properties="device.description=${sinkMix[1]}"

	# Create remaps
	$verbose && echo "DEBUG  Creating remaps" >&2
	loadModule module-remap-source "${remapSourceParams[@]}" master="${sinkMix[0]}".monitor \
		source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new fallbacks before creating the loopbacks
	# A case has been reported where creating the loopbacks and then
	# setting the fallbacks made the loopback devices change their sink
	# to the main sink, messing up the whole setup
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Create loopbacks
	$verbose && echo "DEBUG  Creating loopbacks" >&2
	loadModule module-loopback "${loopbackParams[@]}" source="${sourceEc[0]}"            sink="${sinkMix[0]}"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffects[0]}.monitor" sink="${sinkMix[0]}"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffects[0]}.monitor" sink="${sinkMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: EchoCancellationPlacebo
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellationPlacebo preset
isSetupRequiredEchoCancellationPlacebo () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor )

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# Preset that chooses a master sink and source and renames/remaps them
# to $sinkMain and $sourceMain, respectively
#
# Mimics the result of the EchoCancellation preset minus the actual echo
# cancellation, for when no echo cancellation is desired yet the virtual
# master devices should remain available for applications
setupEchoCancellationPlacebo () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		echo " INFO  Could not find a sink master, not creating placebo devices" >&2
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		echo " INFO  Could not find a source master, not creating placebo devices" >&2
		return 0
	fi
	echo " INFO  Placebo sink master is \"$ecSinkMaster\"" >&2
	echo " INFO  Placebo source master is \"$ecSourceMaster\"" >&2

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Create remaps
	$verbose && echo "DEBUG  Creating remaps" >&2
	loadModule module-remap-sink "${remapSinkParams[@]}" master="$ecSinkMaster" \
		sink_name="${sinkMain[0]}" sink_properties="device.description=${sinkMain[1]}"
	loadModule module-remap-source "${remapSourceParams[@]}" master="$ecSourceMaster" \
		source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new fallbacks
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: None
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the None preset
isSetupRequiredNone () {

	# This preset does nothing, so it does not care about anything
	# happening in the PulseAudio server
	return 1
}

# Preset that does nothing, intended to "switch off" pulse-autoconf
setupNone () {

	# TODO This is only - maybe - relevant when pulse-autoconf has
	# transitioned from another preset to None
	# But is it really required? Likely PulseAudio has already moved
	# streams that were using the previous preset's virtual devices to
	# the new fallback devices
	restoreStreamsOnFallbackDevices
}


# Sets/restores the default settings
#
# For more in-depth documentation about some settings look at the auto-
# generated template configuration file, or directly at the
# printTemplateConfigurationFile() function
setDefaultSettings () {

	# The desired preset, i.e. the configuration that should be
	# maintained in the PulseAudio server
	preset="EchoCancellation"

	# Echo cancellation master finding: Whether to prefer newer devices
	# over older devices
	ecSinkMastersPreferNewer=true
	ecSourceMastersPreferNewer=true

	# Echo cancellation: The parameters that should be used for
	# module-echo-cancel
	ecParams=()
	ecParams+=(aec_method=webrtc)
	ecParams+=(use_master_format=1)
	ecParams+=(aec_args="analog_gain_control=0\\ digital_gain_control=1\\ experimental_agc=1\\ noise_suppression=1\\ voice_detection=1\\ extended_filter=1")

	# Loopbacks: The parameters that should be used for module-loopback
	loopbackParams=()
	loopbackParams+=(latency_msec=60)
	loopbackParams+=(adjust_time=6)

	# Echo cancellation master finding: Patterns for device names in
	# descending order of priority
	ecSinkMasters=()
	ecSinkMasters+=("startswith:") # Any sink
	ecSourceMasters=()
	ecSourceMasters+=("notendswith:.monitor") # Exclude monitor sources

	# Echo cancellation: Whether to create and use a dummy sink as sink
	# master if no real sink master can be found
	# Yes, by default PulseAudio loads its "module-always-sink" module,
	# which automatically creates an "auto_null" sink if no sinks are
	# present, but we cannot use it, because it disappears again as soon
	# as a preset creates a virtual sink
	ecUseDummySink=true

	# Echo cancellation: Whether to create and use a dummy source as
	# source master if no real source master can be found
	ecUseDummySource=true

	# Null sinks: The parameters that should be used for
	# module-null-sink
	nullSinkParams=()

	# Null sources: The parameters that should be used for
	# module-null-source
	nullSourceParams=()

	# Sink remaps: The parameters that should be used for
	# module-remap-sink
	remapSinkParams=()

	# Source remaps: The parameters that should be used for
	# module-remap-source
	remapSourceParams=()

	# Names and descriptions that should be used for virtual devices
	# Names may not contain blanks
	# Descriptions may contain blanks, but quoting is fickle
	# If a description contains blanks you need to
	#  - Enclose the entire description in double quotes (")
	#  - Escape each blank with a leading backslash (\)
	sinkMain=(    'sink_main'  '"Main\ sink\ (play\ everything\ here)"'       ) # The primary sink
	sinkDummy=(   'sink_dummy' '"Dummy\ sink\ (do\ not\ use)"'                ) # The dummy sink
	# shellcheck disable=SC2034  # Unused variable "sinkEc"
	sinkEc=(      'sink_ec'    '"Echo-cancelled\ sink\ (do\ not\ use)"'       ) # The echo-cancelled sink
	sinkEffects=( 'sink_fx'    '"Effects\ sink\ (play\ shared\ music\ here)"' ) # The audio effects sink
	sinkMix=(     'sink_mix'   '"Mixing\ sink\ (do\ not\ use)"'               ) # The mixing sink
	sourceMain=(  'src_main'   '"Main\ source\ (record\ from\ here)"'         ) # The primary source
	sourceDummy=( 'src_dummy'  '"Dummy\ source\ (do\ not\ use)"'              ) # The dummy source
	sourceEc=(    'src_ec'     '"Echo-cancelled\ source\ (do\ not\ use)"'     ) # The echo-cancelled source

	# Echo cancellation master finding: Exact names of devices that
	# should *never* be considered as echo cancellation masters
	ecSinkMastersIgnore=()
	ecSinkMastersIgnore+=("${sinkDummy[0]}")           # The dummy sink
	ecSinkMastersIgnore+=("auto_null")                 # The sink of module-always-sink
	ecSourceMastersIgnore=()
	ecSourceMastersIgnore+=("${sourceDummy[0]}")       # The dummy source
	ecSourceMastersIgnore+=("${sinkDummy[0]}".monitor) # The monitor of the dummy sink
	ecSourceMastersIgnore+=("auto_null".monitor)       # The monitor of module-always-sink's sink

	# How long the main loop should sleep before polling the PulseAudio
	# server again
	sleepTime="5s"

	# Whether to print DEBUG messages to STDERR
	verbose=false
}


# Basic startup checks
# ----------------------------------------------------------------------

# Make sure the required programs are available
allRequiredProgramsPresent=true
type cat &> /dev/null    || { echo "ERROR  Required program \"cat\" is not available" >&2;    allRequiredProgramsPresent=false; }
type column &> /dev/null || { echo "ERROR  Required program \"column\" is not available" >&2; allRequiredProgramsPresent=false; }
type find &> /dev/null   || { echo "ERROR  Required program \"find\" is not available" >&2;   allRequiredProgramsPresent=false; }
type grep &> /dev/null   || { echo "ERROR  Required program \"grep\" is not available" >&2;   allRequiredProgramsPresent=false; }
type id &> /dev/null     || { echo "ERROR  Required program \"id\" is not available" >&2;     allRequiredProgramsPresent=false; }
type kill &> /dev/null   || { echo "ERROR  Required program \"kill\" is not available" >&2;   allRequiredProgramsPresent=false; }
type pactl &> /dev/null  || { echo "ERROR  Required program \"pactl\" is not available" >&2;  allRequiredProgramsPresent=false; }
type sed &> /dev/null    || { echo "ERROR  Required program \"sed\" is not available" >&2;    allRequiredProgramsPresent=false; }
type stat &> /dev/null   || { echo "ERROR  Required program \"stat\" is not available" >&2;   allRequiredProgramsPresent=false; }
type tac &> /dev/null    || { echo "ERROR  Required program \"tac\" is not available" >&2;    allRequiredProgramsPresent=false; }
$allRequiredProgramsPresent || exit 1
unset allRequiredProgramsPresent


# Global state variables
# ----------------------------------------------------------------------

# A string that identifies the PulseAudio instance, to recognize new
# instances
instanceId=""

# The user-level configuration files that are currently effective
# Used to check whether they have been modified
# Key is the file path, value is file size and modification time,
# separated by a space
declare -A configFilesMonitored
# Pre-load the map with the primary user-level configuration file, so
# that it will be picked up if it is created during runtime
configFilesMonitored[~/".config/pulse-autoconf/pulse-autoconf.conf"]=""

# Modules loaded by the presets (their IDs), are unloaded by teardown()
modulesLoaded=()

# Echo cancellation: The dynamically determined device that is used as
# sink master
ecSinkMaster=""
# Echo cancellation: The dynamically determined device that is used as
# source master
ecSourceMaster=""
# Exact names of devices that the current preset does not want to be
# chosen as echo cancellation masters, such as the virtual devices
# created by the echo cancellation module itself, along with other
# virtual devices created by the preset
ecSinkMastersIgnorePreset=()
ecSourceMastersIgnorePreset=()

# Streams that are recording from the fallback/default source, or
# playing to the fallback/default sink
# teardown() populates these arrays before it unloads the modules
# The setupPreset() functions may (and likely should) restore the
# streams to the new fallback devices by calling
# restoreStreamsOnFallbackDevices()
streamsPlayingToFallbackSink=()
streamsRecordingFromFallbackSource=()

# PID of the main loop's sleep process (set to the empty string while
# not in use)
sleepPid=""

# The path of the PID file for which the lock is currently being held
# (set to the empty string while not in possession of a lock)
pidFile=""

# True to leave the main loop and terminate
stopped=false

# If this is true, then the next call of isSetupRequired() will return
# with true
reloadPreset=false

# If this is true, then the next call of setup() will reload the
# configuration from the configuration files
reloadConfig=false

# If this is true, then the most recent call of setup() returned with a
# non-zero return code
setupFailed=false

# If this is true, then the most recent call of getInstanceLock()
# rturned with a non-zero return code
instanceLockFailed=false


# Handler for special single argument "--help"
# ----------------------------------------------------------------------

if test $# -eq 1 && test "$1" = "--help"; then
	echo -n "pulse-autoconf "; getVersion
	echo -n \
"PulseAudio server dynamic configuration daemon

Usage:
  pulse-autoconf
  pulse-autoconf --help
  pulse-autoconf --version
  pulse-autoconf EditConfig [customEditor] [customEditorArgument]...
  pulse-autoconf ListSinksAndSources [showMonitors] [sleepTime]

Monitors a running PulseAudio server instance and ensures that a
certain configuration is in place.

For example, makes sure that echo cancellation is always active between
a dynamically determined master sink and master source, and that the
virtual echo cancellation devices are set as fallback sink/source.
"
	exit 0
fi


# Handler for special single argument "--version"
# ----------------------------------------------------------------------

if test $# -eq 1 && test "$1" = "--version"; then
	getVersion
	exit 0
fi


# Apply/load and validate the settings
# ----------------------------------------------------------------------

reloadConfig


# Print an overview
# ----------------------------------------------------------------------

echo -n " INFO  This is pulse-autoconf " >&2; getVersion >&2
$verbose && echo "DEBUG  Verbose output is enabled" >&2


# Handle special action if present
# ----------------------------------------------------------------------

# Run a special action instead of the regular daemon if such an action
# is given as first argument
if test $# -gt 0; then
	action="$1"; shift
	actionFunction="runAction$action"

	if isFunction "$actionFunction"; then
		$verbose && echo "DEBUG  Running action \"$action\" with $# argument(s)" >&2
		"$actionFunction" "$@" && actionFunctionReturnCode="$?" || actionFunctionReturnCode="$?"
		if test "$actionFunctionReturnCode" != 0 ; then
			echo " WARN  Action \"$action\" returned with code $actionFunctionReturnCode" >&2
		fi
		exit "$actionFunctionReturnCode"
	else
		echo -n "ERROR  Unknown action \"$action\", must be one of:" >&2
		# Print all available actions
		while read -rs validAction; do
			echo -n " \"$validAction\"" >&2
		done < <(declare -F | sed -nre 's/^declare -f //;s/^runAction(.+)$/\1/p')
		echo >&2
		exit 1
	fi
fi


# Install signal/exit handlers
# ----------------------------------------------------------------------

trap handleExit EXIT
trap handleSignalReloadConfig USR1
trap stop TERM INT QUIT HUP


# Enter the main loop
# ----------------------------------------------------------------------

while ! $stopped; do

	#$verbose && echo "DEBUG  ---- Start of main loop iteration ----" >&2

	if isSetupRequired; then
		# The " || true" part is only needed for teardown() when
		# $verbose is false
		# TODO Find out why, when $verbose is false, the script exits
		# after the teardown() call, *even though teardown() returns
		# with code 0*, unless " || true" is used. Wat
		# I should get off my ass and learn a sane scripting language
		# such as Python
		teardown || true
		if setup; then
			setupFailed=false
		else 
			setupFailed=true
		fi
	fi
	if ! $stopped && ! $reloadPreset && ! $reloadConfig; then
		if ! sleep "$sleepTime" & sleepPid=$!; then
			sleep "5s" & sleepPid=$!
		fi
		wait $sleepPid || true
		sleepPid=""
	fi
	if $setupFailed; then
		reloadPreset=true
	fi
done