#!/usr/bin/env bash

OPENSIM=${OPENSIM:-core/opensim}
PGM=${PGM:-$(basename "$0")}
PGM=$(echo "$PGM" | sed -E "s/\.[a-z]+$//")
PKG="opensim-helpers"
CONF="${CONF:-opensim.conf}"
# A template with X's works with both GNU and BSD mktemp
TMP=${TMP:-$(mktemp "${TMPDIR:-/tmp}/$PGM.XXXXXX" 2>/dev/null || echo /tmp/$PGM.$$)}
DEBUG=${DEBUG:-}

if ! which realpath >/dev/null; then
	realpath() {
		readlink -f "$@" 2>/dev/null && return
		cd "$@" 2>/dev/null && pwd -P && return
	}
fi

# Absolute path WITHOUT resolving symlinks: keeps /etc instead of macOS's
# /private/etc (logical pwd). Use this, not realpath/readlink, for config dirs.
abspath() {
	[ -n "${1:-}" ] || return 1
	if [ -d "$1" ]; then
		(cd "$1" 2>/dev/null && pwd)
	elif [ -e "$1" ]; then
		(cd "$(dirname "$1")" 2>/dev/null && printf '%s/%s\n' "$(pwd)" "$(basename "$1")")
	else
		case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s/%s\n' "$PWD" "$1" ;; esac
	fi
}

# echo ":$PATH:" | grep -q ":$BINDIR:" || export PATH=$PATH:$BINDIR
# [ ! "$LIB" ] && LIB=$BASEDIR/lib
BINDIR=$(cd $(dirname "$0") && pwd)
LIBEXECDIR=$(cd $(dirname "${BASH_SOURCE}") && pwd)

BASEDIR=$(dirname "$BINDIR")

# bash-helpers of bash-tools: composer's copy in a git checkout, else the
# bash-tools package
export PATH=$BINDIR:$LIBEXECDIR:$BASEDIR/vendor/bin:$PATH
_helpers=$BASEDIR/vendor/bin/bash-helpers
[ -f "$_helpers" ] || _helpers=$(command -v bash-helpers) || {
	echo "$PGM: bash-helpers not found, install bash-tools" >&2
	exit 1
}
. "$_helpers" || exit 1
unset _helpers

## Rest of the Business logic after functions...

osConsole() {
	[ ! "$2" ] && return
	osInstance=$1
	shift
	osCommand="$@"
	if [ "$OSIM_REST_INI" ]; then
		# The remote console: an empty line answers a question with its default
		[ "$osCommand" = "\n" ] && osCommand=""
		printf '%s\n' "$osCommand" | osRest --ini "$OSIM_REST_INI" - >/dev/null 2>&1
		return
	fi
	[ "$osCommand" = "\n" ] && unset osCommand
	# screen takes a name for the start of one: without this, the console of
	# an instance that does not run would be the one of another, whose name
	# begins the same (testgrid, testgrid_sim1)
	osScreenExists "$osInstance" || return 1
	lr=$(printf "\n\b")
	# pre=$(printf "\n\b")
	screen -x $osInstance -X stuff "$(echo "$pre$osCommand$lr")" >/dev/null 2>&1
}
# Type a line in the console of an instance exactly as given: through a
# register of screen, read from a file, so that nothing in it is taken for an
# escape (^ and \ are in the strings of "stuff"), and it does not show in the
# arguments of a process. An empty line is a bare Enter.
# Args: instance, line
osConsoleLine() {
	local file rc
	osScreenExists "$1" || return 1
	file=$(mktemp "${TMPDIR:-/tmp}/osconsole.XXXXXX") || return 1
	printf '%s\n' "$2" >"$file"
	screen -S "$1" -X readreg p "$file" >/dev/null 2>&1 && screen -S "$1" -X paste p >/dev/null 2>&1
	rc=$?
	# screen reads the file when it gets the command, not when we return
	sleep 0.5
	rm -f "$file"
	return $rc
}

# True if a live (non-dead) screen session exists for the given instance.
osScreenExists() {
	screen -ls 2>/dev/null | grep -E "[0-9]+\.$1[[:space:]]" | grep -qv "Dead"
}

# Whether an instance is running: its screen session, or, for an instance with
# a remote console (no session), its process, found by its config. Args: the
# instance, its config (default: the one being started, OS_CURRENT_INI)
osInstanceRunning() {
	osScreenExists "$1" && return 0
	local ini=${2:-${OS_CURRENT_INI:-}}
	[ -n "$ini" ] && pgrep -f -- "-inifile=$ini( |\$)" >/dev/null 2>&1
}

# The command-line client of the remote console (opensim-rest-php), for an
# instance of this machine: reached at the local address, whatever BaseURL says
osRest() {
	php "$BASEDIR/vendor/magicoli/opensim-rest-php/opensim-rest-cli.php" --host 127.0.0.1 "$@"
}

# Whether the config of an instance gives it a remote console (a port, a user
# and a password in [Network]). If so, OSIM_REST_INI is set: the commands
# sent with osConsole go to it, else to the screen session of the instance.
osUseRest() {
	unset OSIM_REST_INI
	[ -f "${1:-}" ] || return 1
	grep -qiE '^[[:blank:]]*(ConsolePort|console_port)[[:blank:]]*=[[:blank:]]*"?[1-9]' "$1" &&
		grep -qiE '^[[:blank:]]*ConsoleUser[[:blank:]]*=[[:blank:]]*"?[^"[:blank:]]' "$1" &&
		grep -qiE '^[[:blank:]]*ConsolePass[[:blank:]]*=[[:blank:]]*"?[^"[:blank:]]' "$1" || return 1
	export OSIM_REST_INI="$1"
}

# Wait for a pattern in the log of an instance, in what it wrote after the mark
# (a byte offset taken before the start): 0 when it shows, 3 when the instance
# exited meanwhile, 1 at the end of the delay.
# Args: log file, mark, instance, seconds, pattern
osLogWait() {
	local logfile=$1 mark=$2 instance=$3 seconds=$4 pattern=$5 t=0
	while [ $t -lt $((seconds * 2)) ]; do
		tail -c +$((mark + 1)) "$logfile" 2>/dev/null | grep -aqE "$pattern" && return 0
		osShowProgress
		osInstanceRunning "$instance" || return 3
		sleep 0.5
		t=$((t + 1))
	done
	return 1
}

# One line of progress. On a terminal each one takes the place of the previous, which
# makes a quiet line of what takes a while; elsewhere (a log, a pipe) each is a line of
# its own, once. osStepEnd closes the line left open.
osStep() {
	local text=$1 cols
	if [ -t 2 ]; then
		cols=$(tput cols 2>/dev/null || echo 80)
		printf '\r\033[2K%s' "${text:0:$((cols - 1))}" >&2
		: >"$TMP.step"
		return 0
	fi
	[ "$text" = "$(cat "$TMP.step.last" 2>/dev/null)" ] && return 0
	printf '%s\n' "$text" >&2
	printf '%s' "$text" >"$TMP.step.last"
}

# End the line of progress left open by osStep, so that what comes next starts on its own.
osStepEnd() {
	rm -f "$TMP.step.last"
	[ -e "$TMP.step" ] || return 0
	rm -f "$TMP.step"
	printf '\n' >&2
}

# How a simulator loads, shown while the launcher waits for it: what it prints first,
# then the steps its log reports (libexec/sim-progress.awk), on the standard error as the
# waits may be run in a command substitution. What was shown is kept in a file for the
# same reason, each step once.
# Args: log file, byte of the log where this start begins, config file of the
# instance (for its number of regions), file of what the process prints (optional)
osProgressStart() {
	local regionsdir
	OS_PROGRESS_LOG=$1
	OS_PROGRESS_MARK=${2:-0}
	OS_PROGRESS_START=${4:-}
	OS_PROGRESS_STATE="$TMP.progress"
	: >"$OS_PROGRESS_STATE"
	regionsdir=$(crudget "$3" Startup regionload_regionsdir 2>/dev/null)
	OS_PROGRESS_REGIONS=$(cat "$regionsdir"/*.ini 2>/dev/null | grep -c '^[[:blank:]]*\[')
	[ "${OS_PROGRESS_REGIONS:-0}" -ge 1 ] 2>/dev/null || OS_PROGRESS_REGIONS=1
}

# Show the steps not shown yet, if osProgressStart was called.
osShowProgress() {
	[ -n "${OS_PROGRESS_LOG:-}" ] || return 0
	local line
	{
		[ -n "${OS_PROGRESS_START:-}" ] &&
			awk -v mode=start -f "$BASEDIR/libexec/sim-progress.awk" "$OS_PROGRESS_START" 2>/dev/null
		tail -c +$((OS_PROGRESS_MARK + 1)) "$OS_PROGRESS_LOG" 2>/dev/null |
			awk -v regions="$OS_PROGRESS_REGIONS" -f "$BASEDIR/libexec/sim-progress.awk"
	} | while IFS= read -r line; do
		grep -qxF -- "$line" "$OS_PROGRESS_STATE" 2>/dev/null && continue
		printf '%s\n' "$line" >>"$OS_PROGRESS_STATE"
		osStep "$line"
	done
	return 0
}

# End of the progress of an instance. The regions announce themselves to the grid
# some seconds after their configuration is loaded: the steps keep showing until
# all of them did (or for a minute, or while the instance runs), then no more.
osProgressStop() {
	[ -n "${OS_PROGRESS_LOG:-}" ] || return 0
	local t=0 registered
	while [ $t -lt 120 ]; do
		osShowProgress
		registered=$(tail -c +$((OS_PROGRESS_MARK + 1)) "$OS_PROGRESS_LOG" 2>/dev/null | grep -ac '\[GRID SERVICE\]: Region .* registered at')
		[ "${registered:-0}" -ge "${OS_PROGRESS_REGIONS:-1}" ] && break
		osInstanceRunning "${instance:-}" || break
		sleep 0.5
		t=$((t + 1))
	done
	osShowProgress
	osStepEnd
	unset OS_PROGRESS_LOG OS_PROGRESS_START
}

# The log file an instance writes, from the arguments of its process: its -logfile
# (Robust), else the file of its log config. Args: pid
osLogOfPid() {
	local args file config
	args=$(ps -o args= -p "$1" 2>/dev/null) || return 1
	file=$(sed -nE "s/.* -logfile='?([^' ]+)'?.*/\1/p" <<<"$args")
	if [ -z "$file" ]; then
		config=$(sed -nE "s/.* -logconfig='?([^' ]+)'?.*/\1/p" <<<"$args")
		[ -f "$config" ] &&
			file=$(grep -E "LogFileAppender|<file" "$config" | grep -A1 "LogFileAppender" | head -2 | tail -1 | cut -d '"' -f 2)
	fi
	[ -n "$file" ] && echo "$file"
}

# Wait for the process of an instance to exit, showing what it does meanwhile (the last line
# of its log). 0 when it exited, 1 at the end of the delay.
# Args: instance name, pid, seconds
osWaitExit() {
	local name=$1 pid=$2 seconds=$3 t=0 logfile last="" text
	logfile=$(osLogOfPid "$pid")
	while kill -0 "$pid" 2>/dev/null; do
		[ $t -lt $((seconds * 2)) ] || { osStepEnd; return 1; }
		last=$(tail -n 1 "$logfile" 2>/dev/null | tr -d '\r' | sed -E 's/^[0-9-]+ [0-9:,.]+ +[A-Z]+ +//')
		text="  Waiting for $name to stop"
		[ -t 2 ] && text="$text ($((t / 2))s)"
		osStep "$text${last:+: $last}"
		sleep 0.5
		t=$((t + 1))
	done
	osStepEnd
	return 0
}

# Wait for a delay, counting it down. Args: seconds, what is waited for
osCountdown() {
	local left=$1 label=$2
	while [ "$left" -gt 0 ]; do
		osStep "  $label in ${left}s"
		if [ -t 2 ]; then sleep 1; left=$((left - 1)); else sleep "$left"; left=0; fi
	done
	osStepEnd
}

# The connectors a Robust loads, as their class names, one per line: what its
# ServerMain reads, the non-empty values of [ServiceList] and of [Startup]
# ServiceConnectors, written "[config@]port/assembly:class" or
# "assembly:class". ${Const|Name} references are expanded from the variables
# the caller loaded from [Const] (some configs write the values themselves).
# Every service the config lists is one the instance must load.
osRobustServices() {
	local tmp="$TMP.services.ini" value conn
	cleanupIni "$1" >"$tmp" || return 1
	{
		crudini --get --format=lines "$tmp" ServiceList 2>/dev/null | sed -E 's/^\[ *ServiceList *\] *[^=]*= *//'
		crudini --get "$tmp" Startup ServiceConnectors 2>/dev/null | tr ', ' '\n\n'
	} | while IFS= read -r value; do
		value=${value#\"}
		value=${value%\"}
		value=$(osExpandConst "$value")
		[ -n "$value" ] || continue
		case $value in
		*/*) conn=${value#*/} ;;
		*) conn=$value ;;
		esac
		echo "${conn##*:}"
	done | awk '!seen[$0]++'
	rm -f "$tmp"
}

# Follow the log of a Robust while it loads its connectors, showing each one
# as it is reported: 0 when all are loaded, 4 when some failed to load (named
# in OS_FAILED_SERVICES, once the others have reported), 3 when the instance
# exited, 1 when the delay is over. The log, not the console: a detached
# screen cuts the lines at 80 columns.
# Args: log file, byte of the log where this start begins, instance, seconds,
# then the class names.
osWatchServices() {
	local logfile=$1 mark=$2 instance=$3 seconds=$4 content class failed="" ticks=0 reported=0
	shift 4
	local total=$#
	local pending=" $* "
	local max=$((seconds * 2))
	OS_FAILED_SERVICES=
	while :; do
		content=$(tail -c +$((mark + 1)) "$logfile" 2>/dev/null)
		for class in $*; do
			[[ "$pending" == *" $class "* ]] || continue
			if grep -aqE "\[SERVER\]: $class loaded successfully" <<<"$content"; then
				reported=$((reported + 1))
				osStep "  $class $reported/$total"
				pending=${pending/ $class / }
			elif grep -aqE "Failed to load .*:$class([^A-Za-z0-9]|$)" <<<"$content"; then
				reported=$((reported + 1))
				osStepEnd
				printf '  %s: failed to load (%d/%d)\n' "$class" "$reported" "$total" >&2
				failed="$failed $class"
				pending=${pending/ $class / }
			fi
		done
		[ -n "${pending// /}" ] || break
		osInstanceRunning "$instance" || return 3
		ticks=$((ticks + 1))
		[ $ticks -lt $max ] || return 1
		sleep 0.5
	done
	[ -z "$failed" ] || {
		OS_FAILED_SERVICES=${failed# }
		return 4
	}
	return 0
}

osConsoleRead() {
	[ ! "$1" ] && return
	[ ! "$COLUMNS" ] && COLUMNS=75
	osInstance=$1
	shift
	pattern="$@"
	osScreenExists "$osInstance" || return 1
	screen -x $osInstance -X hardcopy -h $TMP.console >/dev/null 2>&1
	if [ "$pattern" ]; then
		egrep "$pattern" $TMP.console && return
		printf "\33[2K"
		printf "$(grep -a . $TMP.console | tail -1 | cut -c -$COLUMNS)\r"
		return 1
	else
		cat $TMP.console | sed "s/^/cat /"
	fi
}
# Wait for a pattern in the console of an instance: 0 when it shows, 3 when
# the instance exited meanwhile, 1 at the end of the delay.
# Usage: osConsoleWait [seconds] instance pattern
# The delay is at least 30 seconds: a shorter one, as the launcher asks for the
# questions of a first start, has always waited that long, and a longer one
# (Robust, regions) is now honoured.
osConsoleWait() {
	[ "$2" ] || return $?
	local seconds=30
	if [ "$3" ]; then
		seconds=$1
		shift
	fi
	[ "$seconds" -ge 30 ] || seconds=30
	osInstance=$1
	shift
	pattern="$@"
	[ "$pattern" = "" ] && return 2
	local t=0 ticks=$((seconds * 2))
	while [ $t -lt $ticks ]; do
		osConsoleRead $osInstance "$pattern" && return
		osShowProgress
		# Stop waiting if the session is gone: the instance has exited.
		osScreenExists "$osInstance" || return 3
		sleep 0.5
		t=$(($t + 1))
	done
	return 1
}

cleanupIni() {
	[ "$1" ] || return
	[ -f "$1" ] || return
	# OpenSim ships some of its files with Windows line endings
	tr -d '\r' <"$1" | sed "s/^[[:blank:]]*//"
}

randomPassword() {
	echo $(</dev/urandom tr -dc "[:alnum:]" | head -c32)
}

crudget() {
	[ -n "${2:-}" ] || return $?
	[ -f "${1:-}" ] || return $?
	cleanupIni "$1" >$TMP.get

	if [ -n "${3:-}" ]; then
		crudini --get $TMP.get $2 $3 | sed -e 's/^"//' -e 's/"$//'
		return $?
	else
		sed -i~ "s/^\([^=]\)-/\\1_/" $TMP.get
		# Strip redundant double-quoting only when both delimiters are present: ='"..."' → ='...'
		# Do NOT blindly strip leading '" which breaks values starting with ( or special chars
		crudini --get --format=sh $TMP.get $2 |
			sed "s/='\"\(.*\)\"'$/='\1'/" \
				>$TMP.crudget.sh 2>/dev/null || true
		[ -s "$TMP.crudget.sh" ] && . "$TMP.crudget.sh" || true
		return 0
	fi
}

crudmerge() {
	[ -f "$2" ] || return
	debug merging $2 in $1
	# cleanupIni $2 | sed "s/^Include-/Include_/" > $TMP.merge.ini
	cleanupIni $2 >$TMP.merge.ini
	crudini --merge $1 <$TMP.merge.ini || end $? $2 Merge failed
}

cleanupIni4Prod() {
	[ "$1" ] || return
	sed -i~ "s/Include_/Include-/" $1
	crudini --get $1 >$TMP.sections
	cat $TMP.sections | while read section; do
		crudini --get $1 $section | grep -qi [a-z] || crudini --del $1 "$section"
	done
}

testDatabaseConnection() {
	[ "$4" ] || return $?
	DatabaseHost=$1
	DatabaseName=$2
	DatabaseUser=$3
	DatabasePassword=$4
	debug "Testing database $DatabaseName connection"

	# The account host MariaDB applies depends on how we connect: a
	# "localhost" target goes through the unix socket (account host
	# 'localhost'), any other value is a TCP connection matching that host.
	# Note: a '%' grant does NOT cover socket 'localhost' connections.
	[ "$DatabaseHost" = "localhost" ] && grant_host="localhost" || grant_host="$DatabaseHost"

	# Real target test, used directly as the condition so errexit never
	# trips here. Capture stderr so we know *why* it fails instead of
	# blindly assuming the user is missing (the old code retried CREATE USER
	# on an already-existing account, without ever fixing anything).
	if dberror=$(echo "" | mysql -h$DatabaseHost -u$DatabaseUser -p"$DatabasePassword" $DatabaseName 2>&1); then
		debug "Database Connection OK"
		return 0
	fi

	case "$dberror" in
	*"Unknown database"*)
		# Authentication works, only the database itself is missing.
		debug "User $DatabaseUser authenticates but database $DatabaseName is missing"
		;;
	*"Access denied"*)
		# Authentication failed: account absent, wrong password, or account
		# exists but not for the host we connect as. Probe before deciding.
		databaseAuthRepair "$DatabaseHost" "$DatabaseName" "$DatabaseUser" "$DatabasePassword" "$grant_host"
		return $?
		;;
	*)
		log 1 "Cannot reach database $DatabaseName on $DatabaseHost: $dberror"
		return 1
		;;
	esac

	# Unknown-database path: auth is fine, create the database and re-test.
	createDatabase $DatabaseHost $DatabaseUser $DatabaseName "$grant_host" &&
		echo "" | mysql -h$DatabaseHost -u$DatabaseUser -p"$DatabasePassword" $DatabaseName &&
		debug "Database OK" && return 0
	return $?
}

databaseAuthRepair() {
	DatabaseHost=$1
	DatabaseName=$2
	DatabaseUser=$3
	DatabasePassword=$4
	grant_host=$5

	# Which hosts does this account already exist for? (admin socket auth)
	existing=$(echo "SELECT Host FROM mysql.user WHERE User='$DatabaseUser';" |
		sudo mysql -h$DatabaseHost -BN 2>/dev/null)

	if [ -z "$existing" ]; then
		# Account truly absent -> create it for the connecting host.
		debug "User $DatabaseUser does not exist yet on $DatabaseHost"
	elif echo "$existing" | grep -qx "$grant_host"; then
		# Account already exists for this exact host -> the password is wrong,
		# creating the user again would not help.
		log 1 "User '$DatabaseUser'@'$grant_host' exists but the password is rejected. Fix the password in the connection string and retry."
		return 1
	else
		# Account exists, but not for the host we connect as: the real cause
		# (e.g. opensim@'%' present, but a localhost socket connection needs
		# opensim@'localhost'). Offer to add the missing host grant.
		debug "User '$DatabaseUser' exists for host(s): $(echo $existing) - but not for '$grant_host'"
		yesno "Create '$DatabaseUser'@'$grant_host' and grant access to $DatabaseName?" || {
			log 1 "Run manually: CREATE USER '$DatabaseUser'@'$grant_host' IDENTIFIED BY '<password>'; GRANT ALL ON $DatabaseName.* TO '$DatabaseUser'@'$grant_host';"
			return 1
		}
	fi

	createDatabaseUser $DatabaseHost $DatabaseUser "$DatabasePassword" "$grant_host" || return $?
	createDatabase $DatabaseHost $DatabaseUser $DatabaseName "$grant_host" &&
		echo "" | mysql -h$DatabaseHost -u$DatabaseUser -p"$DatabasePassword" $DatabaseName &&
		debug "Database OK" && return 0
	return $?
}

createDatabaseUser() {
	[ "$3" ] || return $?
	DatabaseHost=$1
	DatabaseUser=$2
	DatabasePassword=$3
	grant_host="${4:-%}"
	yesno "Create database user '$DatabaseUser'@'$grant_host' on $DatabaseHost?" || return $?
	echo "CREATE USER IF NOT EXISTS '$DatabaseUser'@'$grant_host' IDENTIFIED BY '$DatabasePassword';" | sudo mysql -h$DatabaseHost 2>/dev/null
	return $?
}

createDatabase() {
	[ "$3" ] || return $?
	DatabaseHost=$1
	DatabaseUser=$2
	DatabaseName=$3
	grant_host="${4:-%}"
	yesno "Create database $DatabaseName and grant rights to '$DatabaseUser'@'$grant_host'?" || return $?
	last="create database" && echo "CREATE DATABASE IF NOT EXISTS $DatabaseName character set UTF8;" | sudo mysql -h$DatabaseHost &&
		last="grant rights" && echo "GRANT ALL ON $DatabaseName.* TO '$DatabaseUser'@'$grant_host';" | sudo mysql -h$DatabaseHost &&
		(
			sql="set global innodb_file_format = 'Barracuda'" && echo "$sql" | sudo mysql -h$DatabaseHost &&
				sql="set global innodb_large_prefix = 'ON'" && echo "$sql" | sudo mysql -h$DatabaseHost ||
				log $? "$sql returned error $?. Make sure that your mysql sever accepts large prefix."
		) && debug "Database created" ||
		(
			return=$?
			log $return "error $last"
			return $return
		)
	return $?
}

iniExpandVariables() {
	[ ! "$1" ] && log 1 iniExpandVariables no ini file specified && return
	thisini="$1"
	grep -q '${' $thisini || {
		debug "no var in $thisini, skipping"
		return
	}
	debug expanding $thisini
	sed -E "s/($varPattern)/\n\\1\n/g" $TMP.ini |
		grep -E --color "$varPattern" | sed -e "s/[{}$]//g" -e "s/|/ /" |
		sort -u | while read section variable; do
		value=$(crudget $TMP.ini $section $variable)
		# echo $section $variable = $value
		echo "$value" | grep -q "/" &&
			sed -E -i "s%[$]\{$section\|$variable}%$value%g" $thisini ||
			sed -E -i "s/[$]\{$section\|$variable}/$value/g" $thisini
		# grep --color "$section|$variable" $thisini
		# grep $value $thisini
	done
}

iniMergeAndForget() {
	[ ! "$3" ] && log 1 "usage iniMergeAndForget ini section variable" && return 1
	thisini=$1
	section=$2
	variable=$3
	file=$(crudget $thisini $section $variable)
	[ ! -e "$file" ] && log 2 "file $file not found" && return 2

	debug mergin $common
	crudmerge $thisini $file
	crudini --del $thisini $section $variable
	sed -i~ "s/Include_/Include-/" $thisini
	iniExpandVariables $thisini
}

sql() {
	[ "$1" = "-v" ] && options="-v" && shift || options="-BN"
	echo "$@" | mysql --defaults-file=$TMP.my.cnf $options
}

uncomment() {
	[ "$2" ] || return
	sed -i~ "s/; *$1 *= */$1 = /" "$2"
}

isuuid() {
	echo $1 | grep -qEi "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
}

# version_ge A B -> true (0) if dot-separated numeric version A >= B.
# Portable (bash 3.2+, no external sort -V which BSD/macOS lacks).
version_ge() {
	[ "$1" = "$2" ] && return 0
	# IFS must be set before the array splits, hence its own line.
	local IFS=.
	local i a=($1) b=($2)
	for ((i = 0; i < ${#b[@]}; i++)); do
		local x=${a[i]:-0} y=${b[i]:-0}
		[ "$((10#$x))" -gt "$((10#$y))" ] && return 0
		[ "$((10#$x))" -lt "$((10#$y))" ] && return 1
	done
	return 0
}

# Single source of truth (bash side) for which executable + runtime to use for
# an OpenSim assembly base name (Robust, OpenSim, MoneyServer, pCampBot, …),
# given its core/bin directory. Sets: osversion, runtime (dotnet|mono),
# runtime_opts, Executable. 0.9.3.0+ ships .NET assemblies (.dll, run with
# dotnet + roll-forward); older releases are Mono (.exe). The PHP port shares
# the equivalent helper, so the rule lives in exactly one place per language.
osResolveRuntime() {
	local base="$1" dir="$2"
	osversion=$(echo "$dir" | grep -oE '[0-9]+(\.[0-9]+)+' | head -1)
	# .NET builds come with a runtimeconfig.json, whatever their folder name
	# (e.g. a development build); otherwise the version in the path decides
	if [ -f "$dir/$base.runtimeconfig.json" ] || version_ge "${osversion:-0}" 0.9.3.0; then
		runtime=dotnet
		runtime_opts="--roll-forward Major"
		Executable="$base.dll"
	else
		runtime=mono
		runtime_opts=""
		Executable="$base.exe"
	fi
}

# .NET does not read the dllmap files of a core: the native libraries it needs
# (physics, OpenJPEG) are looked for by their plain name (libBulletSim.so). The
# core being read-only, echo a folder of links with those names for this
# architecture, made in the given base folder for the core of the given bin
# folder; nothing when it cannot be made.
# Args: bin folder of the core, base folder
osNativeLibs() {
	local bin=$1 dest="$2/$(basename "$(dirname "$1")")" arch base lib
	case $(uname -m) in
	aarch64 | arm64) arch=arm64 ;;
	*) arch=x86_64 ;;
	esac
	[ -d "$bin/lib64" ] || return 0
	mkdir -p "$dest" 2>/dev/null || return 0
	for base in BulletSim openjpeg-dotnet ubode; do
		# The build of this architecture, else the plain one (x86_64 only)
		lib="$bin/lib64/lib$base-$arch.so"
		[ -f "$lib" ] || { [ "$arch" = x86_64 ] && lib="$bin/lib64/lib$base.so"; }
		[ -f "$lib" ] && ln -sfn "$lib" "$dest/lib$base.so" 2>/dev/null
	done
	echo "$dest"
}

# Echo the .NET version required by an assembly, read from its
# <name>.runtimeconfig.json (falls back to 8.0.0 if not found).
dotnetRequiredVersion() {
	local cfg="${1%.dll}.runtimeconfig.json" v
	[ -f "$cfg" ] && v=$(grep -A2 'Microsoft.NETCore.App' "$cfg" |
		grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
	echo "${v:-8.0.0}"
}

# Expand the ${Const|Name} references of an instance config value with the
# variables of its [Const] section (loaded by crudget).
osExpandConst() {
	local value=$1 name
	while [[ "$value" =~ \$\{Const\|([A-Za-z0-9_]+)\} ]]; do
		name=${BASH_REMATCH[1]}
		value=${value//"\${Const|$name}"/${!name}}
	done
	printf '%s\n' "$value"
}

# Enable the modules of the install for an instance: their files linked in the
# addins folder of its registry, where OpenSim loads add-ins from, so a core
# kept read-only needs nothing written in its bin folder. The modules to
# enable are the words of EnabledModules (opensim.conf), looked for in
# ModulesRoot (default /usr/share/opensim-modules) under the name of the core
# folder, or its version. What was linked for a module no longer enabled is
# removed.
# Args: addins folder, bin folder of the core
osSyncModules() {
	local addins=$1 core root module name dir file wanted=" "
	core=$(basename "$(dirname "$2")")
	root=${ModulesRoot:-/usr/share/opensim-modules}

	for module in ${EnabledModules//,/ }; do
		dir=
		for name in "$core" "$(echo "$core" | grep -oE '[0-9]+(\.[0-9]+)+' | head -1)"; do
			[ -n "$name" ] && [ -d "$root/$name/$module" ] && dir=$root/$name/$module && break
		done
		if [ -z "$dir" ]; then
			debug "module $module: not installed for $core, skipped"
			continue
		fi
		mkdir -p "$addins" || return $?
		for file in "$dir"/*; do
			[ -f "$file" ] || continue
			ln -sfn "$file" "$addins/$(basename "$file")"
			wanted="$wanted$addins/$(basename "$file") "
		done
		debug "module $module enabled from $dir"
	done

	[ -d "$addins" ] || return 0
	for file in "$addins"/*; do
		[ -L "$file" ] || continue
		case "$(readlink "$file")" in
		"$root"/*) [[ "$wanted" == *" $file "* ]] || { rm -f "$file" && debug "module file $(basename "$file") removed"; } ;;
		esac
	done
}

# Verify the runtime needed to launch an executable is installed.
# Args: runtime (dotnet|mono), executable path. Returns non-zero with an
# explicit message if the runtime, or the required .NET major, is missing.
# Whether a native library is installed (Linux): known to the loader, or in
# the usual folders. Args: name without lib and .so, e.g. gdiplus
osHasLibrary() {
	local file
	{ ldconfig -p || /sbin/ldconfig -p; } 2>/dev/null | grep -q "lib$1\.so" && return 0
	for file in /usr/lib/lib$1.so* /usr/lib*/lib$1.so* /usr/lib/*/lib$1.so* /usr/local/lib/lib$1.so*; do
		[ -e "$file" ] && return 0
	done
	return 1
}

checkRuntimeAvailable() {
	local runtime="$1" exe="$2"
	case "$runtime" in
	mono)
		command -v mono >/dev/null 2>&1 && return 0
		log 1 "mono is not installed (required for OpenSim < 0.9.3.0). Install it, or run install/install.sh"
		return 1
		;;
	dotnet)
		local required major m use=""
		required=$(dotnetRequiredVersion "$exe")
		major=${required%%.*}
		if ! command -v dotnet >/dev/null 2>&1; then
			log 1 "dotnet is not installed. Install the .NET runtime with: sudo $LIBEXECDIR/install-dotnet $major"
			return 1
		fi
		# Roll-forward: any runtime at or above the required major can run the
		# app (the launcher sets DOTNET_ROLL_FORWARD=Major). Pick the lowest.
		for m in $(dotnet --list-runtimes 2>/dev/null |
			grep -o 'Microsoft.NETCore.App [0-9]*' | awk '{print $2}' | sort -un); do
			[ "$m" -ge "$major" ] && use=$m && break
		done
		if [ -n "$use" ]; then
			[ "$use" = "$major" ] && debug "Found .NET runtime $major" ||
				debug "Using .NET $use (roll-forward from required $major)"
			return 0
		fi
		log 1 "OpenSim needs Microsoft.NETCore.App $major or newer, but none is installed"
		log 1 "Installed: $(dotnet --list-runtimes 2>/dev/null | grep -o 'Microsoft.NETCore.App [0-9.]*' | tr '\n' ' ')"
		log 1 "Install .NET $major+ (sudo $LIBEXECDIR/install-dotnet $major) and retry"
		return 1
		;;
	esac
}

# Python venv + crudini (isolated from system Python)
. $BASEDIR/libexec/venv-setup || end $? "Could not set up Python venv"
debug "using $(which python3)"

uuidgen=$(which uuid || which uuidgen)

# --- Resolve configuration ---------------------------------------------------
# One config file. It is found at a predictable per-user path first (a symlink
# into EtcRoot), then at common system/repo locations. [Defaults] holds the
# shared, version-independent locations and the default Version; each installed
# version has its own [opensim-X.Y.Z] section (CoreDirectory, Runtime).
conf=""
for candidate in \
	"$HOME/.config/opensim/$CONF" \
	"$HOME/.$CONF" \
	/etc/opensim/$CONF \
	"$BASEDIR/config/$CONF"; do
	[ -f "$candidate" ] || continue
	conf="$candidate"
	break
done

if [ -n "$conf" ]; then
	debug "loading config $conf"
	cp "$conf" "$TMP.conf"
else
	debug "no config found, using built-in defaults"
	echo "[Defaults]" >"$TMP.conf"
fi

# [Defaults] gives the pointer to the active install profile plus fallback
# values. The named profile section then overrides with its own full set, so
# it always wins. Each variable is given a definite value (empty if absent)
# afterwards for set -u safety.
crudget "$TMP.conf" Defaults
active=${DefaultProfile:-}
[ -n "$active" ] && crudget "$TMP.conf" "$active"
DirectoryLayout=${DirectoryLayout:-}
OpensimVersion=${OpensimVersion:-}
InstallPath=${InstallPath:-}
EtcRoot=${EtcRoot:-$BASEDIR/etc}
CoreRoot=${CoreRoot:-$BASEDIR/core}
CoreDirectory=${CoreDirectory:-}
VarRoot=${VarRoot:-}
LogsRoot=${LogsRoot:-}
CacheRoot=${CacheRoot:-}
DataRoot=${DataRoot:-}
Runtime=${Runtime:-}
SystemUser=${SystemUser:-}
EnabledModules=${EnabledModules:-}
ModulesRoot=${ModulesRoot:-}
debug "base conf loaded (profile ${active:-none}, version ${OpensimVersion:-unset})"

# The etc directory is the entry point for every helper, so a missing one is
# fatal -- except for install scripts, whose job is precisely to create it.
if [ -d "$EtcRoot" ]; then
	debug "EtcRoot=$EtcRoot"
else
	case "$PGM" in
	*install*)
		debug "EtcRoot '$EtcRoot' does not exist yet, install will create it"
		;;
	*)
		end 1 "Etc directory '$EtcRoot' not found. Run ./install/install.sh, or set EtcRoot in [Defaults] of $CONF"
		;;
	esac
fi

# eval "$(crudini --get --format=sh "$_iconf" install 2>/dev/null)"

# Deprecated, use CoreDirectory/bin instead
BinDirectory=${BinDirectory:-$CoreDirectory/bin}

# The main assembly of a core folder: OpenSim.exe (Mono, release tarballs) or
# OpenSim.dll (.NET builds made on Linux have no .exe)
osMainAssembly() {
	ls "$1"/bin/OpenSim.exe "$1"/bin/OpenSim.dll 2>/dev/null | head -1
}

# Core folders under $1, releases by version first, then the others (e.g.
# unstable)
osCores() {
	ls -d "$1"/*/bin/OpenSim.exe "$1"/*/bin/OpenSim.dll 2>/dev/null |
		sed 's:/bin/OpenSim\.[a-z]*$::' | sort -uV |
		awk '/[0-9]+(\.[0-9]+)+$/ { print; next } { others = others $0 "\n" } END { printf "%s", others }'
}

# Find Core best candidate
if [ -n "$CoreDirectory" ] && [ -n "$(osMainAssembly "$CoreDirectory")" ]; then
	OpenSimExe=$(osMainAssembly "$CoreDirectory")
	debug "Found $OpenSimExe in CoreDirectory"
else
	debug "Looking for the latest core"
	latestCore=$(osCores "$CoreRoot" | grep -E '[0-9]+(\.[0-9]+)+$' | tail -1)
	latestCore=${latestCore:-$(osCores "$CoreRoot" | tail -1)}
	OpenSimExe=$([ -n "$latestCore" ] && osMainAssembly "$latestCore")
	if [ -n "$OpenSimExe" -a -e "$OpenSimExe" ]; then
		debug "Found OpenSimExe $OpenSimExe"
		BinDirectory=$(dirname "$OpenSimExe")
		CoreDirectory=$(dirname "$BinDirectory")
	fi
fi

# if  [ -n "${OpenSimExe:-}" ]; then
# 	log "$OpenSimExe found"
# 	BinDirectory=$(dirname $OpenSimExe)
# 	CoreDirectory=$(dirname $BinDirectory)
# 	log "default OpenSim core $BinDirectory"
# else
# 	if grep -q "install" <<< "$PGM"; then
# 		log "No pre-existing OpenSim core, but we're here to handle that"
# 	else
# 		log 1 "No OpenSim.exe found, run ./install/install.sh to install OpenSim core"
# 	fi
# fi
