/**
 * @file physics.c
 * @brief Unit tests for the physics backends and the simulation loop.
 *
 * None of these tests need a renderer or a window. The simulation loop walks
 * akgl_heap_actors directly, so each test rebuilds the actor heap rather than relying
 * on state left behind by an earlier one.
 */

#include <SDL3/SDL.h>
#include <string.h>
#include <akerror.h>

#include <akgl/error.h>
#include <akgl/physics.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>

#include "testutil.h"

/** @brief Records whether the stub movement logic ran, and with what dt. */
static int stub_movement_calls = 0;
static float32_t stub_movement_last_dt = 0;

/** @brief Movement logic stub that records its invocation and does nothing else. */
static akerr_ErrorContext *stub_movement_noop(akgl_Actor *actor, float32_t dt)
{
    PREPARE_ERROR(e);
    stub_movement_calls += 1;
    stub_movement_last_dt = dt;
    SUCCEED_RETURN(e);
}

/** @brief Movement logic stub that raises the interrupt the simulator must swallow. */
static akerr_ErrorContext *stub_movement_interrupt(akgl_Actor *actor, float32_t dt)
{
    PREPARE_ERROR(e);
    stub_movement_calls += 1;
    FAIL_RETURN(e, AKGL_ERR_LOGICINTERRUPT, "deliberate interrupt from test stub");
}

/** @brief Movement logic stub that raises an error the simulator must propagate. */
static akerr_ErrorContext *stub_movement_error(akgl_Actor *actor, float32_t dt)
{
    PREPARE_ERROR(e);
    stub_movement_calls += 1;
    FAIL_RETURN(e, AKERR_VALUE, "deliberate error from test stub");
}

/**
 * @brief Build a character and an actor bound to it, ready for simulation.
 *
 * The actor is placed on the heap with a nonzero refcount and a movement logic
 * stub, which is the minimum the simulation loop requires to process it.
 */
static akerr_ErrorContext *make_sim_actor(akgl_Actor **actor, akgl_Character **basechar, char *name)
{
    PREPARE_ERROR(e);
    ATTEMPT {
	CATCH(e, akgl_heap_next_character(basechar));
	CATCH(e, akgl_character_initialize(*basechar, name));
	CATCH(e, akgl_heap_next_actor(actor));
	CATCH(e, akgl_actor_initialize(*actor, name));
	(*actor)->basechar = *basechar;
	(*actor)->movementlogicfunc = &stub_movement_noop;
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

/** @brief Reset the actor heap so each simulation test starts from a clean slate. */
static akerr_ErrorContext *reset_sim_heap(void)
{
    PREPARE_ERROR(e);
    ATTEMPT {
	CATCH(e, akgl_registry_init_actor());
	CATCH(e, akgl_registry_init_character());
	CATCH(e, akgl_heap_init());
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    stub_movement_calls = 0;
    stub_movement_last_dt = 0;
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_init_null(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");

	TEST_ASSERT(e, backend.gravity == &akgl_physics_null_gravity,
		    "init_null did not install the null gravity function");
	TEST_ASSERT(e, backend.collide == &akgl_physics_null_collide,
		    "init_null did not install the null collide function");
	TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
		    "init_null did not install the null move function");
	TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
		    "init_null did not install the shared simulate function");

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_null(NULL),
			   "akgl_physics_init_null(NULL)");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_null_backend_is_inert(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor actor;
    akgl_Actor reference;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	memset(&actor, 0x00, sizeof(akgl_Actor));
	TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");

	actor.x = 3.0f;  actor.y = 5.0f;  actor.z = 7.0f;
	actor.vx = 11.0f; actor.vy = 13.0f; actor.vz = 17.0f;
	actor.ex = 19.0f; actor.ey = 23.0f; actor.ez = 29.0f;
	memcpy(&reference, &actor, sizeof(akgl_Actor));

	TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity");
	TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move");
	TEST_EXPECT_OK(e, backend.collide(&backend, &actor, 1.0f), "null collide");

	TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
		    "the null backend modified the actor");

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(NULL, &actor, 1.0f),
			   "null gravity with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f),
			   "null move with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, 1.0f),
			   "null collide with NULL self");

	// The actor arguments too. The arcade backend has always checked these;
	// the null one checked only `self`, so which pointers a caller could get
	// away with passing depended on which backend it happened to have.
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(&backend, NULL, 1.0f),
			   "null gravity with a NULL actor");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(&backend, NULL, 1.0f),
			   "null move with a NULL actor");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, 1.0f),
			   "null collide with a NULL actor");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_arcade_gravity(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor actor;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));

	// X gravity subtracts, because the X origin is screen-left.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	backend.gravity_x = 10.0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity x");
	TEST_ASSERT_FEQ(e, actor.ex, -5.0f, "gravity_x 10 over dt 0.5 gave ex %f, expected -5", actor.ex);
	TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "gravity_x leaked into ey (%f)", actor.ey);
	TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "gravity_x leaked into ez (%f)", actor.ez);

	// Y gravity adds, because the Y origin is down-screen.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	backend.gravity_x = 0;
	backend.gravity_y = 10.0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity y");
	TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "gravity_y 10 over dt 0.5 gave ey %f, expected 5", actor.ey);
	TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "gravity_y leaked into ex (%f)", actor.ex);

	// Z gravity subtracts, because the Z origin is behind the camera.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	backend.gravity_y = 0;
	backend.gravity_z = 4.0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.25f), "arcade gravity z");
	TEST_ASSERT_FEQ(e, actor.ez, -1.0f, "gravity_z 4 over dt 0.25 gave ez %f, expected -1", actor.ez);

	// Negative gravity reverses each axis.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	backend.gravity_x = -10.0;
	backend.gravity_y = -10.0;
	backend.gravity_z = -10.0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity negative");
	TEST_ASSERT_FEQ(e, actor.ex, 10.0f, "negative gravity_x gave ex %f, expected 10", actor.ex);
	TEST_ASSERT_FEQ(e, actor.ey, -10.0f, "negative gravity_y gave ey %f, expected -10", actor.ey);
	TEST_ASSERT_FEQ(e, actor.ez, 10.0f, "negative gravity_z gave ez %f, expected 10", actor.ez);

	// A zero dt applies no force even when gravity is set.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	backend.gravity_x = 10.0;
	backend.gravity_y = 10.0;
	backend.gravity_z = 10.0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.0f), "arcade gravity dt 0");
	TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "dt 0 still moved ex (%f)", actor.ex);
	TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "dt 0 still moved ey (%f)", actor.ey);
	TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "dt 0 still moved ez (%f)", actor.ez);

	// All-zero gravity leaves every axis alone.
	memset(&actor, 0x00, sizeof(akgl_Actor));
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	actor.ex = 1.0f; actor.ey = 2.0f; actor.ez = 3.0f;
	TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity all zero");
	TEST_ASSERT_FEQ(e, actor.ex, 1.0f, "zero gravity_x changed ex (%f)", actor.ex);
	TEST_ASSERT_FEQ(e, actor.ey, 2.0f, "zero gravity_y changed ey (%f)", actor.ey);
	TEST_ASSERT_FEQ(e, actor.ez, 3.0f, "zero gravity_z changed ez (%f)", actor.ez);

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(NULL, &actor, 1.0f),
			   "arcade gravity with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(&backend, NULL, 1.0f),
			   "arcade gravity with NULL actor");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_arcade_move(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor actor;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	memset(&actor, 0x00, sizeof(akgl_Actor));

	actor.x = 100.0f; actor.y = 200.0f; actor.z = 300.0f;
	actor.vx = 10.0f; actor.vy = -20.0f; actor.vz = 30.0f;
	TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.5f), "arcade move");
	TEST_ASSERT_FEQ(e, actor.x, 105.0f, "x moved to %f, expected 105", actor.x);
	TEST_ASSERT_FEQ(e, actor.y, 190.0f, "y moved to %f, expected 190", actor.y);
	TEST_ASSERT_FEQ(e, actor.z, 315.0f, "z moved to %f, expected 315", actor.z);

	// dt of zero is a no-op regardless of velocity.
	TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.0f), "arcade move dt 0");
	TEST_ASSERT_FEQ(e, actor.x, 105.0f, "dt 0 moved x to %f", actor.x);
	TEST_ASSERT_FEQ(e, actor.y, 190.0f, "dt 0 moved y to %f", actor.y);
	TEST_ASSERT_FEQ(e, actor.z, 315.0f, "dt 0 moved z to %f", actor.z);

	// Zero velocity is a no-op regardless of dt.
	actor.vx = 0; actor.vy = 0; actor.vz = 0;
	TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 10.0f), "arcade move zero velocity");
	TEST_ASSERT_FEQ(e, actor.x, 105.0f, "zero velocity moved x to %f", actor.x);

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(NULL, &actor, 1.0f),
			   "arcade move with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(&backend, NULL, 1.0f),
			   "arcade move with NULL actor");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

/**
 * @brief The arcade backend resolves, and does nothing when there is no world.
 *
 * This function used to assert that akgl_physics_arcade_collide always raised
 * AKERR_API, with a comment saying it existed so that implementing collision
 * would have to come back here. It has, and this is that visit.
 *
 * What replaces it is the opt-in contract: a backend with no collision world
 * attached returns success having changed nothing, which is what keeps every
 * program written before collision existed running exactly as it did.
 */
akerr_ErrorContext *test_physics_arcade_collide(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor actor;
    akgl_Actor reference;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	memset(&actor, 0x00, sizeof(akgl_Actor));
	actor.x = 3.0f;
	actor.y = 5.0f;
	memcpy(&reference, &actor, sizeof(akgl_Actor));

	// No world: success, and the actor is untouched.
	TEST_EXPECT_OK(e, akgl_physics_arcade_collide(&backend, &actor, 1.0f),
		       "arcade collide with no collision world");
	TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
		    "arcade collide moved an actor with no collision world attached");

	// Both pointers checked. The old stub returned AKERR_API before looking
	// at either, so which arguments were validated depended on the backend.
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &actor, 1.0f),
			   "arcade collide with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(&backend, NULL, 1.0f),
			   "arcade collide with a NULL actor");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}


akerr_ErrorContext *test_physics_factory(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_String typestr;

    ATTEMPT {
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));

	CATCH(e, akgl_string_initialize(&typestr, "null"));
	TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"null\")");
	TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
		    "factory(\"null\") did not install the null backend");

	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_string_initialize(&typestr, "arcade"));
	TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"arcade\")");
	TEST_ASSERT(e, backend.move == &akgl_physics_arcade_move,
		    "factory(\"arcade\") did not install the arcade backend");

	CATCH(e, akgl_string_initialize(&typestr, "newtonian"));
	TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
			   "factory with an unknown backend name");

	// An empty name must not match either prefix.
	CATCH(e, akgl_string_initialize(&typestr, ""));
	TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
			   "factory with an empty backend name");

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(NULL, &typestr),
			   "factory with NULL self");
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(&backend, NULL),
			   "factory with NULL type");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_init_arcade_properties(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;

    ATTEMPT {
	CATCH(e, akgl_registry_init_properties());

	// With nothing configured, every environmental constant defaults to zero.
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with no properties set");
	TEST_ASSERT_FEQ(e, backend.gravity_y, 0.0f,
			"unset physics.gravity.y defaulted to %f, expected 0", backend.gravity_y);
	TEST_ASSERT_FEQ(e, backend.drag_x, 0.0f,
			"unset physics.drag.x defaulted to %f, expected 0", backend.drag_x);
	TEST_ASSERT(e, backend.gravity == &akgl_physics_arcade_gravity,
		    "init_arcade did not install the arcade gravity function");
	TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
		    "init_arcade did not install the shared simulate function");

	// Configured values are read out of the property registry.
	CATCH(e, akgl_set_property("physics.gravity.x", "1.5"));
	CATCH(e, akgl_set_property("physics.gravity.y", "9.8"));
	CATCH(e, akgl_set_property("physics.gravity.z", "2.25"));
	CATCH(e, akgl_set_property("physics.drag.x", "0.5"));
	CATCH(e, akgl_set_property("physics.drag.y", "0.25"));
	CATCH(e, akgl_set_property("physics.drag.z", "0.125"));

	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with properties set");
	TEST_ASSERT_FEQ(e, backend.gravity_x, 1.5f, "physics.gravity.x read as %f", backend.gravity_x);
	TEST_ASSERT_FEQ(e, backend.gravity_y, 9.8f, "physics.gravity.y read as %f", backend.gravity_y);
	TEST_ASSERT_FEQ(e, backend.gravity_z, 2.25f, "physics.gravity.z read as %f", backend.gravity_z);
	TEST_ASSERT_FEQ(e, backend.drag_x, 0.5f, "physics.drag.x read as %f", backend.drag_x);
	TEST_ASSERT_FEQ(e, backend.drag_y, 0.25f, "physics.drag.y read as %f", backend.drag_y);
	TEST_ASSERT_FEQ(e, backend.drag_z, 0.125f, "physics.drag.z read as %f", backend.drag_z);

	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_arcade(NULL),
			   "init_arcade with NULL self");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_skips_inactive(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *actor = NULL;
    akgl_Character *basechar = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));

	// A released actor (refcount 0) is skipped entirely.
	CATCH(e, make_sim_actor(&actor, &basechar, "inactive"));
	actor->refcount = 0;
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a released actor");
	TEST_ASSERT(e, stub_movement_calls == 0,
		    "simulate ran movement logic for an actor with refcount 0 (%d calls)",
		    stub_movement_calls);

	// An actor with no base character is skipped.
	CATCH(e, reset_sim_heap());
	CATCH(e, make_sim_actor(&actor, &basechar, "nochar"));
	actor->basechar = NULL;
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a character-less actor");
	TEST_ASSERT(e, stub_movement_calls == 0,
		    "simulate ran movement logic for an actor with no base character (%d calls)",
		    stub_movement_calls);

	// A fully wired actor is processed.
	CATCH(e, reset_sim_heap());
	CATCH(e, make_sim_actor(&actor, &basechar, "active"));
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with an active actor");
	TEST_ASSERT(e, stub_movement_calls == 1,
		    "simulate ran movement logic %d times for one active actor, expected 1",
		    stub_movement_calls);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_children_follow_parents(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *parent = NULL;
    akgl_Actor *child = NULL;
    akgl_Character *basechar = NULL;
    akgl_Character *childchar = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));

	CATCH(e, make_sim_actor(&parent, &basechar, "parent"));
	CATCH(e, make_sim_actor(&child, &childchar, "child"));

	parent->x = 100.0f; parent->y = 200.0f; parent->z = 300.0f;
	// For a child, vx/vy/vz are read as a fixed offset from the parent.
	child->vx = 5.0f; child->vy = -5.0f; child->vz = 2.0f;
	child->x = -999.0f; child->y = -999.0f; child->z = -999.0f;
	CATCH(e, akgl_actor_add_child(parent, child));

	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a parented child");

	TEST_ASSERT_FEQ(e, child->x, 105.0f, "child x resolved to %f, expected parent 100 + offset 5", child->x);
	TEST_ASSERT_FEQ(e, child->y, 195.0f, "child y resolved to %f, expected parent 200 - offset 5", child->y);
	TEST_ASSERT_FEQ(e, child->z, 302.0f, "child z resolved to %f, expected parent 300 + offset 2", child->z);

	// Only the parent goes through the movement logic; the child is positional only.
	TEST_ASSERT(e, stub_movement_calls == 1,
		    "simulate ran movement logic %d times, expected 1 (parent only)",
		    stub_movement_calls);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_layer_mask(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *layer0 = NULL;
    akgl_Actor *layer3 = NULL;
    akgl_Character *char0 = NULL;
    akgl_Character *char3 = NULL;
    akgl_Iterator opflags;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));

	CATCH(e, make_sim_actor(&layer0, &char0, "onlayer0"));
	CATCH(e, make_sim_actor(&layer3, &char3, "onlayer3"));
	layer0->layer = 0;
	layer3->layer = 3;

	// Masking to layer 3 processes only the layer 3 actor.
	opflags.flags = AKGL_ITERATOR_OP_LAYERMASK;
	opflags.layerid = 3;
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to layer 3");
	TEST_ASSERT(e, stub_movement_calls == 1,
		    "layer mask 3 processed %d actors, expected 1", stub_movement_calls);

	// Masking to a layer with no actors processes nothing.
	opflags.layerid = 7;
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to an empty layer");
	TEST_ASSERT(e, stub_movement_calls == 0,
		    "empty layer mask processed %d actors, expected 0", stub_movement_calls);

	// Without the mask flag, layerid is ignored and both actors are processed.
	opflags.flags = 0;
	opflags.layerid = 7;
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate with the layer mask off");
	TEST_ASSERT(e, stub_movement_calls == 2,
		    "unmasked simulate processed %d actors, expected 2", stub_movement_calls);

	// A NULL iterator selects the documented defaults, which mask nothing.
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with NULL opflags");
	TEST_ASSERT(e, stub_movement_calls == 2,
		    "NULL opflags processed %d actors, expected 2", stub_movement_calls);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *actor = NULL;
    akgl_Character *basechar = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));
	CATCH(e, make_sim_actor(&actor, &basechar, "thruster"));

	// Over-thrust on one axis alone clamps to that axis's max speed,
	// preserving sign. This is the case the old per-axis clamp got right,
	// and it has to keep working.
	actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
	actor->tx = 500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with single-axis over-thrust");
	TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);

	actor->tx = -500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative single-axis over-thrust");
	TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);

	actor->tx = 0.0f; actor->ty = -400.0f; actor->tz = 0.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative y over-thrust");
	TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);

	actor->tx = 0.0f; actor->ty = 0.0f; actor->tz = 300.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with z over-thrust");
	TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz);

	// Over-thrust on several axes at once scales the *vector* back to the
	// ellipse the three max speeds describe, rather than clamping each axis
	// against its own. Clamping the axes independently let the corner of the
	// box through: an actor holding two directions travelled their diagonal,
	// 41% faster than either alone, which is the classic diagonal-run bug.
	// tests/physics_sim.c measures it (`top-down diagonal speed`).
	//
	// The two things that makes true are asserted here rather than the three
	// numbers that fall out of them: the direction asked for is preserved,
	// so every axis is scaled by the same factor, and the result lands
	// exactly on the ellipse.
	actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with over-thrust on every axis");
	TEST_ASSERT_FEQ(e, actor->tx / actor->sx, actor->ty / actor->sy,
			"x and y were scaled differently (%f vs %f), so the direction moved",
			actor->tx / actor->sx, actor->ty / actor->sy);
	TEST_ASSERT_FEQ(e, actor->ty / actor->sy, actor->tz / actor->sz,
			"y and z were scaled differently (%f vs %f), so the direction moved",
			actor->ty / actor->sy, actor->tz / actor->sz);
	TEST_ASSERT_FEQ(e,
			((actor->tx / actor->sx) * (actor->tx / actor->sx))
			+ ((actor->ty / actor->sy) * (actor->ty / actor->sy))
			+ ((actor->tz / actor->sz) * (actor->tz / actor->sz)),
			1.0f,
			"capped thrust (%f, %f, %f) is not on the max-speed ellipse",
			actor->tx, actor->ty, actor->tz);

	// Signs survive the scaling.
	actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust on every axis");
	TEST_ASSERT(e, (actor->tx < 0.0f) && (actor->ty < 0.0f) && (actor->tz < 0.0f),
		    "capping negative thrust changed a sign: (%f, %f, %f)",
		    actor->tx, actor->ty, actor->tz);

	// An axis whose max speed is zero cannot be thrust along at all, and it
	// stays out of the magnitude -- dividing by it would not end well.
	actor->sz = 0.0f;
	actor->tx = 10.0f; actor->ty = 0.0f; actor->tz = 300.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a zero max speed");
	TEST_ASSERT_FEQ(e, actor->tz, 0.0f, "thrust survived a zero max speed as %f", actor->tz);
	TEST_ASSERT_FEQ(e, actor->tx, 10.0f,
			"a zero max speed on z disturbed in-range x thrust (%f)", actor->tx);
	actor->sz = 30.0f;

	// Thrust inside the limit is left alone.
	actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust");
	TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx);
	TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty);
	TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz);

	// Velocity is the sum of environmental force and thrust.
	actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f;
	actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition");
	TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx);
	TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy);
	TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_drag(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *actor = NULL;
    akgl_Character *basechar = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));
	CATCH(e, make_sim_actor(&actor, &basechar, "dragged"));

	// Zero drag leaves environmental velocity untouched.
	actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f;
	actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag");
	TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex);
	TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey);
	TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez);

	// Nonzero drag bleeds off environmental velocity in proportion to dt.
	// The simulator derives dt from the wall clock, so assert the direction
	// and bounds of the change rather than an exact figure.
	backend.drag_x = 0.5;
	backend.drag_y = 0.5;
	backend.drag_z = 0.5;
	backend.gravity_time = SDL_GetTicksNS();
	SDL_Delay(20);
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied");
	TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f,
		    "drag left ex at %f, expected a value between 0 and 100", actor->ex);
	TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f,
		    "drag left ey at %f, expected a value between 0 and 100", actor->ey);
	TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f,
		    "drag left ez at %f, expected a value between 0 and 100", actor->ez);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_movement_states(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *actor = NULL;
    akgl_Character *basechar = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));
	CATCH(e, make_sim_actor(&actor, &basechar, "mover"));

	actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
	actor->ax = 10.0f; actor->ay = 20.0f;

	// An idle actor accumulates no thrust on either axis.
	actor->state = 0;
	actor->tx = 0; actor->ty = 0;
	backend.gravity_time = SDL_GetTicksNS();
	SDL_Delay(10);
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor");
	TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx);
	TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty);

	// Moving horizontally accumulates thrust on X only.
	actor->state = AKGL_ACTOR_STATE_MOVING_LEFT;
	actor->tx = 0; actor->ty = 0;
	backend.gravity_time = SDL_GetTicksNS();
	SDL_Delay(10);
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor");
	TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx);
	TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty);

	// Moving vertically accumulates thrust on Y only.
	actor->state = AKGL_ACTOR_STATE_MOVING_DOWN;
	actor->tx = 0; actor->ty = 0;
	backend.gravity_time = SDL_GetTicksNS();
	SDL_Delay(10);
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor");
	TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty);
	TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx);

	// The right and up states drive the same accumulators.
	actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP);
	actor->tx = 0; actor->ty = 0;
	backend.gravity_time = SDL_GetTicksNS();
	SDL_Delay(10);
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor");
	TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx);
	TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty);
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_logic_interrupt(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;
    akgl_Actor *first = NULL;
    akgl_Actor *second = NULL;
    akgl_Character *char1 = NULL;
    akgl_Character *char2 = NULL;

    ATTEMPT {
	CATCH(e, reset_sim_heap());
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	CATCH(e, akgl_physics_init_null(&backend));

	CATCH(e, make_sim_actor(&first, &char1, "interrupter"));
	CATCH(e, make_sim_actor(&second, &char2, "follower"));
	first->movementlogicfunc = &stub_movement_interrupt;

	// AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame",
	// so the second actor must still be reached.
	stub_movement_calls = 0;
	TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL),
		       "simulate must swallow AKGL_ERR_LOGICINTERRUPT");
	TEST_ASSERT(e, stub_movement_calls == 2,
		    "an interrupting actor stopped the loop after %d of 2 actors",
		    stub_movement_calls);

	// An unrelated error is not swallowed.
	CATCH(e, reset_sim_heap());
	CATCH(e, make_sim_actor(&first, &char1, "failer"));
	first->movementlogicfunc = &stub_movement_error;
	TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL),
			   "simulate must propagate a non-interrupt movement error");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

akerr_ErrorContext *test_physics_simulate_nullpointers(void)
{
    PREPARE_ERROR(e);
    akgl_PhysicsBackend backend;

    ATTEMPT {
	CATCH(e, reset_sim_heap());

	// A backend with no move function cannot simulate.
	memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL),
			   "simulate with no move function installed");

	// A NULL backend must be reported, not dereferenced.
	TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL),
			   "simulate with NULL self");
    } CLEANUP {
    } PROCESS(e) {
    } FINISH(e, true);
    SUCCEED_RETURN(e);
}

int main(void)
{
    PREPARE_ERROR(errctx);

    SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
    SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init());
	CATCH(errctx, akgl_registry_init_properties());

	CATCH(errctx, test_physics_init_null());
	CATCH(errctx, test_physics_null_backend_is_inert());
	CATCH(errctx, test_physics_arcade_gravity());
	CATCH(errctx, test_physics_arcade_move());
	CATCH(errctx, test_physics_arcade_collide());
	CATCH(errctx, test_physics_factory());
	CATCH(errctx, test_physics_init_arcade_properties());
	CATCH(errctx, test_physics_simulate_skips_inactive());
	CATCH(errctx, test_physics_simulate_children_follow_parents());
	CATCH(errctx, test_physics_simulate_layer_mask());
	CATCH(errctx, test_physics_simulate_thrust_and_clamp());
	CATCH(errctx, test_physics_simulate_drag());
	CATCH(errctx, test_physics_simulate_movement_states());
	CATCH(errctx, test_physics_simulate_logic_interrupt());
	CATCH(errctx, test_physics_simulate_nullpointers());
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);
}
