#!/usr/bin/env bash
set -Eeuo pipefail

ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNTIME_ROOT="${Q4_BOT_RESET_RUNTIME_ROOT:-${ROOT}/runtime/native}"
OUT_ROOT="${ROOT}/runtime/native-bot-reset-smoke"
PORT="${Q4_BOT_RESET_PORT:-27671}"
TIMEOUT_SECONDS="${Q4_BOT_RESET_TIMEOUT:-120}"
EXIT_TIMEOUT_SECONDS="${Q4_BOT_RESET_EXIT_TIMEOUT:-20}"
SUSTAIN_SECONDS="${Q4_BOT_RESET_SUSTAIN_SECONDS:-5}"
INITIAL_BOTS=8
BOT_RESOURCE_POOL_CAPACITY=32
ROTATION_MAPS=(
    mp/q4dm3
    mp/q4dm11v1
    mp/q4web_arena
    mp/q4dm3
    mp/q4web_arena
)

SERVER_RAW="${OUT_ROOT}/server.raw.log"
SERVER_LOG="${OUT_ROOT}/server.log"

die() {
    echo "bot reset smoke: $*" >&2
    exit 1
}

require_integer() {
    local name="$1"
    local value="$2"
    local minimum="$3"
    local maximum="$4"

    [[ "${value}" =~ ^[0-9]+$ ]] || die "${name} must be an integer"
    (( value >= minimum && value <= maximum )) \
        || die "${name} must be between ${minimum} and ${maximum}"
}

require_integer Q4_BOT_RESET_PORT "${PORT}" 1 65535
require_integer Q4_BOT_RESET_TIMEOUT "${TIMEOUT_SECONDS}" 30 1800
require_integer Q4_BOT_RESET_EXIT_TIMEOUT "${EXIT_TIMEOUT_SECONDS}" 5 120
require_integer Q4_BOT_RESET_SUSTAIN_SECONDS "${SUSTAIN_SECONDS}" 2 120

BOT_LIFETIMES=$(( INITIAL_BOTS * (1 + ${#ROTATION_MAPS[@]}) ))
(( BOT_LIFETIMES > BOT_RESOURCE_POOL_CAPACITY )) \
    || die "rotation plan must exceed the ${BOT_RESOURCE_POOL_CAPACITY}-handle bot resource pool"

if (( PORT == 28004 || ( PORT >= 28040 && PORT <= 28047 ) )); then
    die "UDP port ${PORT} is reserved for the production server or replay pool"
fi

for command in awk flock grep pgrep ps script setsid sha256sum ss tail tr wc; do
    command -v "${command}" >/dev/null 2>&1 \
        || die "required command is missing: ${command}"
done

[[ -x "${RUNTIME_ROOT}/openQ4-ded_x64" ]] \
    || die "native dedicated binary is missing; run tools/build-native.sh first"
[[ -f "${RUNTIME_ROOT}/q4base/pak001.pk4" ]] \
    || die "retail q4base data is not visible from the native runtime"
[[ -f "${RUNTIME_ROOT}/build-info/SHA256SUMS" ]] \
    || die "native build checksums are missing"
compgen -G "${RUNTIME_ROOT}/baseoq4/q4web_mp_q4web_arena-*.pk4" >/dev/null \
    || die "the staged q4web arena addon is missing"

(
    cd -- "${RUNTIME_ROOT}"
    sha256sum -c build-info/SHA256SUMS >/dev/null
) || die "native build checksum verification failed"

mkdir -p -- "${ROOT}/runtime"
exec 9>"${ROOT}/runtime/.native-bot-reset-smoke.lock"
flock -n 9 || die "another native bot reset smoke is already running"

if ss -H -lun "sport = :${PORT}" | grep -q .; then
    die "UDP port ${PORT} is already in use"
fi

rm -rf -- "${OUT_ROOT}"
mkdir -p -- "${OUT_ROOT}/server-home/baseoq4"
printf '%s\n' \
    'set net_ip 127.0.0.1' \
    "set net_port ${PORT}" \
    'set net_LANServer 1' \
    'set si_pure 0' \
    'set net_serverAllowDataChecksumMismatch 1' \
    'set net_serverDemo 0' \
    'set s_noSound 1' \
    >"${OUT_ROOT}/server-home/baseoq4/autoexec.cfg"
mkfifo -- "${OUT_ROOT}/server.in"
exec 3<>"${OUT_ROOT}/server.in"

server_pid=""

collect_tree() {
    local pid="$1"
    local child=""

    while read -r child; do
        [[ -n "${child}" ]] && collect_tree "${child}"
    done < <(pgrep -P "${pid}" 2>/dev/null || true)
    printf '%s\n' "${pid}"
}

stop_tree() {
    local pid="$1"
    local -a pids=()

    [[ -n "${pid}" ]] || return 0
    mapfile -t pids < <(collect_tree "${pid}")
    kill -TERM -- "-${pid}" 2>/dev/null || true
    kill -TERM -- "${pids[@]}" 2>/dev/null || true
    for (( i = 0; i < 20; ++i )); do
        local alive=0
        local member=""
        for member in "${pids[@]}"; do
            kill -0 "${member}" 2>/dev/null && alive=1
        done
        (( alive == 0 )) && return 0
        sleep 0.1
    done
    kill -KILL -- "-${pid}" 2>/dev/null || true
    kill -KILL -- "${pids[@]}" 2>/dev/null || true
}

cleanup() {
    set +e
    stop_tree "${server_pid}"
    [[ -z "${server_pid}" ]] || wait "${server_pid}" 2>/dev/null || true
    exec 3>&-
    rm -f -- "${OUT_ROOT}/server.in"
}
trap 'exit 130' INT
trap 'exit 143' TERM
trap cleanup EXIT

process_is_running() {
    local pid="$1"

    kill -0 "${pid}" 2>/dev/null \
        && ! grep -Eq 'COMMAND_EXIT_CODE="[0-9]+"' "${SERVER_RAW}" 2>/dev/null
}

wait_for_log() {
    local pattern="$1"
    local started=${SECONDS}

    until grep -Fq -- "${pattern}" "${SERVER_RAW}" 2>/dev/null; do
        process_is_running "${server_pid}" \
            || die "dedicated server exited while waiting for: ${pattern}"
        (( SECONDS - started < TIMEOUT_SECONDS )) \
            || die "timed out waiting for: ${pattern}"
        sleep 0.25
    done
}

wait_for_log_after() {
    local offset="$1"
    local pattern="$2"
    local started=${SECONDS}

    until tail -c "+$(( offset + 1 ))" "${SERVER_RAW}" 2>/dev/null | grep -Fq -- "${pattern}"; do
        process_is_running "${server_pid}" \
            || die "dedicated server exited while waiting for a new log marker: ${pattern}"
        (( SECONDS - started < TIMEOUT_SECONDS )) \
            || die "timed out waiting for a new log marker: ${pattern}"
        sleep 0.25
    done
}

wait_for_exact_line() {
    local expected="$1"
    local started=${SECONDS}

    until [[ -r "${SERVER_RAW}" ]] \
        && awk -v expected="${expected}" '
            { sub(/\r$/, ""); sub(/[[:space:]]+$/, "", $0) }
            $0 == expected { found = 1 }
            END { exit !found }
        ' "${SERVER_RAW}"; do
        process_is_running "${server_pid}" \
            || die "dedicated server exited while waiting for marker: ${expected}"
        (( SECONDS - started < TIMEOUT_SECONDS )) \
            || die "timed out waiting for marker: ${expected}"
        sleep 0.25
    done
}

capture_server_info() {
    local tag="$1"
    local destination="$2"
    local begin="Q4_BOT_RESET_${tag}_BEGIN"
    local end="Q4_BOT_RESET_${tag}_END"
    local clean="${OUT_ROOT}/.server-current.log"

    printf 'echo %s\nserverInfo\nbotStatus\necho %s\n' "${begin}" "${end}" >&3
    wait_for_exact_line "${end}"
    tr -d '\r' <"${SERVER_RAW}" >"${clean}"
    awk -v begin="${begin}" -v end="${end}" '
        { sub(/[[:space:]]+$/, "", $0) }
        $0 == begin { capture = 1; next }
        $0 == end && capture { complete = 1; exit }
        capture { print }
        END { if (!complete) exit 2 }
    ' "${clean}" >"${destination}" \
        || die "could not extract marked serverInfo block ${tag}"
}

bot_count() {
    awk '/^client +[0-9]+: major_*, ping =/ { count++ } END { print count + 0 }' "$1"
}

client_count() {
    awk '/^client +[0-9]+:/ { count++ } END { print count + 0 }' "$1"
}

population_matches() {
    local evidence="$1"
    local expected="$2"
    local expected_map="${3:-}"

    [[ "$(bot_count "${evidence}")" -eq "${expected}" ]] || return 1
    [[ "$(client_count "${evidence}")" -eq "${expected}" ]] || return 1
    grep -Fxq -- "botStatus slots=${expected} entities=${expected} active=${expected}" "${evidence}" || return 1
    grep -Fxq -- "bot_minplayers = ${expected}" "${evidence}" || return 1
    grep -Fxq 'si_maxPlayers = 12' "${evidence}" || return 1
    [[ -z "${expected_map}" ]] \
        || grep -Fxq -- "si_map = ${expected_map}" "${evidence}"
}

assert_population() {
    local evidence="$1"
    local expected="$2"
    local expected_map="${3:-}"
    local bots=""
    local clients=""
    local status_line=""

    bots="$(bot_count "${evidence}")"
    clients="$(client_count "${evidence}")"
    status_line="$(awk '/^botStatus slots=/ { status = $0 } END { print status }' "${evidence}")"
    if ! population_matches "${evidence}" "${expected}" "${expected_map}"; then
        cat -- "${evidence}" >&2
        die "population mismatch in $(basename -- "${evidence}"): map=${expected_map:-any}, bots=${bots}/${expected}, clients=${clients}/${expected}, status='${status_line}'"
    fi
    printf '%s: bots=%s clients=%s %s\n' \
        "$(basename -- "${evidence}")" "${bots}" "${clients}" "${status_line}"
}

wait_for_population() {
    local label="$1"
    local destination="$2"
    local expected="$3"
    local expected_map="${4:-}"
    local started=${SECONDS}
    local attempt=0
    local probe="${OUT_ROOT}/.${label}.probe.serverinfo"

    while (( SECONDS - started < TIMEOUT_SECONDS )); do
        attempt=$(( attempt + 1 ))
        capture_server_info "${label}_${attempt}" "${probe}"
        if population_matches "${probe}" "${expected}" "${expected_map}"; then
            mv -- "${probe}" "${destination}"
            assert_population "${destination}" "${expected}" "${expected_map}"
            return 0
        fi
        sleep 1
    done
    [[ ! -f "${probe}" ]] || cat -- "${probe}" >&2
    die "timed out waiting for ${expected} bot clients on ${expected_map:-the active map}"
}

rotate_to_map() {
    local rotation="$1"
    local map="$2"
    local evidence="${OUT_ROOT}/bots-rotation-${rotation}.serverinfo"
    local log_offset=""

    log_offset="$(wc -c <"${SERVER_RAW}")"
    printf 'set bot_minplayers %s\nset si_gameType DM\nset si_map %s\nspawnServer %s\n' \
        "${INITIAL_BOTS}" "${map}" "${map}" >&3
    wait_for_log_after "${log_offset}" "msec to load ${map}"
    wait_for_population "ROTATION_${rotation}" "${evidence}" "${INITIAL_BOTS}" "${map}"
    process_is_running "${server_pid}" \
        || die "dedicated server exited after bot rotation ${rotation} to ${map}"
    assert_log_clean
}

CRASH_PATTERN='Fatal Error|fatal signal|segmentation fault|core dumped|free\(\): invalid pointer|double free|terminate called|assertion .*failed|reliable messages overflow|snapshot .* contains too many|couldn.t apply snapshot'

assert_network_isolated() {
    local unexpected=""

    grep -Fq -- "Server spawned on port ${PORT}." "${SERVER_RAW}" \
        || die "dedicated server never confirmed the isolated UDP port ${PORT}"
    unexpected="$(tr -d '\r' <"${SERVER_RAW}" | awk -v expected="${PORT}" '
        /Opening IPv[46] UDP socket:/ || /Server spawned on port [0-9]+\./ {
            if ( $0 !~ (":" expected "$") && $0 !~ ("port " expected "\\.$") ) {
                print
            }
        }
    ')"
    if [[ -n "${unexpected}" ]]; then
        printf '%s\n' "${unexpected}" >&2
        die "dedicated server escaped the isolated UDP port ${PORT}"
    fi
}

assert_log_clean() {
    assert_network_isolated
    if grep -Eiq "${CRASH_PATTERN}|Address already in use|IPSocketForFamily:.*bind|COMMAND_EXIT_CODE=\"[1-9][0-9]*\"" "${SERVER_RAW}"; then
        grep -Ein "${CRASH_PATTERN}|Address already in use|IPSocketForFamily:.*bind|COMMAND_EXIT_CODE=\"[1-9][0-9]*\"" "${SERVER_RAW}" | tail -n 20 >&2 || true
        die "runtime failure signature in ${SERVER_RAW}"
    fi
}

wait_for_clean_exit() {
    local started=${SECONDS}
    local state=""
    local status=0

    while kill -0 "${server_pid}" 2>/dev/null; do
        state="$(ps -o stat= -p "${server_pid}" 2>/dev/null || true)"
        [[ "${state}" == Z* ]] && break
        (( SECONDS - started < EXIT_TIMEOUT_SECONDS )) \
            || die "dedicated server did not exit within ${EXIT_TIMEOUT_SECONDS}s"
        sleep 0.25
    done
    if wait "${server_pid}"; then
        return 0
    else
        status=$?
        die "dedicated server exited with status ${status}"
    fi
}

server_command=(
    "${RUNTIME_ROOT}/openQ4-ded_x64"
    +set fs_basepath "${RUNTIME_ROOT}"
    +set fs_savepath "${OUT_ROOT}/server-home"
    +set fs_caseSensitiveOS 0
    +set s_noSound 1
    +set net_ip 127.0.0.1
    +set net_port "${PORT}"
    +set net_LANServer 1
    +set si_pure 0
    +set net_serverAllowDataChecksumMismatch 1
    +set net_serverDemo 0
    +set si_name Q4BotMinplayersResetGate
    +set si_maxPlayers 12
    +set si_warmup 0
    +set bot_debug 0
    +set bot_minplayers "${INITIAL_BOTS}"
    +set si_map mp/q4web_arena
    +set si_gameType DM
    +spawnServer
)
printf -v server_command_line '%q ' "${server_command[@]}"
TERM=xterm setsid script -qefc "${server_command_line}" "${SERVER_RAW}" <&3 >/dev/null 2>&1 &
server_pid=$!

wait_for_log "Server spawned on port ${PORT}."
wait_for_log 'loading maps/mp/q4web_arena.aas32'
wait_for_log 'msec to load mp/q4web_arena'
wait_for_population BOTS_READY "${OUT_ROOT}/bots-initial.serverinfo" "${INITIAL_BOTS}" mp/q4web_arena

rotation=0
for map in "${ROTATION_MAPS[@]}"; do
    rotation=$(( rotation + 1 ))
    rotate_to_map "${rotation}" "${map}"
done

printf 'set bot_minplayers 0\n' >&3
wait_for_population BOTS_REMOVED "${OUT_ROOT}/bots-reset.serverinfo" 0 "${ROTATION_MAPS[-1]}"

sleep "${SUSTAIN_SECONDS}"
process_is_running "${server_pid}" \
    || die "dedicated server exited during the zero-bot sustain interval"
capture_server_info ZERO_SUSTAINED "${OUT_ROOT}/bots-zero-sustained.serverinfo"
assert_population "${OUT_ROOT}/bots-zero-sustained.serverinfo" 0 "${ROTATION_MAPS[-1]}"
assert_log_clean

printf 'quit\n' >&3
wait_for_clean_exit
server_pid=""
tr -d '\r' <"${SERVER_RAW}" >"${SERVER_LOG}"
assert_log_clean

if ss -H -lun "sport = :${PORT}" | grep -q .; then
    die "UDP port ${PORT} remained in use after shutdown"
fi

trap - EXIT
cleanup

echo "Native bot lifecycle smoke passed: ${#ROTATION_MAPS[@]} map rotations reused the ${BOT_RESOURCE_POOL_CAPACITY}-handle pools across ${BOT_LIFETIMES} bot lifetimes, then bot_minplayers 0 stayed empty."
echo "Evidence directory: ${OUT_ROOT}"
printf 'Population evidence: %s\n' \
    "${OUT_ROOT}/bots-initial.serverinfo" \
    "${OUT_ROOT}"/bots-rotation-*.serverinfo \
    "${OUT_ROOT}/bots-reset.serverinfo" \
    "${OUT_ROOT}/bots-zero-sustained.serverinfo"
