package StdlibIngameTests
import DamageEvent
import ClosureTimers
import ClosureForGroups
import TimerUtils
import UnitIds
import Execute
import ErrorHandling
import UnitSpatialIndex
import SpatialIndexForUnits
import SparseSet
import UnitIndexer
import OnUnitEnterLeave

/*	Manual, in-game checks for behaviour the compiletime interpreter cannot
	reach: real damage events, real timers and real unit enumeration.

	This package is deliberately NOT a
	@Test suite - `grill test` runs in the interpreter, where none of these
	events exist.

	HOW TO RUN
	==========
	Importing the package is enough:

	>	import StdlibIngameTests

	The suite starts one second into the game and prints its results. Type
	-stdlibtest to run it again, or call runStdlibIngameTests() yourself from a
	timer callback - not straight from init, since it creates units.

	Results print as PASS / FAIL / INFO lines, followed by a summary. The whole
	run takes about three seconds because the TimedLoop checks wait for ticks.

	Each check runs inside try(), so one crashing check reports CRASH and the
	rest still run.

	WHAT QUESTION THIS ANSWERS
	==========================
	DamageEvent assumes that a DAMAGING which ends with amount <= 0 is never
	followed by a DAMAGED, and closes the damage instance out early on that
	basis.

	Check 1 *observes* whether DAMAGED fires and prints the answer as an INFO
	line - it cannot fail, because either answer is a legitimate engine
	behaviour.

	Check 10 is the one that decides whether the assumption is safe. Top level
	hits are harmless either way, since the early close leaves nothing behind
	for onDamage to consume. Only a nested zeroed hit can consume the enclosing
	instance, so check 10 is the one that must pass.
*/

// ============================================================================
// Reporting
// ============================================================================

var checksRun = 0
var checksFailed = 0

function check(bool passed, string label)
	checksRun++
	if passed
		print("|cff44ff44PASS|r  " + label)
	else
		checksFailed++
		print("|cffff4444FAIL|r  " + label)

function note(string label)
	print("|cffffcc00INFO|r  " + label)

function section(string label)
	print("|cff8888ff" + label + "|r")

/**	Runs one test in an isolated thread. Without this a single crashing check
	kills the whole run, and everything after it silently never happens. */
function runGuarded(string label, ForForceCallback cb)
	if not try(cb)
		checksRun++
		checksFailed++
		print("|cffff4444CRASH|r " + label + " -> " + (lastError == "" ? "thread crashed" : lastError))

// ============================================================================
// Test units
// ============================================================================

constant TEST_POS = vec2(0, 0)
constant GROUP_TEST_POS = vec2(2000, 2000)

unit attacker = null
unit victim = null
/** Target for nested damage, so check 10 never re-enters the same widget. */
unit victim2 = null

function setupUnits()
	attacker = createUnit(players[0], UnitIds.footman, TEST_POS, angle(0))
	victim = createUnit(players[0], UnitIds.footman, TEST_POS.add(150., 0.), angle(0))
	victim..setMaxHP(1000000)..setHP(1000000.)
	victim2 = createUnit(players[0], UnitIds.footman, TEST_POS.add(300., 0.), angle(0))
	victim2..setMaxHP(1000000)..setHP(1000000.)

function teardownUnits()
	// null tolerant: setupUnits may itself have been the thing that crashed
	if attacker != null
		attacker.remove()
		attacker = null
	if victim != null
		victim.remove()
		victim = null
	if victim2 != null
		victim2.remove()
		victim2 = null

/**	Heals the victim, then deals `amount` as CODE damage.
	Ordinary physical damage rather than the DAMAGE_TYPE_UNIVERSAL default of
	the short damageTarget overload, so armour applies and the reduced amount
	really does differ from the unreduced one. */
function dealDamage(real amount)
	victim.setHP(victim.getMaxHP())
	DamageEvent.setNextDamageFromCode()
	attacker.damageTarget(victim, amount, false, false, ATTACK_TYPE_CHAOS, DAMAGE_TYPE_NORMAL,
		WEAPON_TYPE_WHOKNOWS)

// ============================================================================
// 1. Does DAMAGED fire after a DAMAGING that ended at zero?
// ============================================================================

var unreducedFired = false
var reducedFired = false
var observedAmount = 0.

function testZeroedDamaging()
	section("--- 1. DAMAGED after a zeroed DAMAGING (the load bearing one) ---")
	unreducedFired = false
	reducedFired = false

	let ul = DamageEvent.addUnreducedListener() ->
		unreducedFired = true
		DamageEvent.setAmount(0.)

	let rl = DamageEvent.addListener() ->
		reducedFired = true

	dealDamage(50.)

	check(unreducedFired, "unreduced listener fired")
	if reducedFired
		note("DAMAGED *DOES* fire after a zeroed DAMAGING.")
		note("  The early close in DamageEvent.onUnreducedDamage is wrong for")
		note("  this game version and must be replaced. Report this result.")
	else
		note("DAMAGED does NOT fire after a zeroed DAMAGING - assumption holds.")
		note("  The early close in DamageEvent.onUnreducedDamage is correct.")

	destroy ul
	destroy rl

// ============================================================================
// 2. A zeroed hit must not poison later damage instances
// ============================================================================

function testZeroDoesNotPoisonNextInstance()
	section("--- 2. a zeroed hit must not poison later damage ---")

	let ul = DamageEvent.addUnreducedListener() ->
		DamageEvent.setAmount(0.)
	dealDamage(50.)
	destroy ul

	reducedFired = false
	observedAmount = 0.
	let rl = DamageEvent.addListener() ->
		reducedFired = true
		observedAmount = DamageEvent.getAmount()
	dealDamage(40.)

	check(reducedFired, "later damage still reaches reduced listeners")
	check(observedAmount > 0., "later damage is not nulled (amount " + observedAmount.toString(1) + ")")
	check(not DamageEvent.isFiring(), "no damage instance left on the stack")

	destroy rl

// ============================================================================
// 3. abortCurrent() in an unreduced listener must not poison later damage
// ============================================================================

function testAbortInUnreducedListener()
	section("--- 3. abortCurrent() in an unreduced listener ---")

	let ul = DamageEvent.addUnreducedListener() ->
		DamageEvent.abortCurrent()
	dealDamage(50.)
	destroy ul

	reducedFired = false
	observedAmount = 0.
	let rl = DamageEvent.addListener() ->
		reducedFired = true
		observedAmount = DamageEvent.getAmount()
	dealDamage(40.)

	check(reducedFired, "damage after an abort still fires listeners")
	check(observedAmount > 0., "damage after an abort is not nulled (amount " + observedAmount.toString(1) + ")")
	check(not DamageEvent.isFiring(), "no damage instance left on the stack")

	destroy rl

// ============================================================================
// 4. Same priority listeners fire in registration order (FIFO)
// ============================================================================

var order = ""

function testListenerOrder()
	section("--- 4. same priority listeners fire FIFO ---")
	order = ""

	let a = DamageEvent.addListener(3) ->
		order += "A"
	let b = DamageEvent.addListener(3) ->
		order += "B"
	let c = DamageEvent.addListener(3) ->
		order += "C"

	dealDamage(30.)

	check(order == "ABC", "registration order preserved (expected ABC, got " + order + ")")

	destroy a
	destroy b
	destroy c

// ============================================================================
// 5. A listener that destroys the NEXT listener mid-event
// ============================================================================

DamageListener victimListener = null
var firedFirst = false
var firedVictim = false
var firedThird = false

function testDestroyNextListener()
	section("--- 5. a listener destroying the NEXT listener mid-event ---")
	firedFirst = false
	firedVictim = false
	firedThird = false

	let first = DamageEvent.addListener(4) ->
		firedFirst = true
		if victimListener != null
			destroy victimListener
			victimListener = null

	victimListener = DamageEvent.addListener(4) ->
		firedVictim = true

	let third = DamageEvent.addListener(4) ->
		firedThird = true

	dealDamage(30.)

	check(firedFirst, "first listener fired")
	check(not firedVictim, "the destroyed listener did not fire")
	check(firedThird, "traversal continued past the destroyed listener")

	destroy first
	destroy third
	if victimListener != null
		destroy victimListener
		victimListener = null

// ============================================================================
// 6. A listener that destroys itself mid-event
// ============================================================================

DamageListener selfDestroyer = null
var firedSelf = false
var firedAfterSelf = false

function testSelfDestroyingListener()
	section("--- 6. a listener destroying itself mid-event ---")
	firedSelf = false
	firedAfterSelf = false

	selfDestroyer = DamageEvent.addListener(5) ->
		firedSelf = true
		if selfDestroyer != null
			destroy selfDestroyer
			selfDestroyer = null

	let after = DamageEvent.addListener(5) ->
		firedAfterSelf = true

	dealDamage(30.)

	check(firedSelf, "self destroying listener fired")
	check(firedAfterSelf, "traversal continued after it destroyed itself")

	destroy after
	if selfDestroyer != null
		destroy selfDestroyer
		selfDestroyer = null

// ============================================================================
// 10. A zeroed NESTED hit must not consume the enclosing damage instance
// ============================================================================

/*	The check that decides whether a zeroed hit may be closed out early.
	Checks 1-3 are all top level hits, where an early close leaves
	DamageInstance.current == null and onDamage bails out safely whichever way
	the engine behaves - so they cannot tell the two worlds apart.

	Nesting can:

	  early close is safe   - no DAMAGED for the zeroed inner hit.
	  early close corrupts  - DAMAGED fires for the inner hit, finds the
	                          *enclosing* instance as current, and consumes it
	                          with the inner event's damage of 0. The outer hit
	                          then reports 0 to its own reduced listeners.

	Measured in game: DAMAGED *does* fire for a zeroed hit, so there is no
	early close any more and the outer hit must keep its own amount.

	Listeners filter on the target unit rather than call order, so the result
	does not depend on which DAMAGED arrives first. */

var damageDepth = 0
var innerZeroed = false
var outerReducedFired = false
var outerAmount = 0.
var innerReducedFired = false
var reducedCalls = 0

function testNestedZeroedDamage()
	section("--- 10. a zeroed NESTED hit must not consume the outer instance ---")
	damageDepth = 0
	innerZeroed = false
	outerReducedFired = false
	outerAmount = 0.
	innerReducedFired = false
	reducedCalls = 0

	let ul = DamageEvent.addUnreducedListener() ->
		damageDepth++
		if damageDepth == 1
			// outer hit: deal a nested hit from inside this listener
			DamageEvent.setNextDamageFromCode()
			attacker.damageTarget(victim2, 20., false, false, ATTACK_TYPE_CHAOS, DAMAGE_TYPE_NORMAL,
				WEAPON_TYPE_WHOKNOWS)
		else
			// inner hit: zero it
			innerZeroed = true
			DamageEvent.setAmount(0.)
		damageDepth--

	let rl = DamageEvent.addListener() ->
		reducedCalls++
		if DamageEvent.getTarget() == victim
			outerReducedFired = true
			outerAmount = DamageEvent.getAmount()
		else if DamageEvent.getTarget() == victim2
			innerReducedFired = true

	dealDamage(60.)

	check(innerZeroed, "the nested hit reached the unreduced listeners")
	check(outerReducedFired, "the outer hit reached its own reduced listeners")
	check(outerAmount > 1., "the outer hit kept its own amount (" + outerAmount.toString(1) + ")")
	check(not DamageEvent.isFiring(), "no damage instance left on the stack")

	note("DAMAGED fired " + reducedCalls + " time(s); zeroed nested hit "
		+ (innerReducedFired ? "DID" : "did NOT") + " reach reduced listeners")

	destroy ul
	destroy rl

// ============================================================================
// 11. A late default listener must remain at the default priority
// ============================================================================

var lateDefaultReducedAmount = -1.

function testLateDefaultReducedListenerPriority()
	section("--- 11. late default reduced listener stays before priority 100 ---")
	lateDefaultReducedAmount = -1.

	// Register the observer first: production systems commonly initialize a
	// final observer before gameplay installs short-lived modifiers.
	let observer = DamageEvent.addListener(100) ->
		lateDefaultReducedAmount = DamageEvent.getAmount()
	let modifier = DamageEvent.addListener() ->
		// Expected: no-priority means priority 0, so this changes 100 to 40
		// before the already-registered priority-100 observer runs.
		DamageEvent.setAmount(40.)

	DamageEvent.setNextDamageFromCode()
	attacker.damageTarget(victim, 100.)

	check(lateDefaultReducedAmount == 40., "late default reduced listener: expected 40.0, actual "
		+ lateDefaultReducedAmount.toString(1))

	destroy observer
	destroy modifier

// ============================================================================
// 12. The same default-priority contract applies before native reduction
// ============================================================================

var lateDefaultUnreducedAmount = -1.

function testLateDefaultUnreducedListenerPriority()
	section("--- 12. late default unreduced listener stays before priority 100 ---")
	lateDefaultUnreducedAmount = -1.

	let observer = DamageEvent.addUnreducedListener(100) ->
		lateDefaultUnreducedAmount = DamageEvent.getAmount()
	let modifier = DamageEvent.addUnreducedListener() ->
		// Expected: the default-priority modifier runs first and the explicit
		// priority-100 observer reads its live value of 40 rather than 100.
		DamageEvent.setAmount(40.)

	DamageEvent.setNextDamageFromCode()
	attacker.damageTarget(victim, 100.)

	check(lateDefaultUnreducedAmount == 40., "late default unreduced listener: expected 40.0, actual "
		+ lateDefaultUnreducedAmount.toString(1))

	destroy observer
	destroy modifier

// ============================================================================
// 7. Nested group enumeration
// ============================================================================

var outerCount = 0
var innerCalls = 0

function testGroupNesting()
	section("--- 7. a group enumeration nested inside another ---")

	unit array probes
	for i = 0 to 3
		probes[i] = createUnit(players[0], UnitIds.footman, GROUP_TEST_POS.add(i * 64., 0.), angle(0))

	outerCount = 0
	innerCalls = 0

	// the collision filtering path and forNearestUnit both used to iterate the
	// engine wide ENUM_GROUP, so the inner call clobbered the outer one
	forUnitsInRange(GROUP_TEST_POS.add(96., 0.), 400., true) (unit u) ->
		outerCount++
		forNearestUnit(u.getPos(), 300., null) (unit nearest) ->
			if nearest != null
				innerCalls++

	check(outerCount >= 4, "outer enumeration visited every unit (" + outerCount + ")")
	check(innerCalls == outerCount, "nested enumeration ran exactly once per outer unit ("
		+ innerCalls + " vs " + outerCount + ")")

	for i = 0 to 3
		probes[i].remove()

// ============================================================================
// 13 + 14. UnitSpatialIndex
// ============================================================================

/*	These are differential tests: they never assert a hand-written expected set, they assert that the
	closure result equals what the engine enumeration returns for the same query. That is the only
	claim that matters for membership parity, and it means the awkward
	cases (hidden, locust, corpses, boundary distances) need no engine behaviour to be hardcoded.

	These checks are Lua-index checks only. On Jass or with the index disabled, the native-less API
	returns an empty SparseSet by contract, so there is no meaningful parity assertion to run. */

constant SPATIAL_TEST_POS = vec2(-2000, 2000)
unit spatialInitProbe = null
var creatingSpatialInitProbe = false
var spatialInitProbeEnterEvents = 0

function countSpatialInitProbeEnter()
	if creatingSpatialInitProbe or getEnterLeaveUnit() == spatialInitProbe
		spatialInitProbeEnterEvents++

function collectViaSparseSet(vec2 pos, real radius, bool collisionFiltering, group into)
	let matches = unitsInRange(pos, radius, collisionFiltering)
	for i = 0 to matches.size() - 1
		into.add(matches.get(i))
	destroy matches

function collectNative(vec2 pos, real radius, bool collisionFiltering, group into)
	if collisionFiltering
		let raw = CreateGroup()
		GroupEnumUnitsInRange(raw, pos.x, pos.y, radius + MAX_COLLISION_SIZE, null)
		for u from raw
			if IsUnitInRangeXY(u, pos.x, pos.y, radius)
				into.add(u)
		raw.destr()
	else
		GroupEnumUnitsInRange(into, pos.x, pos.y, radius, null)

function describeDifference(group actual, group expected) returns string
	var missing = 0
	var extra = 0
	for i = 0 to expected.size() - 1
		if not actual.has(expected.get(i))
			missing++
	for i = 0 to actual.size() - 1
		if not expected.has(actual.get(i))
			extra++
	return "engine=" + expected.size() + " closure=" + actual.size()
		+ " missed=" + missing + " spurious=" + extra

function checkRangeParity(vec2 pos, real radius, bool collisionFiltering, string label)
	let actual = CreateGroup()
	let expected = CreateGroup()
	collectViaSparseSet(pos, radius, collisionFiltering, actual)
	collectNative(pos, radius, collisionFiltering, expected)
	var equal = actual.size() == expected.size()
	if equal
		for i = 0 to actual.size() - 1
			if not expected.has(actual.get(i))
				equal = false
	check(equal, label + " (" + describeDifference(actual, expected) + ")")
	actual.destr()
	expected.destr()

function testSpatialIndexParity()
	section("--- 13. spatial index vs engine enumeration ---")
	if isLua and USE_UNIT_SPATIAL_INDEX
		note("indexed path active, tracking " + spatialIndexTrackedUnits()
			+ " units, staleness " + spatialIndexWorstStaleness()
			+ "s, padding " + spatialIndexPadCells() + " cell(s)")
	else
		note("index inactive (isLua=" + isLua + ", enabled=" + USE_UNIT_SPATIAL_INDEX + ")")
		return

	// A spread of probes straddling the query boundary, so the distance test is exercised on both
	// sides of it and inside the uncertain annulus rather than only well inside the radius.
	unit array probes
	for i = 0 to 5
		probes[i] = createUnit(players[0], UnitIds.footman,
			SPATIAL_TEST_POS.add(i * 120., 0.), angle(0))

	// Awkward population cases. No expected behaviour is hardcoded - whatever the engine does with
	// these is what the closure must also do.
	let hidden = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS.add(60., 0.), angle(0))
	hidden.hide()
	let locust = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS.add(180., 0.), angle(0))
	locust.addAbility(LOCUST_ID)
	let corpse = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS.add(240., 0.), angle(0))
	corpse.kill()

	// The synchronous bootstrap relies on the engine's player enum, specifically because it includes
	// states omitted by range/rect enums. Pin that engine distinction explicitly.
	let playerUnits = CreateGroup()
	GroupEnumUnitsOfPlayer(playerUnits, players[0], null)
	check(playerUnits.has(hidden), "player enumeration includes hidden units")
	check(playerUnits.has(locust), "player enumeration includes Locust units")
	playerUnits.destr()

	checkRangeParity(SPATIAL_TEST_POS, 300., false, "range query matches the engine")
	checkRangeParity(SPATIAL_TEST_POS, 300., true, "collision-filtered query matches the engine")
	checkRangeParity(SPATIAL_TEST_POS, 121., false, "tight radius on a probe boundary")
	checkRangeParity(SPATIAL_TEST_POS, 2000., false, "wide radius")

	// Manual UnitIndexer deindexing does not remove a live unit from native enumeration, so the
	// independent spatial registry must retain it until OnUnitEnterLeave observes a real leave.
	probes[0].deindex()
	checkRangeParity(SPATIAL_TEST_POS, 300., false, "live manually deindexed unit remains enumerable")
	probes[0].toUnitIndex()

	// This unit was created directly in this package's init, after UnitSpatialIndex initialized but
	// before the zero-timer synthetic seed. Synchronous enter tracking must have bucketed it already.
	checkRangeParity(spatialInitProbe.getPos(), 32., false, "init-time spawn is indexed immediately")
	check(spatialInitProbeEnterEvents == 1, "init-time spawn emits one enter event ("
		+ spatialInitProbeEnterEvents + ")")

	// Eligibility is evaluated at query time. Toggling Locust must take effect immediately in both
	// directions rather than waiting for a sweep or permanently dropping the unit from the index.
	probes[1].addAbility(LOCUST_ID)
	locust.removeAbility(LOCUST_ID)
	checkRangeParity(SPATIAL_TEST_POS, 300., false, "locust transitions match immediately")
	probes[1].removeAbility(LOCUST_ID)
	locust.addAbility(LOCUST_ID)

	// Rect parity, including the engine's documented +32 minimum-bound off-by-one.
	let r = Rect(SPATIAL_TEST_POS.x - 200., SPATIAL_TEST_POS.y - 200.,
		SPATIAL_TEST_POS.x + 200., SPATIAL_TEST_POS.y + 200.)
	let actualRect = CreateGroup()
	let expectedRect = CreateGroup()
	let rectMatches = unitsInBox(vec2(r.getMinX() + 32., r.getMinY() + 32.),
		vec2(r.getMaxX(), r.getMaxY()))
	for i = 0 to rectMatches.size() - 1
		actualRect.add(rectMatches.get(i))
	destroy rectMatches
	GroupEnumUnitsInRect(expectedRect, r, null)
	var rectEqual = actualRect.size() == expectedRect.size()
	if rectEqual
		for i = 0 to actualRect.size() - 1
			if not expectedRect.has(actualRect.get(i))
				rectEqual = false
	check(rectEqual, "rect query matches the engine ("
		+ describeDifference(actualRect, expectedRect) + ")")
	actualRect.destr()
	expectedRect.destr()
	r.remove()

	// A teleport is not observable, so the index only learns about it from the sweep or from an
	// explicit refresh. This pins the documented contract: after updateSpatialIndex() it agrees
	// immediately. (Ordinary movement needs no such call - the padding covers it.)
	probes[5].setPos(SPATIAL_TEST_POS)
	probes[5].updateSpatialIndex()
	checkRangeParity(SPATIAL_TEST_POS, 50., false, "teleport is visible after updateSpatialIndex")

	for i = 0 to 5
		probes[i].remove()
	hidden.remove()
	locust.remove()
	corpse.remove()

function testSpatialIndexLifecycle()
	section("--- 14. spatial index lifecycle bounds ---")
	if not isLua or not USE_UNIT_SPATIAL_INDEX
		note("indexed path inactive - lifecycle-specific checks skipped")
		return

	// Growing by two complete sweep budgets guarantees a larger cycle regardless of where the
	// pre-existing population sits relative to a ceil boundary.
	let batchSize = SPATIAL_INDEX_UNITS_PER_TICK * 2
	unit array lifecycleProbes
	let initialBound = spatialIndexWorstStaleness()
	for i = 0 to batchSize - 1
		lifecycleProbes[i] = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS, angle(0))
	let expandedBound = spatialIndexWorstStaleness()
	check(expandedBound > initialBound, "registry growth expands the staleness bound immediately")
	let allocatedAtPeak = spatialIndexAllocatedSlots()

	for i = 0 to batchSize - 1
		lifecycleProbes[i].remove()
		lifecycleProbes[i] = null
	check(spatialIndexWorstStaleness() >= expandedBound,
		"registry shrink retains the old bound until a stable sweep completes")

	// A second same-sized wave must consume the just-freed ids rather than extending every numeric
	// cache table with another session-long block of slots.
	for i = 0 to batchSize - 1
		lifecycleProbes[i] = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS, angle(0))
	check(spatialIndexAllocatedSlots() == allocatedAtPeak,
		"removed-unit cache slots are reused (" + spatialIndexAllocatedSlots() + " allocated)")
	for i = 0 to batchSize - 1
		lifecycleProbes[i].remove()
		lifecycleProbes[i] = null

var reentrancyVisited = 0
var reentrancyNested = 0
// Globals rather than locals: Wurst closures cannot capture local arrays, and these probes have to
// be reachable from inside the visitors that mutate them.
unit array reentrancyProbes
unit spawnedDuringQuery = null

function testSpatialIndexReentrancy()
	section("--- 15. spatial index reentrancy ---")
	if not isLua or not USE_UNIT_SPATIAL_INDEX
		note("indexed path inactive - snapshot-specific checks skipped")
		return

	for i = 0 to 3
		reentrancyProbes[i] = createUnit(players[0], UnitIds.footman,
			SPATIAL_TEST_POS.add(i * 64., 0.), angle(0))

	// A visitor that removes a *later* unit in the same batch. This is the case that breaks a naive
	// readout which caches the group size and indexes into it: groups silently drop removed units,
	// so every later index shifts down and a unit is skipped. A snapshot is immune.
	reentrancyVisited = 0
	let removalSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
	for i = 0 to removalSnapshot.size() - 1
		let u = removalSnapshot.get(i)
		reentrancyVisited++
		if reentrancyVisited == 1
			for j = 0 to 3
				if reentrancyProbes[j] != u
					reentrancyProbes[j].remove()
					reentrancyProbes[j] = null
					break
	check(reentrancyVisited >= 4, "removing a unit mid-iteration skips nobody ("
		+ reentrancyVisited + " visited)")
	destroy removalSnapshot

	for i = 0 to 3
		if reentrancyProbes[i] != null
			reentrancyProbes[i].remove()
			reentrancyProbes[i] = null
	for i = 0 to 3
		reentrancyProbes[i] = createUnit(players[0], UnitIds.footman,
			SPATIAL_TEST_POS.add(i * 64., 0.), angle(0))

	// A nested query inside a visitor, three deep, to exercise the snapshot stack: an inner query
	// pushes above the outer one and must not disturb it.
	reentrancyVisited = 0
	reentrancyNested = 0
	let outerSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
	for outerIndex = 0 to outerSnapshot.size() - 1
		let outerUnit = outerSnapshot.get(outerIndex)
		reentrancyVisited++
		let middleSnapshot = unitsInRange(outerUnit.getPos(), 300.)
		for middleIndex = 0 to middleSnapshot.size() - 1
			let middleUnit = middleSnapshot.get(middleIndex)
			reentrancyNested++
			let innerSnapshot = unitsInRange(middleUnit.getPos(), 100.)
			for innerIndex = 0 to innerSnapshot.size() - 1
				let innerUnit = innerSnapshot.get(innerIndex)
				if innerUnit == null
					reentrancyNested--
			destroy innerSnapshot
		destroy middleSnapshot
	destroy outerSnapshot
	check(reentrancyVisited == 4, "outer enumeration unaffected by nesting ("
		+ reentrancyVisited + ")")
	check(reentrancyNested > 0, "nested enumerations ran (" + reentrancyNested + ")")

	// A visitor that creates a unit inside the query radius: it must not be visited by the query
	// that is already running, and must not corrupt the iteration.
	reentrancyVisited = 0
	spawnedDuringQuery = null
	let creationSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
	for i = 0 to creationSnapshot.size() - 1
		let _u = creationSnapshot.get(i)
		reentrancyVisited++
		if spawnedDuringQuery == null
			spawnedDuringQuery = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS, angle(0))
	destroy creationSnapshot
	check(reentrancyVisited == 4, "creating a unit mid-iteration does not extend it ("
		+ reentrancyVisited + ")")
	if spawnedDuringQuery != null
		spawnedDuringQuery.remove()
		spawnedDuringQuery = null

	for i = 0 to 3
		reentrancyProbes[i].remove()

// ============================================================================
// 8 + 9. TimedLoop registration
// ============================================================================

class LoopProbe
	use TimedLoop
	var ticks = 0

	override function onTimedLoop()
		ticks++

function testTimedLoopStopBeforeStart()
	section("--- 8. TimedLoop: stop() before the first start() ---")
	let probe = new LoopProbe()
	// a defensive stop on an instance that was never started
	probe.stopTimedLoop()
	probe.startTimedLoop()

	doAfter(0.5) ->
		check(probe.ticks > 0, "onTimedLoop runs after stop-then-start (ticks " + probe.ticks + ")")
		probe.stopTimedLoopAndDestroy()

function testTimedLoopDoubleStart()
	section("--- 9. TimedLoop: repeated start() must not double register ---")
	let single = new LoopProbe()
	let tripled = new LoopProbe()
	single.startTimedLoop()
	tripled..startTimedLoop()..startTimedLoop()..startTimedLoop()

	doAfter(0.5) ->
		check(tripled.ticks == single.ticks, "three starts tick as often as one ("
			+ tripled.ticks + " vs " + single.ticks + ")")
		single.stopTimedLoopAndDestroy()
		tripled.stopTimedLoopAndDestroy()

// ============================================================================
// Runner
// ============================================================================

/**	Runs every in-game check. Call from a timer callback rather than straight
	from init, because it creates units. */
public function runStdlibIngameTests()
	checksRun = 0
	checksFailed = 0
	print("|cff8888ff=== Wurst stdlib in-game tests ===|r")

	runGuarded("setup") ->
		setupUnits()
	runGuarded("1. zeroed DAMAGING") ->
		testZeroedDamaging()
	runGuarded("2. zeroed hit poisoning") ->
		testZeroDoesNotPoisonNextInstance()
	runGuarded("3. abortCurrent poisoning") ->
		testAbortInUnreducedListener()
	runGuarded("4. listener order") ->
		testListenerOrder()
	runGuarded("5. destroy next listener") ->
		testDestroyNextListener()
	runGuarded("6. self destroying listener") ->
		testSelfDestroyingListener()
	runGuarded("10. zeroed nested hit") ->
		testNestedZeroedDamage()
	runGuarded("11. late default reduced listener") ->
		testLateDefaultReducedListenerPriority()
	runGuarded("12. late default unreduced listener") ->
		testLateDefaultUnreducedListenerPriority()
	runGuarded("teardown") ->
		teardownUnits()

	runGuarded("7. nested enumeration") ->
		testGroupNesting()

	runGuarded("13. spatial index parity") ->
		testSpatialIndexParity()
	runGuarded("14. spatial index lifecycle bounds") ->
		testSpatialIndexLifecycle()
	runGuarded("15. spatial index reentrancy") ->
		testSpatialIndexReentrancy()

	// these finish asynchronously, so the summary waits for them
	runGuarded("8. TimedLoop stop before start") ->
		testTimedLoopStopBeforeStart()
	runGuarded("9. TimedLoop double start") ->
		testTimedLoopDoubleStart()

	doAfter(1.5) ->
		print("|cff8888ff=== " + (checksRun - checksFailed) + "/" + checksRun
			+ " checks passed ===|r")
		if checksFailed > 0
			print("|cffff4444" + checksFailed + " check(s) FAILED|r")

/** Registers "-stdlibtest" as a chat command that runs the suite. */
public function registerStdlibIngameTestCommand()
	let trig = CreateTrigger()
	for i = 0 to bj_MAX_PLAYERS - 1
		trig.registerPlayerChatEvent(players[i], "-stdlibtest", true)
	trig.addAction(() -> runStdlibIngameTests())

init
	// Created during init on purpose: this exercises the window before the deferred preplaced pass.
	onEnter(function countSpatialInitProbeEnter)
	creatingSpatialInitProbe = true
	spatialInitProbe = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS.add(-400., 0.), angle(0))
	creatingSpatialInitProbe = false
	// Importing the package is enough to run the suite. The delay lets the map
	// and the stdlib's own init blocks finish before units are created.
	doAfter(1.) ->
		runStdlibIngameTests()
	registerStdlibIngameTestCommand()
