/**
 * @file ui.c
 * @brief Implements the UI subsystem: clay's arena, fonts, rendering and lifecycle.
 */

#include <stdarg.h>
#include <stddef.h>
#include <stdint.h>

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

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

/**
 * @brief Bytes of the first clay error's text kept for reporting.
 *
 * Sized to hold every message clay 0.14 actually emits -- they are one
 * sentence each -- with room for a longer one to truncate into rather than
 * overflow.
 */
#define UI_ERROR_TEXT_LENGTH    256

/** @brief The arena clay lays its structures out in. Static storage, so this file allocates nothing. */
static uint8_t ui_arena_bytes[AKGL_UI_ARENA_BYTES];
/** @brief Usable bytes. #AKGL_UI_ARENA_BYTES unless a test has narrowed it. */
static size_t ui_arena_usable = AKGL_UI_ARENA_BYTES;
/** @brief The context Clay_Initialize handed back, or NULL before init / after shutdown. */
static Clay_Context *ui_context;
/** @brief Whether a frame is open -- between frame_begin and frame_end. */
static bool ui_in_frame;

/**
 * @brief The fontId table: slot N holds the registry name behind clay fontId N.
 *
 * Names, not `TTF_Font *` handles -- fonts are not reference counted, and a
 * name resolved per use through #AKGL_REGISTRY_FONT reports "gone" honestly
 * where a cached handle would dangle.
 */
static char ui_fonts[AKGL_UI_MAX_FONTS][AKGL_UI_FONT_NAME_LENGTH];
/** @brief Slots of #ui_fonts in use. Ids 0 to this minus one are live. */
static int ui_font_count;

/** @brief Scratch one TEXT render command's line is copied through, so SDL_ttf gets a C string. */
static char ui_text_scratch[AKGL_UI_MAX_TEXT_BYTES];

/**
 * @brief Pixels one wheel notch scrolls a clay scroll container.
 *
 * SDL reports the wheel in notches; clay wants pixels. 32 is three lines of
 * 16pt text per notch, which is what desktop toolkits converge on.
 */
#define UI_WHEEL_NOTCH_PIXELS   32.0f

/** @brief Where the pointer last was, in layout pixels. Fed to clay per event. */
static float ui_pointer_x;
/** @brief Vertical half of the pointer position. */
static float ui_pointer_y;
/** @brief Whether the left button is currently held. */
static bool ui_pointer_down;
/**
 * @brief A left press arrived since the last frame_begin latched.
 *
 * Widgets read the *latched* copy (#ui_pointer_pressed) rather than clay's
 * `Clay_PointerData.state`, because clay advances its press-edge per
 * Clay_SetPointerState call and several of those can land between two
 * layouts -- one per motion event -- which turns "pressed this frame" into
 * "pressed since the last mouse event", an edge no widget can trust.
 */
static bool ui_pointer_pending_pressed;
/** @brief The frame-stable press edge: one left press happened before this frame. */
static bool ui_pointer_pressed;
/** @brief Motion arrived since the last frame_begin latched. Companion to the press edge. */
static bool ui_pointer_pending_moved;
/**
 * @brief The frame-stable motion edge: the pointer moved before this frame.
 *
 * What keeps a menu's hover-selection polite. A stationary pointer parked
 * over a row must not re-claim the selection every frame -- the keyboard
 * would fight it and lose, sixty times a second -- so hover only moves the
 * selection on frames the mouse actually moved. Found the way these things
 * are found: the demo script pressed Up and the selection did not move.
 */
static bool ui_pointer_moved;
/** @brief Wheel notches accumulated since the last frame_begin, horizontal. */
static float ui_wheel_x;
/** @brief Wheel notches accumulated since the last frame_begin, vertical. */
static float ui_wheel_y;
/** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */
static uint64_t ui_last_frame_ns;

/** @brief The layout width akgl_ui_init or the last resize established. The dialog spans it. */
static int ui_layout_width;
/** @brief The layout height, kept alongside #ui_layout_width. */
static int ui_layout_height;

/**
 * @brief What `NULL` means wherever a widget takes a style.
 *
 * The JRPG textbox's palette, on purpose: the widget that replaces that panel
 * should reproduce it, so the two are comparable pixel for pixel.
 *
 *  Field           Value                   Meaning
 */
static const akgl_UiStyle ui_default_style = {
    {  24,  20,  37, 235 },     /* fill: near-black, slightly translucent  */
    { 240, 236, 214, 255 },     /* edge: parchment                         */
    { 240, 236, 214, 255 },     /* ink:  parchment                         */
    8.0f,                       /* padding                                 */
    0.0f,                       /* corner_radius: square                   */
    0                           /* fontid: the first font registered       */
};

/**
 * @brief First error clay reported since the stash was last cleared.
 *
 * clay's error handler is a void callback with no way to refuse or return,
 * and the measure callback's signature is no better -- a layout error cannot
 * surface at the call that caused it, because libakgl is nowhere on the stack
 * when it happens. Both stash the first message here and akgl_ui_frame_end
 * raises it, which is the earliest point the error protocol can carry it.
 * Later errors in the same frame are counted but not kept: the first one is
 * almost always the cause and the rest its consequences.
 */
static char ui_clay_error_text[UI_ERROR_TEXT_LENGTH];
/** @brief How many errors have been stashed since the stash was cleared. */
static uint32_t ui_clay_error_count;

/**
 * @brief Log a layout-time failure now and stash the first one for frame_end.
 *
 * The shared back half of the clay error handler and the measure callback --
 * the two places an error can happen with no way to return it.
 *
 * @param format printf format for the message, followed by its arguments.
 */
static void ui_stash_error(const char *format, ...)
{
    va_list args;
    int count = 0;

    ui_clay_error_count += 1;
    if ( ui_clay_error_count > 1 ) {
	// Still worth logging: the operator watching the log sees the whole
	// cascade, even though frame_end will raise only the first.
	va_start(args, format);
	SDL_LogMessageV(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_ERROR, format, args);
	va_end(args);
	return;
    }
    va_start(args, format);
    IGNORE(aksl_vsnprintf(&count, ui_clay_error_text, sizeof(ui_clay_error_text), format, args));
    va_end(args);
    SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", ui_clay_error_text);
}

/**
 * @brief The error handler given to Clay_Initialize.
 *
 * Clay_String is a length and a pointer, not a C string, so the text goes
 * through the stash with an explicit precision.
 */
static void ui_clay_error(Clay_ErrorData error)
{
    ui_stash_error("clay: %.*s", (int)error.errorText.length, error.errorText.chars);
}

/**
 * @brief Resolve a clay fontId to the live font handle behind it.
 *
 * @param fontid The id a text element carried.
 * @param dest   Receives the font. Assumed non-`NULL`; internal callers only.
 * @return `NULL` on success, otherwise an error context owned by the caller.
 * @throws AKGL_ERR_UI If the id was never registered, or the name behind it
 *         has left the registry since.
 */
static akerr_ErrorContext *ui_font_for_id(uint16_t fontid, TTF_Font **dest)
{
    PREPARE_ERROR(errctx);
    FAIL_NONZERO_RETURN(
	errctx,
	((int)fontid >= ui_font_count),
	AKGL_ERR_UI,
	"fontId %u was never registered; akgl_ui_font_register has issued %d",
	fontid,
	ui_font_count);
    *dest = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[fontid], NULL);
    FAIL_ZERO_RETURN(
	errctx,
	*dest,
	AKGL_ERR_UI,
	"The font named %s behind fontId %u is no longer in the registry",
	ui_fonts[fontid],
	fontid);
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_init(int width, int height)
{
    uint32_t needed = 0;
    Clay_Arena arena;
    Clay_ErrorHandler handler = { &ui_clay_error, NULL };
    Clay_Dimensions dimensions;

    PREPARE_ERROR(errctx);
    FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width);
    FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height);
    FAIL_NONZERO_RETURN(
	errctx,
	(ui_context != NULL),
	AKGL_ERR_UI,
	"The UI subsystem is already initialized; akgl_ui_shutdown first");

    // Ceilings before measurement: Clay_MinMemorySize reports what the
    // *current* ceilings need, so they must be set first. Element count
    // before word count, and not the other way around -- with no context
    // yet, Clay_SetMaxElementCount also overwrites the word-cache default
    // with elements * 2, and would silently undo a word count set before it.
    Clay_SetMaxElementCount(AKGL_UI_MAX_ELEMENTS);
    Clay_SetMaxMeasureTextCacheWordCount(AKGL_UI_MAX_MEASURE_WORDS);
    needed = Clay_MinMemorySize();
    FAIL_NONZERO_RETURN(
	errctx,
	((size_t)needed > ui_arena_usable),
	AKGL_ERR_UI,
	"clay needs %u bytes for %d elements and %d measured words; the arena holds %u. Raise AKGL_UI_ARENA_BYTES.",
	needed,
	AKGL_UI_MAX_ELEMENTS,
	AKGL_UI_MAX_MEASURE_WORDS,
	(uint32_t)ui_arena_usable);

    ui_clay_error_text[0] = '\0';
    ui_clay_error_count = 0;
    arena = Clay_CreateArenaWithCapacityAndMemory(ui_arena_usable, ui_arena_bytes);
    dimensions.width = (float)width;
    dimensions.height = (float)height;
    ui_context = Clay_Initialize(arena, dimensions, handler);
    // The capacity check above is the one that should fail; this one exists
    // because Clay_Initialize reports refusal by returning NULL and a NULL
    // context would otherwise surface as a crash inside the first CLAY()
    // block, far from the cause.
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "Clay_Initialize refused: %s", ui_clay_error_text);
    // Needs the context Clay_Initialize just made current, so it cannot move
    // earlier. Without a measure function every text element is an error.
    Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL);
    ui_layout_width = width;
    ui_layout_height = height;
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_shutdown(void)
{
    PREPARE_ERROR(errctx);
    // clay holds no resource but the arena, and the arena is ours: there is
    // nothing to hand back, so shutdown is forgetting. The next init lays a
    // fresh context over the same bytes.
    ui_context = NULL;
    ui_in_frame = false;
    ui_font_count = 0;
    ui_clay_error_text[0] = '\0';
    ui_clay_error_count = 0;
    ui_pointer_x = 0.0f;
    ui_pointer_y = 0.0f;
    ui_pointer_down = false;
    ui_pointer_pending_pressed = false;
    ui_pointer_pressed = false;
    ui_pointer_pending_moved = false;
    ui_pointer_moved = false;
    ui_wheel_x = 0.0f;
    ui_wheel_y = 0.0f;
    ui_last_frame_ns = 0;
    ui_layout_width = 0;
    ui_layout_height = 0;
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_resize(int width, int height)
{
    Clay_Dimensions dimensions;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
    FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width);
    FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height);
    dimensions.width = (float)width;
    dimensions.height = (float)height;
    Clay_SetLayoutDimensions(dimensions);
    ui_layout_width = width;
    ui_layout_height = height;
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_font_register(char *name, uint16_t *fontid)
{
    int i = 0;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
    FAIL_ZERO_RETURN(errctx, fontid, AKERR_NULLPOINTER, "Null fontid destination");
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
    FAIL_ZERO_RETURN(
	errctx,
	SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL),
	AKGL_ERR_UI,
	"No font named %s in the registry; akgl_text_loadfont first",
	name);
    for ( i = 0; i < ui_font_count; i++ ) {
	if ( SDL_strcmp(ui_fonts[i], name) == 0 ) {
	    *fontid = (uint16_t)i;
	    SUCCEED_RETURN(errctx);
	}
    }
    FAIL_NONZERO_RETURN(
	errctx,
	(ui_font_count >= AKGL_UI_MAX_FONTS),
	AKGL_ERR_UI,
	"All %d fontId slots are taken. Raise AKGL_UI_MAX_FONTS.",
	AKGL_UI_MAX_FONTS);
    PASS(errctx, aksl_strcpy(ui_fonts[ui_font_count], AKGL_UI_FONT_NAME_LENGTH, name));
    *fontid = (uint16_t)ui_font_count;
    ui_font_count += 1;
    SUCCEED_RETURN(errctx);
}

Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData)
{
    Clay_Dimensions dimensions = { 0.0f, 0.0f };
    TTF_Font *font = NULL;
    int w = 0;
    int h = 0;

    (void)userData;
    // No error protocol in here: clay's callback signature returns dimensions
    // by value, so failure is zero-by-zero plus a stashed message for
    // frame_end. The checks mirror ui_font_for_id without borrowing it,
    // because a context prepared here would have nowhere to go.
    if ( config == NULL ) {
	ui_stash_error("Text measurement was asked for with no element configuration");
	return dimensions;
    }
    if ( (int)config->fontId >= ui_font_count ) {
	ui_stash_error(
	    "fontId %u was never registered; akgl_ui_font_register has issued %d",
	    config->fontId,
	    ui_font_count);
	return dimensions;
    }
    font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[config->fontId], NULL);
    if ( font == NULL ) {
	ui_stash_error(
	    "The font named %s behind fontId %u is no longer in the registry",
	    ui_fonts[config->fontId],
	    config->fontId);
	return dimensions;
    }
    if ( text.length == 0 ) {
	return dimensions;
    }
    // The slice is not NUL-terminated and does not need to be: SDL_ttf takes
    // an explicit byte length, so the measurement happens in place with no
    // copy -- this callback runs for every word clay has not cached.
    if ( TTF_GetStringSize(font, text.chars, (size_t)text.length, &w, &h) == false ) {
	ui_stash_error("Measuring %d bytes of text failed: %s", text.length, SDL_GetError());
	return dimensions;
    }
    dimensions.width = (float)w;
    dimensions.height = (float)h;
    return dimensions;
}

/** @brief Clamp one clay colour channel (a float, conventionally 0-255) to a byte. */
static uint8_t ui_color_channel(float value)
{
    if ( value <= 0.0f ) {
	return 0;
    }
    if ( value >= 255.0f ) {
	return 255;
    }
    return (uint8_t)(value + 0.5f);
}

/** @brief Convert a clay colour to the SDL_Color every draw primitive takes. */
static SDL_Color ui_color_from_clay(Clay_Color color)
{
    SDL_Color out;

    out.r = ui_color_channel(color.r);
    out.g = ui_color_channel(color.g);
    out.b = ui_color_channel(color.b);
    out.a = ui_color_channel(color.a);
    return out;
}

/**
 * @brief Draw one BORDER command: four edge fills and up to four corner arcs.
 *
 * The edges are shortened by the corner radius and the corners are stroked as
 * quarter arcs of the adjacent widths' larger value -- the approximation is
 * only visible when two adjacent sides have different widths *and* a radius,
 * which nothing in this library produces.
 *
 * @param self The backend. Assumed checked by the caller.
 * @param command The BORDER render command.
 * @return `NULL` on success, otherwise an error context owned by the caller.
 * @throws AKERR_* Whatever the fills and arcs underneath raise.
 */
static akerr_ErrorContext *ui_execute_border(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
    Clay_BorderRenderData *border = &command->renderData.border;
    Clay_BoundingBox box = command->boundingBox;
    SDL_Color color;
    SDL_FRect edge;
    float32_t radius = border->cornerRadius.topLeft;
    float32_t thickness = 0.0f;

    PREPARE_ERROR(errctx);
    color = ui_color_from_clay(border->color);
    if ( border->width.left > 0 ) {
	edge.x = box.x;
	edge.y = box.y + radius;
	edge.w = (float32_t)border->width.left;
	edge.h = box.height - (radius * 2.0f);
	PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
    }
    if ( border->width.right > 0 ) {
	edge.x = box.x + box.width - (float32_t)border->width.right;
	edge.y = box.y + radius;
	edge.w = (float32_t)border->width.right;
	edge.h = box.height - (radius * 2.0f);
	PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
    }
    if ( border->width.top > 0 ) {
	edge.x = box.x + radius;
	edge.y = box.y;
	edge.w = box.width - (radius * 2.0f);
	edge.h = (float32_t)border->width.top;
	PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
    }
    if ( border->width.bottom > 0 ) {
	edge.x = box.x + radius;
	edge.y = box.y + box.height - (float32_t)border->width.bottom;
	edge.w = box.width - (radius * 2.0f);
	edge.h = (float32_t)border->width.bottom;
	PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
    }
    if ( radius > 0.0f ) {
	thickness = (float32_t)((border->width.top > border->width.left) ? border->width.top : border->width.left);
	if ( thickness > 0.0f ) {
	    PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + radius, radius, 180.0f, 270.0f, thickness, color));
	}
	thickness = (float32_t)((border->width.top > border->width.right) ? border->width.top : border->width.right);
	if ( thickness > 0.0f ) {
	    PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + radius, radius, 270.0f, 360.0f, thickness, color));
	}
	thickness = (float32_t)((border->width.bottom > border->width.right) ? border->width.bottom : border->width.right);
	if ( thickness > 0.0f ) {
	    PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + box.height - radius, radius, 0.0f, 90.0f, thickness, color));
	}
	thickness = (float32_t)((border->width.bottom > border->width.left) ? border->width.bottom : border->width.left);
	if ( thickness > 0.0f ) {
	    PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + box.height - radius, radius, 90.0f, 180.0f, thickness, color));
	}
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Draw one TEXT command through akgl_text_rendertextat().
 *
 * clay wraps text itself -- each command is one line -- so the line is drawn
 * unwrapped at the command's position. The slice is copied through the
 * bounded scratch because akgl_text_rendertextat takes a C string.
 *
 * @param self The backend. Assumed checked by the caller.
 * @param command The TEXT render command.
 * @return `NULL` on success, otherwise an error context owned by the caller.
 * @throws AKGL_ERR_UI If the line exceeds #AKGL_UI_MAX_TEXT_BYTES or the
 *         fontId does not resolve.
 * @throws AKERR_* Whatever the copy or the draw underneath raises.
 */
static akerr_ErrorContext *ui_execute_text(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
    Clay_TextRenderData *text = &command->renderData.text;
    TTF_Font *font = NULL;

    PREPARE_ERROR(errctx);
    if ( text->stringContents.length == 0 ) {
	SUCCEED_RETURN(errctx);
    }
    FAIL_NONZERO_RETURN(
	errctx,
	(text->stringContents.length >= (int32_t)sizeof(ui_text_scratch)),
	AKGL_ERR_UI,
	"A text run of %d bytes exceeds AKGL_UI_MAX_TEXT_BYTES (%d)",
	text->stringContents.length,
	(int)sizeof(ui_text_scratch));
    PASS(errctx, ui_font_for_id(text->fontId, &font));
    PASS(errctx, aksl_strncpy(
	     ui_text_scratch,
	     sizeof(ui_text_scratch),
	     text->stringContents.chars,
	     (size_t)text->stringContents.length));
    PASS(errctx, akgl_text_rendertextat(
	     font,
	     ui_text_scratch,
	     ui_color_from_clay(text->textColor),
	     0,
	     (int)command->boundingBox.x,
	     (int)command->boundingBox.y));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Draw one IMAGE command: a sprite's first frame stretched to the box.
 *
 * @param self The backend. Assumed checked by the caller, `draw_texture`
 *             included.
 * @param command The IMAGE render command, whose `imageData` is an
 *                `akgl_Sprite *` by the contract in akgl/ui.h.
 * @return `NULL` on success, otherwise an error context owned by the caller.
 * @throws AKGL_ERR_UI If the command carries no sprite, or the sprite has no
 *         sheet behind it.
 * @throws AKERR_* Whatever the frame lookup or the draw underneath raises.
 */
static akerr_ErrorContext *ui_execute_image(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
    akgl_Sprite *sprite = (akgl_Sprite *)command->renderData.image.imageData;
    SDL_FRect src;
    SDL_FRect dest;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(
	errctx,
	sprite,
	AKGL_ERR_UI,
	"An IMAGE element carries no sprite; .image.imageData must be an akgl_Sprite pointer");
    FAIL_ZERO_RETURN(
	errctx,
	sprite->sheet,
	AKGL_ERR_UI,
	"The sprite named %s behind an IMAGE element has no sheet",
	sprite->name);
    PASS(errctx, akgl_spritesheet_coords_for_frame(sprite, &src, sprite->frameids[0]));
    dest.x = command->boundingBox.x;
    dest.y = command->boundingBox.y;
    dest.w = command->boundingBox.width;
    dest.h = command->boundingBox.height;
    PASS(errctx, self->draw_texture(self, sprite->sheet->texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands)
{
    Clay_RenderCommand *command = NULL;
    SDL_FRect fill;
    SDL_Rect clip;
    bool clipped = false;
    static bool custom_skip_logged = false;
    int32_t i = 0;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
    FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
    FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
    FAIL_ZERO_RETURN(errctx, commands, AKERR_NULLPOINTER, "NULL command array");

    ATTEMPT {
	// The loop is the whole ATTEMPT body on purpose: CATCH reports
	// failure by break, which must leave the block, and here leaving the
	// loop is leaving the block.
	for ( i = 0; i < commands->length; i++ ) {
	    command = Clay_RenderCommandArray_Get(commands, i);
	    switch ( command->commandType ) {
	    case CLAY_RENDER_COMMAND_TYPE_RECTANGLE:
		fill.x = command->boundingBox.x;
		fill.y = command->boundingBox.y;
		fill.w = command->boundingBox.width;
		fill.h = command->boundingBox.height;
		CATCH(errctx, akgl_draw_filled_rounded_rect(
			  self,
			  &fill,
			  command->renderData.rectangle.cornerRadius.topLeft,
			  ui_color_from_clay(command->renderData.rectangle.backgroundColor)));
		break;
	    case CLAY_RENDER_COMMAND_TYPE_BORDER:
		CATCH(errctx, ui_execute_border(self, command));
		break;
	    case CLAY_RENDER_COMMAND_TYPE_TEXT:
		CATCH(errctx, ui_execute_text(self, command));
		break;
	    case CLAY_RENDER_COMMAND_TYPE_IMAGE:
		CATCH(errctx, ui_execute_image(self, command));
		break;
	    case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
		clip.x = (int)command->boundingBox.x;
		clip.y = (int)command->boundingBox.y;
		clip.w = (int)command->boundingBox.width;
		clip.h = (int)command->boundingBox.height;
		CATCH(errctx, akgl_draw_set_clip(self, &clip));
		clipped = true;
		break;
	    case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END:
		CATCH(errctx, akgl_draw_set_clip(self, NULL));
		clipped = false;
		break;
	    case CLAY_RENDER_COMMAND_TYPE_CUSTOM:
		// Skipped in this version, once in the log rather than once
		// per frame -- sixty repeats a second is how a log gets
		// ignored.
		if ( custom_skip_logged == false ) {
		    SDL_Log("akgl_ui: CUSTOM render commands are not handled and were skipped");
		    custom_skip_logged = true;
		}
		break;
	    default:
		break;
	    }
	    // A CATCH inside a case reports failure with `break`, and that
	    // break binds to the *switch* -- so a failed command falls out
	    // here with errctx carrying the failure, and this is what leaves
	    // the loop. On success errctx is still NULL; check the pointer,
	    // not a status through it.
	    if ( errctx != NULL ) {
		break;
	    }
	}
    } CLEANUP {
	// A failed frame must not leave the next frame's world clipped.
	if ( clipped == true ) {
	    IGNORE(akgl_draw_set_clip(self, NULL));
	}
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Report whether the pointer is over any element of the retained layout.
 *
 * The one question the consumed contract turns on. Asked of clay rather than
 * answered from our own geometry, so floating elements, z-order and culling
 * all mean whatever clay says they mean -- with one exception: clay wraps
 * every layout in an internal `Clay__RootContainer` sized to the whole
 * screen, so "over anything" naively answers yes for every pixel of the
 * window. The root is excluded here; being inside the window is not being
 * over the interface.
 */
static bool ui_pointer_over_anything(void)
{
    static uint32_t rootid = 0;
    Clay_ElementIdArray over = Clay_GetPointerOverIds();
    int32_t i = 0;

    if ( rootid == 0 ) {
	rootid = Clay_GetElementId(CLAY_STRING("Clay__RootContainer")).id;
    }
    for ( i = 0; i < over.length; i++ ) {
	if ( over.internalArray[i].id != rootid ) {
	    return true;
	}
    }
    return false;
}

akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed)
{
    Clay_Vector2 position;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
    FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
    FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination");
    *consumed = false;
    // Inert until initialized, like the rest of the subsystem: the host is
    // told "not mine" rather than "not ready", so its event loop needs no
    // knowledge of whether the UI is up.
    if ( ui_context == NULL ) {
	SUCCEED_RETURN(errctx);
    }

    switch ( event->type ) {
    case SDL_EVENT_MOUSE_MOTION:
	ui_pointer_x = event->motion.x;
	ui_pointer_y = event->motion.y;
	ui_pointer_pending_moved = true;
	position.x = ui_pointer_x;
	position.y = ui_pointer_y;
	// Fed per event, not per frame, which is the usage clay documents:
	// hover tracks the pointer through fast motion instead of sampling
	// wherever it happened to be when the frame began.
	Clay_SetPointerState(position, ui_pointer_down);
	break;
    case SDL_EVENT_MOUSE_BUTTON_DOWN:
	if ( event->button.button != SDL_BUTTON_LEFT ) {
	    break;
	}
	ui_pointer_x = event->button.x;
	ui_pointer_y = event->button.y;
	ui_pointer_down = true;
	ui_pointer_pending_pressed = true;
	position.x = ui_pointer_x;
	position.y = ui_pointer_y;
	Clay_SetPointerState(position, true);
	*consumed = ui_pointer_over_anything();
	break;
    case SDL_EVENT_MOUSE_BUTTON_UP:
	if ( event->button.button != SDL_BUTTON_LEFT ) {
	    break;
	}
	ui_pointer_x = event->button.x;
	ui_pointer_y = event->button.y;
	ui_pointer_down = false;
	position.x = ui_pointer_x;
	position.y = ui_pointer_y;
	Clay_SetPointerState(position, false);
	// The release is consumed on the same terms as the press: a click
	// that began on a panel must not leak its other half to the game.
	*consumed = ui_pointer_over_anything();
	break;
    case SDL_EVENT_MOUSE_WHEEL:
	ui_wheel_x += event->wheel.x;
	ui_wheel_y += event->wheel.y;
	*consumed = ui_pointer_over_anything();
	break;
    case SDL_EVENT_WINDOW_RESIZED:
	PASS(errctx, akgl_ui_resize(event->window.data1, event->window.data2));
	break;
    default:
	break;
    }
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_frame_begin(void)
{
    Clay_Vector2 scroll;
    uint64_t now = 0;
    float dt = 0.0f;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
    FAIL_NONZERO_RETURN(
	errctx,
	ui_in_frame,
	AKGL_ERR_UI,
	"A UI frame is already open; a frame_begin/frame_begin sequence means a frame_end went missing");
    ui_clay_error_text[0] = '\0';
    ui_clay_error_count = 0;

    // Latch the press and motion edges for the widgets this frame declares,
    // then let the event loop start accumulating the next ones.
    ui_pointer_pressed = ui_pointer_pending_pressed;
    ui_pointer_pending_pressed = false;
    ui_pointer_moved = ui_pointer_pending_moved;
    ui_pointer_pending_moved = false;

    now = SDL_GetTicksNS();
    if ( ui_last_frame_ns != 0 ) {
	dt = (float)(now - ui_last_frame_ns) / (float)AKGL_TIME_ONESEC_NS;
    }
    ui_last_frame_ns = now;
    scroll.x = ui_wheel_x * UI_WHEEL_NOTCH_PIXELS;
    scroll.y = ui_wheel_y * UI_WHEEL_NOTCH_PIXELS;
    ui_wheel_x = 0.0f;
    ui_wheel_y = 0.0f;
    Clay_UpdateScrollContainers(false, scroll, dt);

    Clay_BeginLayout();
    ui_in_frame = true;
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self)
{
    Clay_RenderCommandArray commands;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
    FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
    FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
    FAIL_NONZERO_RETURN(
	errctx,
	(ui_in_frame == false),
	AKGL_ERR_UI,
	"No UI frame is open; akgl_ui_frame_begin starts one");
    // Closed before anything can fail, so one bad frame is one bad frame and
    // the next frame_begin is legal rather than "already open".
    ui_in_frame = false;
    commands = Clay_EndLayout();
    // Layout errors take precedence over drawing: a layout that failed is not
    // a layout, and drawing its partial commands would only bury the cause
    // under whatever the draw then reported.
    FAIL_NONZERO_RETURN(
	errctx,
	(ui_clay_error_count > 0),
	AKGL_ERR_UI,
	"clay reported %u layout error(s); the first: %s",
	ui_clay_error_count,
	ui_clay_error_text);
    PASS(errctx, akgl_ui_execute_commands(self, &commands));
    SUCCEED_RETURN(errctx);
}

/** @brief Wrap a caller's C string as the length-and-pointer form clay takes. Borrowed, not copied. */
static Clay_String ui_string_from(char *text)
{
    Clay_String out;

    out.isStaticallyAllocated = false;
    out.length = (int32_t)SDL_strlen(text);
    out.chars = text;
    return out;
}

/** @brief Convert an SDL_Color to clay's float 0-255 convention. */
static Clay_Color ui_clay_color(SDL_Color color)
{
    Clay_Color out;

    out.r = (float)color.r;
    out.g = (float)color.g;
    out.b = (float)color.b;
    out.a = (float)color.a;
    return out;
}

/**
 * @brief The two checks every widget makes before declaring anything.
 *
 * @return `NULL` on success, otherwise an error context owned by the caller.
 * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is
 *         open.
 */
static akerr_ErrorContext *ui_widget_ready(void)
{
    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
    FAIL_NONZERO_RETURN(
	errctx,
	(ui_in_frame == false),
	AKGL_ERR_UI,
	"Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end");
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style)
{
    const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style;
    uint16_t pad = (uint16_t)look->padding;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null dialog id");
    FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null dialog text");
    PASS(errctx, ui_widget_ready());

    CLAY({
	    .id = CLAY_SIDI(ui_string_from(id), 0),
	    .layout = {
		.sizing = {
		    .width = CLAY_SIZING_FIXED((float)ui_layout_width - (look->padding * 2.0f)),
		    .height = CLAY_SIZING_FIXED(AKGL_UI_DIALOG_HEIGHT)
		},
		.padding = { pad, pad, pad, pad }
	    },
	    .backgroundColor = ui_clay_color(look->fill),
	    .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
	    .border = {
		.color = ui_clay_color(look->edge),
		.width = { 1, 1, 1, 1, 0 }
	    },
	    .floating = {
		.offset = { 0.0f, -look->padding },
		.attachPoints = {
		    .element = CLAY_ATTACH_POINT_CENTER_BOTTOM,
		    .parent = CLAY_ATTACH_POINT_CENTER_BOTTOM
		},
		.attachTo = CLAY_ATTACH_TO_ROOT
	    }
	}) {
	CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({
		    .textColor = ui_clay_color(look->ink),
		    .fontId = look->fontid
		}));
    }
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style)
{
    const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style;
    uint16_t pad = (uint16_t)look->padding;
    Clay_FloatingAttachPointType attach = CLAY_ATTACH_POINT_LEFT_TOP;
    Clay_Vector2 offset = { 0.0f, 0.0f };

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null label id");
    FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null label text");
    PASS(errctx, ui_widget_ready());

    /*
     *  Anchor                        Attach point                    Inset
     */
    switch ( anchor ) {
    case AKGL_UI_ANCHOR_TOP_LEFT:
	attach = CLAY_ATTACH_POINT_LEFT_TOP;
	offset.x = look->padding;
	offset.y = look->padding;
	break;
    case AKGL_UI_ANCHOR_TOP_RIGHT:
	attach = CLAY_ATTACH_POINT_RIGHT_TOP;
	offset.x = -look->padding;
	offset.y = look->padding;
	break;
    case AKGL_UI_ANCHOR_BOTTOM_LEFT:
	attach = CLAY_ATTACH_POINT_LEFT_BOTTOM;
	offset.x = look->padding;
	offset.y = -look->padding;
	break;
    case AKGL_UI_ANCHOR_BOTTOM_RIGHT:
	attach = CLAY_ATTACH_POINT_RIGHT_BOTTOM;
	offset.x = -look->padding;
	offset.y = -look->padding;
	break;
    case AKGL_UI_ANCHOR_CENTER:
	attach = CLAY_ATTACH_POINT_CENTER_CENTER;
	break;
    default:
	FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Anchor %d is not an akgl_UiAnchor", (int)anchor);
    }

    CLAY({
	    .id = CLAY_SIDI(ui_string_from(id), 0),
	    .layout = {
		.padding = { pad, pad, pad, pad }
	    },
	    .backgroundColor = ui_clay_color(look->fill),
	    .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
	    .floating = {
		.offset = offset,
		.attachPoints = { .element = attach, .parent = attach },
		.attachTo = CLAY_ATTACH_TO_ROOT
	    }
	}) {
	CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({
		    .textColor = ui_clay_color(look->ink),
		    .fontId = look->fontid
		}));
    }
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu)
{
    const akgl_UiStyle *look = NULL;
    uint16_t pad = 0;
    Clay_String idstring;
    Clay_Color rowink;
    Clay_Color rowfill;
    bool rowselected = false;
    int32_t i = 0;

    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu");
    FAIL_ZERO_RETURN(errctx, menu->id, AKERR_NULLPOINTER, "Null menu id");
    FAIL_NONZERO_RETURN(
	errctx,
	((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)),
	AKERR_OUTOFBOUNDS,
	"A menu carries 1 to %d entries; this one claims %d",
	AKGL_UI_MENU_MAX_ITEMS,
	menu->count);
    FAIL_NONZERO_RETURN(
	errctx,
	((menu->selected < 0) || (menu->selected >= menu->count)),
	AKERR_OUTOFBOUNDS,
	"Selection %d is outside this menu's %d entries",
	menu->selected,
	menu->count);
    for ( i = 0; i < menu->count; i++ ) {
	FAIL_ZERO_RETURN(errctx, menu->items[i], AKERR_NULLPOINTER, "Menu entry %d is NULL", i);
    }
    PASS(errctx, ui_widget_ready());

    look = ( menu->style != NULL ) ? menu->style : &ui_default_style;
    pad = (uint16_t)look->padding;
    idstring = ui_string_from(menu->id);

    CLAY({
	    .id = CLAY_SIDI(idstring, 0),
	    .layout = {
		.padding = { pad, pad, pad, pad },
		.layoutDirection = CLAY_TOP_TO_BOTTOM
	    },
	    .backgroundColor = ui_clay_color(look->fill),
	    .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
	    .border = {
		.color = ui_clay_color(look->edge),
		.width = { 1, 1, 1, 1, 0 }
	    },
	    .floating = {
		.attachPoints = {
		    .element = CLAY_ATTACH_POINT_CENTER_CENTER,
		    .parent = CLAY_ATTACH_POINT_CENTER_CENTER
		},
		.attachTo = CLAY_ATTACH_TO_ROOT
	    }
	}) {
	for ( i = 0; i < menu->count; i++ ) {
	    // The selected row draws inverted. An unselected row declares no
	    // background at all -- alpha 0 emits no render command -- so the
	    // panel's fill shows through rather than being repainted.
	    rowselected = (i == menu->selected);
	    rowink = rowselected ? ui_clay_color(look->fill) : ui_clay_color(look->ink);
	    rowfill = rowselected ? ui_clay_color(look->ink) : (Clay_Color){ 0, 0, 0, 0 };
	    CLAY({
		    .id = CLAY_SIDI(idstring, (uint32_t)(i + 1)),
		    .layout = {
			.sizing = { .width = CLAY_SIZING_GROW(0) },
			.padding = { pad, pad, (uint16_t)(pad / 2), (uint16_t)(pad / 2) }
		    },
		    .backgroundColor = rowfill
		}) {
		// Mouse selection: a row claims the selection when the
		// pointer moves onto it or clicks it -- not merely by being
		// under a stationary pointer, which would re-claim it every
		// frame and the keyboard would fight it and lose. The
		// frame-latched press edge on a hovered row confirms. The
		// row highlight catches up next frame, which at any playable
		// frame rate is invisible.
		if ( Clay_Hovered() && (ui_pointer_moved || ui_pointer_pressed) ) {
		    menu->selected = i;
		    if ( ui_pointer_pressed ) {
			menu->activated = true;
		    }
		}
		CLAY_TEXT(ui_string_from(menu->items[i]), CLAY_TEXT_CONFIG({
			    .textColor = rowink,
			    .fontId = look->fontid
			}));
	    }
	}
    }
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed)
{
    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu");
    FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
    FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination");
    FAIL_NONZERO_RETURN(
	errctx,
	((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)),
	AKERR_OUTOFBOUNDS,
	"A menu carries 1 to %d entries; this one claims %d",
	AKGL_UI_MENU_MAX_ITEMS,
	menu->count);
    FAIL_NONZERO_RETURN(
	errctx,
	((menu->selected < 0) || (menu->selected >= menu->count)),
	AKERR_OUTOFBOUNDS,
	"Selection %d is outside this menu's %d entries",
	menu->selected,
	menu->count);
    *consumed = false;
    if ( ui_context == NULL ) {
	SUCCEED_RETURN(errctx);
    }

    switch ( event->type ) {
    case SDL_EVENT_KEY_DOWN:
	if ( event->key.key == SDLK_UP ) {
	    menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1);
	    *consumed = true;
	} else if ( event->key.key == SDLK_DOWN ) {
	    menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1);
	    *consumed = true;
	} else if ( event->key.key == SDLK_RETURN ) {
	    menu->activated = true;
	    *consumed = true;
	}
	break;
    case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
	if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ) {
	    menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1);
	    *consumed = true;
	} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ) {
	    menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1);
	    *consumed = true;
	} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ) {
	    menu->activated = true;
	    *consumed = true;
	}
	break;
    default:
	break;
    }
    SUCCEED_RETURN(errctx);
}

void akgl_ui_arena_limit(size_t limit)
{
    if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) {
	ui_arena_usable = AKGL_UI_ARENA_BYTES;
    } else {
	ui_arena_usable = limit;
    }
}
