#!/usr/bin/env bash
# Integration test for the development environment.
#
# It drives `frogue-dev`, the same entry point `nix run .#dev` runs, with the
# same config and the same OTLP environment; drives a request through the
# server; and then asserts that the trace and the log records actually reached
# OpenObserve. Nothing here re-implements the environment, so it covers the
# topology *and* the way the app starts process-compose -- an app that binds
# something fixed fails here rather than only on whoever's machine happens to
# have that port taken.
#
# Run it with `nix run .#dev-test`. It needs podman, it really starts the
# container, and it uses the development ports, so nothing else may be holding
# 6565 or 5080 while it runs. `FROGUE_DEV_OBSERVE_URL` and
# `FROGUE_DEV_OBSERVE_AUTH` are exported by the wrapper, from the same bindings
# that configure the stack.

set -euo pipefail

if ! command -v frogue-dev >/dev/null; then
	echo "dev-test: frogue-dev is not on PATH" >&2
	echo "dev-test: run me through \`nix run .#dev-test\`" >&2
	exit 1
fi

root=${FROGUE_DEV_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
cd "$root"

# A connection attempt is the cheapest protocol-agnostic liveness probe, and
# unlike curl it is not fooled by a listener that speaks something other than
# HTTP -- which is exactly what the OTLP port is.
port_open() {
	(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
}

busy=""
for port in 6565 5080; do
	if port_open "$port"; then
		busy="$busy $port"
	fi
done
if [ -n "$busy" ]; then
	echo "dev-test: something is already listening on$busy; stop it first" >&2
	exit 1
fi

log_dir=${TMPDIR:-/tmp}/frogue-dev-test
mkdir -p "$log_dir"
log=$log_dir/process-compose.log
# Kept apart from the stack's console output: pointing both at one file would
# leave two writers truncating each other.
console=$log_dir/console.log

echo "dev-test: starting the development stack (logs: $log_dir)"
frogue-dev --tui=false --log-file "$log" >"$console" 2>&1 &
pc_pid=$!

cleanup() {
	kill "$pc_pid" 2>/dev/null || true
	wait "$pc_pid" 2>/dev/null || true
	# process-compose stops what it started, but if it was killed outright the
	# container can outlive it and hold the ports.
	podman rm --force frogue-dev-openobserve >/dev/null 2>&1 || true
}
trap cleanup EXIT

wait_for_http() {
	local what=$1 url=$2 timeout=$3
	local deadline=$((SECONDS + timeout))
	while [ "$SECONDS" -lt "$deadline" ]; do
		if curl --silent --fail --output /dev/null "$url"; then
			echo "dev-test: $what is up"
			return 0
		fi
		sleep 1
	done
	echo "dev-test: $what did not answer at $url within ${timeout}s" >&2
	echo "dev-test: last lines of $console:" >&2
	tail --lines=20 "$console" >&2 || true
	return 1
}

# /healthz is unauthenticated, which is why it is the probe; the API behind it
# is not.
wait_for_http openobserve "${FROGUE_DEV_OBSERVE_URL}/healthz" 60

# Generous: the watcher compiles the whole workspace before it can serve
# anything, and that is minutes from cold.
wait_for_http frogue "http://127.0.0.1:6565/" 900

echo "dev-test: driving a request through the server"
curl --silent --output /dev/null "http://127.0.0.1:6565/"

# OpenObserve keeps no per-service index to list, so the assertion is a query
# against the stream instead. OTLP spans and log records land in the stream
# named after the org's default, and the emitting service is the `service_name`
# column rather than the stream name. The window is rebuilt on every attempt so
# that the query follows the data as the batch exporters deliver it.
search() {
	local signal=$1 sql=$2 now from body
	now=$(date +%s%6N)
	from=$((now - 3600000000))
	body=$(jq --null-input \
		--arg sql "$sql" \
		--argjson from "$from" \
		--argjson to "$now" \
		'{query: {sql: $sql, start_time: $from, end_time: $to, from: 0, size: 10}}')
	curl --silent \
		--header "Authorization: ${FROGUE_DEV_OBSERVE_AUTH}" \
		--header 'Content-Type: application/json' \
		--data "$body" \
		"${FROGUE_DEV_OBSERVE_URL}/api/default/_search?type=${signal}"
}

# The export is batched, with a five second schedule delay, so this is expected
# to take a few seconds rather than being immediate.
wait_for_signal() {
	local what=$1 signal=$2 sql=$3 check=$4 response=""
	local deadline=$((SECONDS + 60))
	while [ "$SECONDS" -lt "$deadline" ]; do
		response=$(search "$signal" "$sql" || true)
		if printf '%s' "$response" | jq --exit-status "$check" >/dev/null 2>&1; then
			echo "dev-test: openobserve has frogue's $what"
			return 0
		fi
		sleep 1
	done

	echo "dev-test: frogue's $what never reached openobserve; the search returned: $response" >&2
	echo "dev-test: last lines of $console:" >&2
	tail --lines=20 "$console" >&2 || true
	return 1
}

wait_for_signal traces traces \
	'SELECT service_name, count() AS spans FROM "default" GROUP BY service_name' \
	'any(.hits[]; .service_name == "frogue")'

wait_for_signal logs logs \
	'SELECT count(*) AS records FROM "default"' \
	'.hits[0].records > 0'

# Auto-reload is the point of the watcher, so it is asserted rather than
# assumed: a change under either watched directory has to end in the server
# being restarted. The files are only touched, never modified -- only the mtime
# matters, so this leaves no trace in the working copy -- and the restart is
# seen through cargo, which prints its build line on every run. Watching that
# rather than the server's own startup line keeps the assertion independent of
# RUST_LOG, which the environment deliberately leaves to the caller.
expect_restart() {
	local path=$1
	# The dots stand in for the backticks cargo puts around `dev`: spelling
	# them out would trip shellcheck's "expressions don't expand in single
	# quotes" warning.
	local pattern='Finished .dev. profile'
	local before
	before=$(grep --extended-regexp --count "$pattern" "$console" || true)
	touch "$path"

	local deadline=$((SECONDS + 300))
	while [ "$SECONDS" -lt "$deadline" ]; do
		if [ "$(grep --extended-regexp --count "$pattern" "$console" || true)" -gt "$before" ]; then
			echo "dev-test: touching $path restarted the server"
			return 0
		fi
		sleep 1
	done

	echo "dev-test: touching $path did not restart the server" >&2
	echo "dev-test: last lines of $console:" >&2
	tail --lines=20 "$console" >&2 || true
	return 1
}

expect_restart frogue/src/main.rs
expect_restart frogue/src/lib.rs

wait_for_http "frogue after reloading" "http://127.0.0.1:6565/" 120

cleanup
trap - EXIT

# A leak here would be invisible in the happy path but would break the next
# run, so it is part of the test.
for _ in $(seq 1 20); do
	busy=""
	for port in 6565 5080; do
		if port_open "$port"; then
			busy="$busy $port"
		fi
	done
	[ -z "$busy" ] && break
	sleep 0.5
done
if [ -n "$busy" ]; then
	echo "dev-test: ports still bound after shutdown:$busy" >&2
	exit 1
fi

echo "dev-test: OK, traces and logs flow to openobserve and the stack shuts down cleanly"
