/**
 * @file text.c
 * @brief Unit tests for font loading and text measurement.
 *
 * Measurement needs a font but no renderer at all. Drawing needs one, but not a
 * display: a software renderer over a window under the dummy video driver takes
 * akgl_text_rendertextat() all the way from rasterizing to blitting, so this
 * suite covers it without waiting for the offscreen harness. What is still
 * missing is any assertion about the *pixels* -- that is the harness's job.
 *
 * The fixture font is monospaced on purpose: the width of an N-character string
 * is exactly N times the width of one character, so every assertion below is a
 * relationship between measurements rather than a hardcoded pixel count that
 * would break when FreeType changes its rounding.
 */

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

#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>

#include "testutil.h"

/** @brief The monospaced ASCII subset described in assets/akgl_test_mono.LICENSE.txt. */
#define TEST_FONT_PATH "assets/akgl_test_mono.ttf"
/** @brief Point size every test in this file opens the fixture font at. */
#define TEST_FONT_SIZE 16
/** @brief Width and height of the offscreen target the drawing tests draw into. */
#define TEST_TARGET_SIZE 128

/** @brief The fixture font, opened once by main() and shared by every test. */
static TTF_Font *testfont = NULL;
/** @brief A 2D backend bound to a software renderer, built by main(). */
static akgl_RenderBackend testbackend;

akerr_ErrorContext *test_text_loadfont(void)
{
    PREPARE_ERROR(errctx);
    TTF_Font *registered = NULL;

    ATTEMPT {
	CATCH(errctx, akgl_registry_init_font());

	TEST_EXPECT_OK(
	    errctx,
	    akgl_text_loadfont("testfont", TEST_FONT_PATH, TEST_FONT_SIZE),
	    "loading the fixture font");

	registered = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL);
	TEST_ASSERT(errctx, registered != NULL,
		    "akgl_text_loadfont did not place the font in AKGL_REGISTRY_FONT");
	TEST_ASSERT(errctx, TTF_GetFontHeight(registered) > 0,
		    "the registered font reports height %d, expected a positive height",
		    TTF_GetFontHeight(registered));

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_loadfont(NULL, TEST_FONT_PATH, TEST_FONT_SIZE),
			   "loading a font under a NULL name");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_loadfont("nullpath", NULL, TEST_FONT_SIZE),
			   "loading a font from a NULL path");
	TEST_EXPECT_STATUS(errctx, AKGL_ERR_SDL,
			   akgl_text_loadfont("missing", "assets/no_such_font.ttf", TEST_FONT_SIZE),
			   "loading a font that does not exist");

	// Loading over a live name closes the font it displaces. Nothing here
	// can observe the close directly -- the proof is the memcheck run --
	// but the replacement itself is observable, and it is the path that
	// used to abandon ten kilobytes per call.
	TEST_EXPECT_OK(
	    errctx,
	    akgl_text_loadfont("testfont", TEST_FONT_PATH, TEST_FONT_SIZE),
	    "loading a second font over a registered name");
	TEST_ASSERT(errctx,
		    SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL) != registered,
		    "reloading a font under a live name left the displaced font registered");

	// And the font goes back. Unloading is what makes the suite's own
	// bookkeeping honest, so this is not only an API test.
	TEST_EXPECT_OK(errctx, akgl_text_unloadfont("testfont"), "unloading a registered font");
	TEST_ASSERT(errctx,
		    SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL) == NULL,
		    "akgl_text_unloadfont left the font in AKGL_REGISTRY_FONT");
	TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_text_unloadfont("testfont"),
			   "unloading a font that is already gone");
	TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_text_unloadfont("never_loaded"),
			   "unloading a name that was never registered");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_unloadfont(NULL),
			   "unloading a NULL name");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_text_measure(void)
{
    PREPARE_ERROR(errctx);
    int w = 0;
    int h = 0;
    int onecharw = 0;
    int onecharh = 0;
    int emptyw = 0;
    int emptyh = 0;

    ATTEMPT {
	// One cell. This is the measurement a character grid is built from.
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "A", &onecharw, &onecharh),
		       "measuring a single character");
	TEST_ASSERT(errctx, onecharw > 0,
		    "one character measured %d wide, expected a positive width", onecharw);
	TEST_ASSERT(errctx, onecharh == TTF_GetFontHeight(testfont),
		    "one character measured %d high, expected the font height %d",
		    onecharh, TTF_GetFontHeight(testfont));

	// The font is monospaced, so four cells are exactly four times one.
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "AAAA", &w, &h),
		       "measuring four characters");
	TEST_ASSERT(errctx, w == (onecharw * 4),
		    "four characters measured %d wide, expected %d", w, onecharw * 4);
	TEST_ASSERT(errctx, h == onecharh,
		    "four characters on one line measured %d high, expected %d", h, onecharh);

	// ...and every character advances by the same amount, which is what
	// makes a fixed grid legitimate in the first place.
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "W", &w, &h), "measuring a wide glyph");
	TEST_ASSERT(errctx, w == onecharw,
		    "'W' measured %d wide but 'A' measured %d in a monospaced font", w, onecharw);
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "i", &w, &h), "measuring a narrow glyph");
	TEST_ASSERT(errctx, w == onecharw,
		    "'i' measured %d wide but 'A' measured %d in a monospaced font", w, onecharw);

	// The empty string is zero wide and still one line high, so a cursor
	// sitting on an empty line has somewhere to be.
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "", &emptyw, &emptyh),
		       "measuring the empty string");
	TEST_ASSERT(errctx, emptyw == 0,
		    "the empty string measured %d wide, expected 0", emptyw);
	TEST_ASSERT(errctx, emptyh == onecharh,
		    "the empty string measured %d high, expected the font height %d",
		    emptyh, onecharh);

	// Measuring does not disturb the destinations it was not asked about.
	w = -1;
	h = -1;
	TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "hello", &w, &h),
		       "measuring a word");
	TEST_ASSERT(errctx, w == (onecharw * 5),
		    "\"hello\" measured %d wide, expected %d", w, onecharw * 5);

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(NULL, "A", &w, &h),
			   "measuring with a NULL font");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, NULL, &w, &h),
			   "measuring a NULL string");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, "A", NULL, &h),
			   "measuring into a NULL width");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, "A", &w, NULL),
			   "measuring into a NULL height");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_text_measure_wrapped(void)
{
    PREPARE_ERROR(errctx);
    int w = 0;
    int h = 0;
    int onecharw = 0;
    int flatw = 0;
    int flath = 0;
    int lineskip = 0;

    ATTEMPT {
	CATCH(errctx, akgl_text_measure(testfont, "A", &onecharw, &flath));
	lineskip = TTF_GetFontLineSkip(testfont);
	TEST_ASSERT(errctx, lineskip > 0, "the font reports a line skip of %d", lineskip);

	// A wrap length wide enough for the whole string measures the same as
	// the unwrapped call.
	CATCH(errctx, akgl_text_measure(testfont, "one two", &flatw, &flath));
	TEST_EXPECT_OK(errctx,
		       akgl_text_measure_wrapped(testfont, "one two", flatw + onecharw, &w, &h),
		       "measuring a string that fits inside the wrap length");
	TEST_ASSERT(errctx, w == flatw,
		    "an unwrapped measurement gave %d wide, expected %d", w, flatw);
	TEST_ASSERT(errctx, h == flath,
		    "an unwrapped measurement gave %d high, expected %d", h, flath);

	// Narrow enough to force a break at the space: two lines, and nothing
	// wider than the wrap length.
	TEST_EXPECT_OK(errctx,
		       akgl_text_measure_wrapped(testfont, "one two", onecharw * 4, &w, &h),
		       "measuring a string that has to wrap");
	TEST_ASSERT(errctx, w <= (onecharw * 4),
		    "a wrapped measurement gave %d wide, past the %d wrap length",
		    w, onecharw * 4);
	TEST_ASSERT(errctx, h >= (lineskip * 2),
		    "a wrapped measurement gave %d high, expected at least two lines (%d)",
		    h, lineskip * 2);

	// Zero wraps on newlines only, so an embedded newline still costs a line
	// and a long unbroken string does not.
	TEST_EXPECT_OK(errctx, akgl_text_measure_wrapped(testfont, "one\ntwo", 0, &w, &h),
		       "measuring a string with an embedded newline");
	TEST_ASSERT(errctx, h >= (lineskip * 2),
		    "an embedded newline measured %d high, expected at least two lines (%d)",
		    h, lineskip * 2);
	TEST_ASSERT(errctx, w == (onecharw * 3),
		    "the longer of two three-character lines measured %d wide, expected %d",
		    w, onecharw * 3);

	TEST_EXPECT_OK(errctx, akgl_text_measure_wrapped(testfont, "one two", 0, &w, &h),
		       "measuring a string with no newline at wrap length zero");
	TEST_ASSERT(errctx, h == flath,
		    "a string with no newline measured %d high at wrap length 0, expected %d",
		    h, flath);

	// A negative wrap length is refused rather than silently treated as
	// "never wrap", which is what SDL_ttf does with it.
	TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
			   akgl_text_measure_wrapped(testfont, "one two", -1, &w, &h),
			   "measuring at a negative wrap length");

	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_measure_wrapped(NULL, "A", 100, &w, &h),
			   "measuring wrapped with a NULL font");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_measure_wrapped(testfont, NULL, 100, &w, &h),
			   "measuring a NULL string wrapped");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_measure_wrapped(testfont, "A", 100, NULL, &h),
			   "measuring wrapped into a NULL width");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_measure_wrapped(testfont, "A", 100, &w, NULL),
			   "measuring wrapped into a NULL height");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_text_render_backend_guards(void)
{
    PREPARE_ERROR(errctx);
    akgl_RenderBackend backend;
    SDL_Color white = { 0xff, 0xff, 0xff, 0xff };

    ATTEMPT {
	// Nothing initialized the renderer at all, which is where the global
	// starts and where a host that has not called akgl_game_init leaves it.
	akgl_renderer = NULL;
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
			   "drawing text with no renderer backend");

	// A backend that exists but was never given an SDL_Renderer -- the
	// state a host is in between allocating one and initializing it.
	memset(&backend, 0x00, sizeof(akgl_RenderBackend));
	akgl_renderer = &backend;
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
			   "drawing text through a backend with no SDL renderer");

	// A live SDL_Renderer but no methods: a host that created its own
	// renderer and has not called akgl_render_2d_bind() yet. This is the one
	// that used to be a segfault rather than an error -- with a real
	// renderer behind it the call gets all the way to a NULL function
	// pointer, which is why the check has to be here and not left to SDL.
	backend.sdl_renderer = testbackend.sdl_renderer;
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
			   "drawing text through a backend that was never bound");

	akgl_renderer = &testbackend;
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(NULL, "hello", white, 0, 0, 0),
			   "drawing text with a NULL font");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(testfont, NULL, white, 0, 0, 0),
			   "drawing a NULL string");
    } CLEANUP {
	akgl_renderer = &testbackend;
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *test_text_rendertextat(void)
{
    PREPARE_ERROR(errctx);
    SDL_Color white = { 0xff, 0xff, 0xff, 0xff };

    ATTEMPT {
	// A bound backend over a software renderer is enough to draw through:
	// no display, no window shown, and the whole path from rasterizing to
	// blitting runs.
	akgl_renderer = &testbackend;
	TEST_EXPECT_OK(errctx, akgl_renderer->frame_start(akgl_renderer), "starting a frame");
	TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "hello", white, 0, 4, 4),
		       "drawing a line of text");
	// Wrapping takes the other rasterizing path.
	TEST_EXPECT_OK(errctx,
		       akgl_text_rendertextat(testfont, "one two three", white, TEST_TARGET_SIZE / 2, 0, 0),
		       "drawing wrapped text");
	// The empty string. SDL_ttf refuses it with "Text has zero width" from
	// both rasterizers, so this used to report AKERR_NULLPOINTER for what a
	// caller means as "draw nothing" -- while akgl_text_measure("") is
	// documented as legal. This case was written and deliberately left
	// unasserted until the two halves of the header agreed.
	TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, 0, 0, 0),
		       "drawing an empty line of text");
	TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, TEST_TARGET_SIZE / 2, 0, 0),
		       "drawing an empty line of wrapped text");

	// Drawing nothing must still refuse the things drawing something
	// refuses, rather than short-circuiting past the checks.
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(NULL, "", white, 0, 0, 0),
			   "drawing an empty line with no font");
	TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
			   akgl_text_rendertextat(testfont, NULL, white, 0, 0, 0),
			   "drawing a NULL string");

	TEST_EXPECT_OK(errctx, akgl_renderer->frame_end(akgl_renderer), "ending the frame");
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief akgl_text_unloadallfonts closes every registered font and clears the registry.
 *
 * A font was reachable only by name, so a game that exited without unloading
 * each one by hand left them open -- and SDL_Quit destroying the property
 * registry took the last reference with it. Roughly 10 KB per font, most of it
 * FreeType's. Bounded by how many fonts a game loads, but there was nothing a
 * game could do about it.
 */
akerr_ErrorContext *test_text_unloadallfonts(void)
{
    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_registry_init_font());
	CATCH(errctx, akgl_text_loadfont("unloadall_a", TEST_FONT_PATH, 16));
	CATCH(errctx, akgl_text_loadfont("unloadall_b", TEST_FONT_PATH, 24));
	TEST_ASSERT(errctx,
		    SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "unloadall_a", NULL) != NULL,
		    "the first font is not in the registry");
	TEST_ASSERT(errctx,
		    SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "unloadall_b", NULL) != NULL,
		    "the second font is not in the registry");

	TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading every font");
	TEST_ASSERT(errctx, AKGL_REGISTRY_FONT == 0,
		    "unloading every font left the registry behind");

	// Shutdown paths run after partial startups, so a second call and a
	// call against no registry at all are both success.
	TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading every font again");

	// And the subsystem comes back with a fresh registry.
	CATCH(errctx, akgl_registry_init_font());
	TEST_EXPECT_OK(errctx, akgl_text_loadfont("unloadall_c", TEST_FONT_PATH, 16),
		       "loading a font after unloading them all");
	TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading that one too");
    } CLEANUP {
	if ( AKGL_REGISTRY_FONT == 0 ) {
	    IGNORE(akgl_registry_init_font());
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

int main(void)
{
    PREPARE_ERROR(errctx);

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

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	memset(&testbackend, 0x00, sizeof(akgl_RenderBackend));
	FAIL_ZERO_BREAK(
	    errctx,
	    SDL_Init(SDL_INIT_VIDEO),
	    AKGL_ERR_SDL,
	    "Couldn't initialize SDL: %s",
	    SDL_GetError());
	FAIL_ZERO_BREAK(
	    errctx,
	    TTF_Init(),
	    AKGL_ERR_SDL,
	    "Couldn't initialize the font engine: %s",
	    SDL_GetError());

	testfont = TTF_OpenFont(TEST_FONT_PATH, TEST_FONT_SIZE);
	FAIL_ZERO_BREAK(
	    errctx,
	    testfont,
	    AKGL_ERR_SDL,
	    "Couldn't open %s: %s",
	    TEST_FONT_PATH,
	    SDL_GetError());

	// The host owns the window and libakgl binds to it, which is the
	// arrangement akgl_render_2d_bind exists for and the one an embedded
	// interpreter drawing text is in.
	FAIL_ZERO_BREAK(
	    errctx,
	    SDL_CreateWindowAndRenderer(
		"net/aklabs/libakgl/test_text",
		TEST_TARGET_SIZE,
		TEST_TARGET_SIZE,
		0,
		&akgl_window,
		&testbackend.sdl_renderer),
	    AKGL_ERR_SDL,
	    "Couldn't create window/renderer: %s",
	    SDL_GetError());
	CATCH(errctx, akgl_render_2d_bind(&testbackend));

	CATCH(errctx, test_text_loadfont());
	CATCH(errctx, test_text_measure());
	CATCH(errctx, test_text_measure_wrapped());
	CATCH(errctx, test_text_render_backend_guards());
	CATCH(errctx, test_text_rendertextat());
	CATCH(errctx, test_text_unloadallfonts());
    } CLEANUP {
	if ( testfont != NULL ) {
	    TTF_CloseFont(testfont);
	}
	TTF_Quit();
	SDL_Quit();
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);
}
