/**
 * @file collision.c
 * @brief Collision shapes: construction, defaults, and the extrusion invariant.
 *
 * The invariant is the one worth staring at. A 2D shape handed to a 3D
 * narrowphase has to be thick enough that separating two of them along z is
 * never cheaper than separating them in the plane -- otherwise the narrowphase
 * answers "push it into the screen", the resolver does, and the actor sinks
 * through the floor while every test that only asks "did they collide" passes.
 * test_shape_extrusion_beats_planar_penetration checks the inequality directly
 * rather than checking that the number is 2.
 */

#include <string.h>

#include <akerror.h>

#include <akgl/collision.h>
#include <akgl/error.h>

#include "testutil.h"

akerr_ErrorContext *test_shape_box(void)
{
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_BOX), "a box is not a box");
	// Top-left-and-size in, centre-and-half-extent out.
	TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "half width is %f, expected 8", shape.hx);
	TEST_ASSERT_FEQ(errctx, shape.hy, 16.0f, "half height is %f, expected 16", shape.hy);
	TEST_ASSERT_FEQ(errctx, shape.ox, 16.0f, "centre x is %f, expected 16", shape.ox);
	TEST_ASSERT_FEQ(errctx, shape.oy, 16.0f, "centre y is %f, expected 16", shape.oy);

	// The defaults exist so that giving an actor a hitbox does not silently
	// make it shove every other actor on the map.
	TEST_ASSERT(errctx, (shape.layermask == AKGL_COLLISION_LAYER_ACTOR),
		    "a new shape is not on the actor layer");
	TEST_ASSERT(errctx, (shape.collidemask == AKGL_COLLISION_LAYER_STATIC),
		    "a new shape responds to something other than map geometry");
	TEST_ASSERT(errctx, (shape.flags == AKGL_COLLISION_FLAG_NONE),
		    "a new shape carries flags nobody set");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(NULL, &body, 0.0f),
			   "a box with no destination");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(&shape, NULL, 0.0f),
			   "a box with no rectangle");

	body.w = 0.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with zero width");
	body.w = -4.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with negative width");
	body.w = 16.0f;
	body.h = 0.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
			   "a box with zero height");
	body.h = 32.0f;
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, -1.0f),
			   "a box with negative depth");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_shape_circle_and_capsule(void)
{
    akgl_CollisionShape shape;
    SDL_FRect wide = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 10.0f };
    SDL_FRect tall = { .x = 0.0f, .y = 0.0f, .w = 10.0f, .h = 40.0f };

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_circle(&shape, 16.0f, 16.0f, 8.0f, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CIRCLE), "a circle is not a circle");
	TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "circle radius is %f, expected 8", shape.hx);
	TEST_ASSERT_FEQ(errctx, shape.hy, 8.0f, "a circle's hy should mirror its radius, got %f", shape.hy);

	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_circle(&shape, 0.0f, 0.0f, 0.0f, 0.0f),
			   "a circle of zero radius");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_circle(NULL, 0.0f, 0.0f, 8.0f, 0.0f),
			   "a circle with no destination");

	CATCH(errctx, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_X), "an x capsule is not one");
	CATCH(errctx, akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f));
	TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_Y), "a y capsule is not one");

	/*
	 * A capsule capped on its *short* axis is a circle described
	 * confusingly. Refusing it is the difference between a caller getting an
	 * error and a caller getting a shape that is not the one they drew.
	 */
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f),
			   "a tall rectangle capped on x");
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f),
			   "a wide rectangle capped on y");
	TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
			   akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_BOX, 0.0f),
			   "a capsule on an axis that is not an axis");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The inequality that keeps a 2D game flat, checked rather than assumed.
 *
 * For any two shapes the setters produce, the overlap along z when they are
 * concentric must exceed the largest penetration they can reach in the plane.
 * If it does not, the narrowphase is entitled to answer "separate along z", and
 * a resolver acting on that pushes the actor into the screen -- invisible, and
 * fatal, because the actor is still inside the floor.
 *
 * This tests the property over a spread of sizes rather than testing that the
 * ratio constant equals 2, so that changing the constant to something that does
 * not work fails here instead of failing in a game.
 */
akerr_ErrorContext *test_shape_extrusion_beats_planar_penetration(void)
{
    akgl_CollisionShape a;
    akgl_CollisionShape b;
    SDL_FRect body;
    float32_t sizes[] = { 1.0f, 4.0f, 16.0f, 32.0f, 512.0f };
    float32_t zoverlap = 0.0f;
    float32_t planarmax = 0.0f;
    float32_t worstz = 0.0f;
    float32_t worstplanar = 0.0f;
    float32_t worsta = 0.0f;
    float32_t worstb = 0.0f;
    bool violated = false;
    int i = 0;
    int j = 0;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	/*
	 * The check is recorded into a flag and asserted after the loops rather
	 * than asserted inside them. TEST_ASSERT expands to FAIL_BREAK, which
	 * reports by `break`ing, and a `break` inside a `for` leaves the loop
	 * rather than the ATTEMPT block -- so an assertion written in here
	 * cannot fail the test. AGENTS.md documents that hazard for CATCH; it
	 * applies to every macro in the family, and this test was written with
	 * the bug before it was written without it.
	 */
	for ( i = 0; i < (int)(sizeof(sizes) / sizeof(sizes[0])); i++ ) {
	    for ( j = 0; j < (int)(sizeof(sizes) / sizeof(sizes[0])); j++ ) {
		body.x = 0.0f;
		body.y = 0.0f;
		body.w = sizes[i];
		body.h = sizes[i];
		if ( akgl_collision_shape_box(&a, &body, 0.0f) != NULL ) {
		    violated = true;
		    break;
		}
		body.w = sizes[j];
		body.h = sizes[j];
		if ( akgl_collision_shape_box(&b, &body, 0.0f) != NULL ) {
		    violated = true;
		    break;
		}

		// Concentric is the worst case: every axis is fully overlapped.
		zoverlap = 2.0f * ((a.hz < b.hz) ? a.hz : b.hz);
		planarmax = 2.0f * ((a.hx < b.hx) ? a.hx : b.hx);
		if ( zoverlap <= planarmax ) {
		    violated = true;
		    worstz = zoverlap;
		    worstplanar = planarmax;
		    worsta = sizes[i];
		    worstb = sizes[j];
		}
	    }
	}

	TEST_ASSERT(errctx, (violated == false),
		    "a %fx%f box against a %fx%f one overlaps %f on z and up to %f in the "
		    "plane; z is the cheaper separation, so the narrowphase will answer "
		    "\"push it into the screen\" and the actor will sink through the floor",
		    worsta, worsta, worstb, worstb, worstz, worstplanar);

	// A depth given by hand is taken at face value. A caller who does that
	// has opted out of the guarantee above, which is why the setters choose
	// for you unless you say otherwise.
	body.w = 16.0f;
	body.h = 16.0f;
	CATCH(errctx, akgl_collision_shape_box(&a, &body, 3.0f));
	TEST_ASSERT_FEQ(errctx, a.hz, 3.0f, "an explicit depth of 3 became %f", a.hz);
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_shape_bounds(void)
{
    akgl_CollisionShape shape;
    SDL_FRect body = { .x = 8.0f, .y = 4.0f, .w = 16.0f, .h = 32.0f };
    SDL_FRect bounds;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));

	// At the origin the bounds are the rectangle it was built from. That
	// round trip is the whole contract, and it is easy to get wrong by half
	// an extent in either direction.
	CATCH(errctx, akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, &bounds));
	TEST_ASSERT_FEQ(errctx, bounds.x, 8.0f, "bounds x is %f, expected 8", bounds.x);
	TEST_ASSERT_FEQ(errctx, bounds.y, 4.0f, "bounds y is %f, expected 4", bounds.y);
	TEST_ASSERT_FEQ(errctx, bounds.w, 16.0f, "bounds w is %f, expected 16", bounds.w);
	TEST_ASSERT_FEQ(errctx, bounds.h, 32.0f, "bounds h is %f, expected 32", bounds.h);

	// And they translate with the owner rather than staying put.
	CATCH(errctx, akgl_collision_shape_bounds(&shape, 100.0f, -50.0f, &bounds));
	TEST_ASSERT_FEQ(errctx, bounds.x, 108.0f, "bounds x is %f, expected 108", bounds.x);
	TEST_ASSERT_FEQ(errctx, bounds.y, -46.0f, "bounds y is %f, expected -46", bounds.y);

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_bounds(NULL, 0.0f, 0.0f, &bounds),
			   "bounds of no shape");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, NULL),
			   "bounds with no destination");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The mask test, and the asymmetry that is the reason it exists.
 */
akerr_ErrorContext *test_shape_interacts(void)
{
    akgl_CollisionShape player;
    akgl_CollisionShape npc;
    akgl_CollisionShape none;
    SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
    bool interacts = false;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_collision_shape_box(&player, &body, 0.0f));
	CATCH(errctx, akgl_collision_shape_box(&npc, &body, 0.0f));
	memset(&none, 0x00, sizeof(akgl_CollisionShape));

	// Out of the box, two actors ignore each other. This is the default that
	// keeps a town full of NPCs from shoving itself apart.
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == false),
		    "two default shapes collide with each other; the default masks are wrong");

	// The player opts in to blocking against actors. The NPC does not opt in
	// to blocking against the player, and must not be dragged along by it --
	// that asymmetry is the entire point of two masks instead of one.
	player.collidemask |= AKGL_COLLISION_LAYER_ACTOR;
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == true), "the player does not respond to an actor it opted in to");
	CATCH(errctx, akgl_collision_shape_interacts(&npc, &player, &interacts));
	TEST_ASSERT(errctx, (interacts == false),
		    "the NPC responded to the player because the player responded to it");

	// A shape with no kind takes no part, in either direction.
	CATCH(errctx, akgl_collision_shape_interacts(&player, &none, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a shape of no kind was collided with");
	CATCH(errctx, akgl_collision_shape_interacts(&none, &player, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a shape of no kind collided with something");

	// And neither does a disabled one, which is the cheap way to switch an
	// actor out without losing its shape.
	npc.layermask = AKGL_COLLISION_LAYER_ACTOR;
	npc.flags |= AKGL_COLLISION_FLAG_DISABLED;
	CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
	TEST_ASSERT(errctx, (interacts == false), "a disabled shape was collided with");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(NULL, &npc, &interacts),
			   "interacts with no self");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(&player, NULL, &interacts),
			   "interacts with no other");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_collision_shape_interacts(&player, &npc, NULL),
			   "interacts with no destination");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

int main(void)
{
    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	CATCH(errctx, test_shape_box());
	CATCH(errctx, test_shape_circle_and_capsule());
	CATCH(errctx, test_shape_extrusion_beats_planar_penetration());
	CATCH(errctx, test_shape_bounds());
	CATCH(errctx, test_shape_interacts());
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);

    return 0;
}
