/**
 * @file main.c
 * @brief Startup, the frame loop, the screens, and teardown.
 *
 * The startup order is libakgl's one sequence that works (deps/libakgl
 * include/akgl/game.h): metadata, akgl_game_init(), screen properties,
 * akgl_render_2d_init(), a physics backend -- and then, new in this example,
 * the interpreter. The scripts compute, the engine draws; the interpreter is
 * lent no devices at all, so a script that tries SPRITE is refused by name.
 */

#include <stdbool.h>
#include <stddef.h>
#include <string.h>

#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>

#include <akerror.h>
#include <akstdlib.h>

#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/ui.h>

#include "galaga.h"

/** @brief Where the example's assets live. CMake defines it; `--assets` overrides. */
#ifndef GALAGA_ASSET_DIR
#define GALAGA_ASSET_DIR "."
#endif

/** @brief The enemy script. CMake defines it; `--script` overrides. */
#ifndef GALAGA_SCRIPT_PATH
#define GALAGA_SCRIPT_PATH "galaga.bas"
#endif

/** @brief The HUD font. CMake defines it; headless runs still need it for the UI. */
#ifndef GALAGA_FONT_PATH
#define GALAGA_FONT_PATH "font.ttf"
#endif

#define GALAGA_PATH_MAX 1024

galaga_Game galaga_game;
galaga_Shared galaga_shared;

/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
static char *SHOTPATH = NULL;
static int SHOTFRAME = 0;

/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
static int FAILED = 0;

/* ------------------------------------------------------------- starfield --- */

/*
 * No parallax facility exists in libakgl and none is needed: a fixed array of
 * stars advanced per frame and drawn with akgl_draw_point() between
 * frame_start and akgl_game_update(). Two speed bands give the depth for
 * free -- the slow band reads as far away.
 */
#define GALAGA_STARS 96

static struct
{
    float x;
    float y;
    float speed;
    Uint8 bright;
} STARS[GALAGA_STARS];

static void starfield_seed(void)
{
    int i = 0;

    for ( i = 0; i < GALAGA_STARS; i++ ) {
	STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
	STARS[i].y = galaga_random() * (float)GALAGA_VIEW_HEIGHT;
	if ( (i % 2) == 0 ) {
	    STARS[i].speed = 40.0f;		/* the far band  */
	    STARS[i].bright = 110;
	} else {
	    STARS[i].speed = 110.0f;		/* the near band */
	    STARS[i].bright = 220;
	}
    }
}

static akerr_ErrorContext *starfield_draw(void)
{
    SDL_Color color = { 255, 255, 255, 255 };
    int i = 0;
    PREPARE_ERROR(errctx);

    for ( i = 0; i < GALAGA_STARS; i++ ) {
	STARS[i].y += STARS[i].speed * galaga_game.dt;
	if ( STARS[i].y > (float)GALAGA_VIEW_HEIGHT ) {
	    STARS[i].y -= (float)GALAGA_VIEW_HEIGHT;
	    STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
	}
	color.r = STARS[i].bright;
	color.g = STARS[i].bright;
	color.b = STARS[i].bright;
	PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
    }
    SUCCEED_RETURN(errctx);
}

/* ------------------------------------------------------------ screenshots --- */

/**
 * @brief Read the render target back and write it out as a PNG.
 *
 * Called after everything has drawn and before the frame is presented,
 * because SDL_RenderPresent is where the target stops being readable. The
 * figures in docs/20 and docs/21 are output from this program rather than
 * pictures somebody took once, so they cannot show a game that no longer
 * exists.
 */
static akerr_ErrorContext *save_screenshot(char *path)
{
    SDL_Surface *shot = NULL;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError());
    ATTEMPT {
	FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL,
			"IMG_SavePNG(%s): %s", path, SDL_GetError());
    } CLEANUP {
	SDL_DestroySurface(shot);
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SDL_Log("Wrote %s", path);
    SUCCEED_RETURN(errctx);
}

/* ---------------------------------------------------------------- assets --- */

static char *SPRITE_FILES[] = {
    "sprite_galaga_player.json",
    "sprite_galaga_bee.json",
    "sprite_galaga_butterfly.json",
    "sprite_galaga_boss.json",
    "sprite_galaga_boss_hurt.json",
    "sprite_galaga_playershot.json",
    "sprite_galaga_enemyshot.json",
    "sprite_galaga_boom.json",
    NULL
};

static char *CHARACTER_FILES[] = {
    "character_galaga_player.json",
    "character_galaga_bee.json",
    "character_galaga_butterfly.json",
    "character_galaga_boss.json",
    "character_galaga_playershot.json",
    "character_galaga_enemyshot.json",
    "character_galaga_boom.json",
    NULL
};

static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
{
    int count = 0;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
    FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
    FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
    PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Sprites first, characters second. Not a preference: a character's
 * JSON names its sprites by registry name, so a character loaded first fails
 * on the first sprite it cannot find.
 */
static akerr_ErrorContext *load_assets(char *assetdir)
{
    char path[GALAGA_PATH_MAX];
    int i = 0;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
    for ( i = 0; SPRITE_FILES[i] != NULL; i++ ) {
	PASS(errctx, asset_path(assetdir, SPRITE_FILES[i], (char *)&path, sizeof(path)));
	PASS(errctx, akgl_sprite_load_json((char *)&path));
    }
    for ( i = 0; CHARACTER_FILES[i] != NULL; i++ ) {
	PASS(errctx, asset_path(assetdir, CHARACTER_FILES[i], (char *)&path, sizeof(path)));
	PASS(errctx, akgl_character_load_json((char *)&path));
    }
    SUCCEED_RETURN(errctx);
}

/* --------------------------------------------------------------- startup --- */

/** @brief Replacement for akgl_game.lowfpsfunc, which logs a line per frame. */
static void galaga_lowfps(void)
{
}

static akerr_ErrorContext *startup(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
			      "akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
    PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
			      "1.0.0", sizeof(akgl_game.version) - 1));
    PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
			      "net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));

    PASS(errctx, akgl_game_init());
    akgl_game.lowfpsfunc = &galaga_lowfps;

    /* Properties before the renderer: akgl_render_2d_init reads both, and an
     * unset one defaults to the string "0" -- a zero-sized window. */
    PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
    PASS(errctx, akgl_set_property("game.screenheight", "960"));
    PASS(errctx, akgl_render_2d_init(akgl_renderer));

    FAIL_ZERO_RETURN(
	errctx,
	SDL_SetRenderLogicalPresentation(
	    akgl_renderer->sdl_renderer,
	    GALAGA_VIEW_WIDTH,
	    GALAGA_VIEW_HEIGHT,
	    SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
	AKGL_ERR_SDL,
	"%s",
	SDL_GetError()
	);

    /* The view is what the camera looks through, so it says the same thing. */
    akgl_camera->x = 0.0f;
    akgl_camera->y = 0.0f;
    akgl_camera->w = (float)GALAGA_VIEW_WIDTH;
    akgl_camera->h = (float)GALAGA_VIEW_HEIGHT;

    /*
     * akgl_game_init does NOT install a physics backend, whatever physics.h's
     * file comment says (libakgl docs/14-physics.md). Null physics accepts
     * every call and moves nothing: whatever writes x and y directly is the
     * mover, and in this game that is BASIC writing through ACTOR@.
     */
    PASS(errctx, akgl_physics_init_null(akgl_physics));
    SUCCEED_RETURN(errctx);
}

/* ---------------------------------------------------------------- the UI --- */

static akgl_UiMenu TITLE_MENU = {
    "titlemenu", { "START", "QUIT" }, 2, 0, false, NULL
};
static akgl_UiMenu AGAIN_MENU = {
    "againmenu", { "PLAY AGAIN", "QUIT" }, 2, 0, false, NULL
};

/* Clay borrows label text until frame_end, so these cannot be locals. */
static char HUD_SCORE[64];
static char HUD_LIVES[64];

/**
 * @brief Draw a headline centred above the menu, in the banner font.
 *
 * Direct text rather than a ui label: the menu owns AKGL_UI_ANCHOR_CENTER,
 * and a label anchored there disappears behind it -- there is no
 * top-centre anchor to reach for. Drawn before the UI bracket, so the menu
 * still paints over it if the two ever meet.
 */
static akerr_ErrorContext *draw_banner(char *text)
{
    SDL_Color ink = { 235, 235, 235, 255 };
    TTF_Font *font = NULL;
    int w = 0;
    int h = 0;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
    font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
    FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
    PASS(errctx, akgl_text_measure(font, text, &w, &h));
    PASS(errctx, akgl_text_rendertextat(font, text, ink, 0,
					(GALAGA_VIEW_WIDTH - w) / 2, 280));
    SUCCEED_RETURN(errctx);
}

static akerr_ErrorContext *declare_title(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, akgl_ui_menu(&TITLE_MENU));
    SUCCEED_RETURN(errctx);
}

static akerr_ErrorContext *declare_play(void)
{
    int count = 0;
    PREPARE_ERROR(errctx);

    PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE),
			       "SCORE %06d", galaga_game.score));
    PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES),
			       "LIVES %d   WAVE %d", galaga_game.lives, galaga_shared.wave));
    PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
    PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
    SUCCEED_RETURN(errctx);
}

static akerr_ErrorContext *declare_end(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, akgl_ui_menu(&AGAIN_MENU));
    SUCCEED_RETURN(errctx);
}

/* ------------------------------------------------------------ transitions --- */

static akerr_ErrorContext *start_game(void)
{
    PREPARE_ERROR(errctx);

    galaga_game.score = 0;
    galaga_game.lives = 3;
    memset(galaga_game.kills, 0, sizeof(galaga_game.kills));
    memset(galaga_game.shots, 0, sizeof(galaga_game.shots));
    galaga_game.respawn_timer = 0.0f;
    galaga_shared.wave = 1;

    galaga_game.player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
    PASS(errctx, galaga_wave_spawn());
    galaga_game.screen = GALAGA_SCREEN_PLAY;
    SUCCEED_RETURN(errctx);
}

/**
 * @brief End-of-round bookkeeping: notice a cleared wave or a spent ship.
 */
static akerr_ErrorContext *check_transitions(bool *running)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
    (void)running;
    if ( galaga_game.screen != GALAGA_SCREEN_PLAY ) {
	SUCCEED_RETURN(errctx);
    }
    if ( galaga_game.lives <= 0 ) {
	PASS(errctx, galaga_wave_release());
	galaga_game.screen = GALAGA_SCREEN_GAMEOVER;
	SUCCEED_RETURN(errctx);
    }
    if ( galaga_enemies_alive() == 0 ) {
	galaga_game.screen = GALAGA_SCREEN_VICTORY;
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Consume a menu activation. The menu never clears `activated`; the
 * state machine that acts on it does.
 */
static akerr_ErrorContext *consume_menus(bool *running)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
    if ( galaga_game.screen == GALAGA_SCREEN_TITLE && TITLE_MENU.activated ) {
	TITLE_MENU.activated = false;
	if ( TITLE_MENU.selected == 0 ) {
	    PASS(errctx, start_game());
	} else {
	    *running = false;
	}
    }
    if ( (galaga_game.screen == GALAGA_SCREEN_GAMEOVER
	  || galaga_game.screen == GALAGA_SCREEN_VICTORY)
	 && AGAIN_MENU.activated ) {
	AGAIN_MENU.activated = false;
	if ( AGAIN_MENU.selected == 0 ) {
	    PASS(errctx, galaga_wave_release());
	    PASS(errctx, start_game());
	} else {
	    *running = false;
	}
    }
    SUCCEED_RETURN(errctx);
}

/* ------------------------------------------------------------- the frame --- */

/**
 * @brief Route one event: the UI gets first refusal, then the menus, then
 * the controller. A consumed event goes no further.
 */
static akerr_ErrorContext *route_event(SDL_Event *event, bool *running)
{
    bool consumed = false;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
    FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");

    if ( event->type == SDL_EVENT_QUIT ) {
	*running = false;
	SUCCEED_RETURN(errctx);
    }
    PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
    if ( consumed ) {
	SUCCEED_RETURN(errctx);
    }
    if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
	PASS(errctx, akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed));
    } else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
		|| galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
	PASS(errctx, akgl_ui_menu_handle_event(&AGAIN_MENU, event, &consumed));
    }
    if ( consumed ) {
	SUCCEED_RETURN(errctx);
    }
    /* Every event, unconditionally: one that no control map binds is not an
     * error, it is a call that did nothing. */
    PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, event));
    SUCCEED_RETURN(errctx);
}

/** @brief The previous frame's timestamp, for dt. Stamped once at loop start. */
static uint64_t LAST_NS = 0;

static akerr_ErrorContext *frame(bool *running)
{
    SDL_Event event;
    uint64_t now = 0;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");

    while ( SDL_PollEvent(&event) == true ) {
	PASS(errctx, route_event(&event, running));
    }

    galaga_game.frame += 1;
    if ( galaga_game.autoplay ) {
	if ( galaga_game.screen == GALAGA_SCREEN_TITLE && galaga_game.frame >= 8 ) {
	    PASS(errctx, start_game());
	}
	/*
	 * On an end screen the pilot presses Return, which drives the real
	 * menu path -- declare, handle, activate, restart -- so a headless
	 * run that dies keeps exercising the game instead of idling.
	 */
	if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
	     || galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
	    if ( (galaga_game.frame % 30) == 0 ) {
		SDL_Event press;
		memset(&press, 0, sizeof(press));
		press.type = SDL_EVENT_KEY_DOWN;
		press.key.key = SDLK_RETURN;
		PASS(errctx, route_event(&press, running));
	    }
	}
	PASS(errctx, galaga_player_autoplay(galaga_game.frame));
    }

    /*
     * dt from the wall clock, clamped: a debugger pause or a stalled runner
     * must not become one frame of teleporting enemies. The clamp is a 30 Hz
     * frame, the slowest game this is still worth playing at.
     */
    now = SDL_GetTicksNS();
    galaga_game.dt = (float)(now - LAST_NS) / 1e9f;
    LAST_NS = now;
    if ( galaga_game.dt > (1.0f / 30.0f) ) {
	galaga_game.dt = 1.0f / 30.0f;
    }

    /* The shared frame state, refreshed before any enemy thinks. The engine
     * fills GAME@.ROLL% from its own PRNG rather than letting the script call
     * the native RND, so a headless run is the same game on every machine. */
    galaga_shared.playerx = galaga_game.player->x + 50.0f;
    galaga_shared.playery = galaga_game.player->y;
    galaga_shared.rnd = galaga_random();

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    PASS(errctx, starfield_draw());

    /*
     * akgl_game_update is update-every-actor, step-the-physics, draw-the-
     * world. Updating every actor is where the forty scripts run: each
     * enemy's updatefunc is the hook in enemies.c, and that hook is a BASIC
     * call. Held back on the menu screens so the world stands still there.
     */
    if ( galaga_game.screen == GALAGA_SCREEN_PLAY ) {
	PASS(errctx, akgl_game_update(NULL));
	PASS(errctx, check_transitions(running));
    }

    /* The banner is direct text, drawn before the UI bracket. */
    if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
	PASS(errctx, draw_banner("GALAGA"));
    } else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER ) {
	PASS(errctx, draw_banner("GAME OVER"));
    } else if ( galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
	PASS(errctx, draw_banner("VICTORY"));
    }

    /* The UI bracket sits between akgl_game_update and frame_end, exactly as
     * libakgl docs/22-ui.md draws it. */
    PASS(errctx, akgl_ui_frame_begin());
    switch ( galaga_game.screen ) {
    case GALAGA_SCREEN_TITLE:
	PASS(errctx, declare_title());
	break;
    case GALAGA_SCREEN_PLAY:
	PASS(errctx, declare_play());
	break;
    case GALAGA_SCREEN_GAMEOVER:
    case GALAGA_SCREEN_VICTORY:
	PASS(errctx, declare_end());
	break;
    }
    PASS(errctx, akgl_ui_frame_end(akgl_renderer));

    PASS(errctx, consume_menus(running));

    if ( (SHOTPATH != NULL) && (galaga_game.frame == SHOTFRAME) ) {
	PASS(errctx, save_screenshot(SHOTPATH));
    }
    PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
    SUCCEED_RETURN(errctx);
}

static akerr_ErrorContext *run(int frames)
{
    bool running = true;
    PREPARE_ERROR(errctx);

    LAST_NS = SDL_GetTicksNS();
    while ( running == true ) {
	PASS(errctx, frame(&running));
	if ( (frames > 0) && (galaga_game.frame >= frames) ) {
	    running = false;
	}
	/* A crude frame limiter. A game on a real display should ask SDL for
	 * vsync; this one has to work under the dummy video driver, where
	 * there is nothing to sync to. */
	SDL_Delay(16);
    }
    SUCCEED_RETURN(errctx);
}

/* -------------------------------------------------------------- teardown --- */

/**
 * @brief Give back what the process is holding.
 *
 * There is no akgl_game_shutdown; teardown is the application's. Fonts have
 * to unload before TTF_Quit destroys them underneath the registry. IGNORE()
 * on every call: a teardown failure must not mask whatever error is already
 * being reported.
 */
static void shutdown_game(void)
{
    int i = 0;

    IGNORE(akgl_ui_shutdown());
    IGNORE(akgl_text_unloadallfonts());
    for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
	if ( akgl_heap_actors[i].refcount > 0 ) {
	    IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
	}
    }
    TTF_Quit();
    SDL_Quit();
}

/* ------------------------------------------------------------------ args --- */

static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir,
				      char **script, int *frames)
{
    int i = 0;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
    FAIL_ZERO_RETURN(errctx, script, AKERR_NULLPOINTER, "script");
    FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");

    for ( i = 1; i < argc; i++ ) {
	if ( strcmp(argv[i], "--autoplay") == 0 ) {
	    galaga_game.autoplay = true;
	} else if ( strcmp(argv[i], "--frames") == 0 ) {
	    i += 1;
	    FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
	    PASS(errctx, aksl_atoi(argv[i], frames));
	} else if ( strcmp(argv[i], "--assets") == 0 ) {
	    i += 1;
	    FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
	    *assetdir = argv[i];
	} else if ( strcmp(argv[i], "--script") == 0 ) {
	    i += 1;
	    FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--script needs a path");
	    *script = argv[i];
	} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
	    i += 1;
	    FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot needs a path");
	    SHOTPATH = argv[i];
	} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
	    i += 1;
	    FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE,
				"--screenshot-frame needs a number");
	    PASS(errctx, aksl_atoi(argv[i], &SHOTFRAME));
	} else {
	    FAIL_RETURN(
		errctx,
		AKERR_VALUE,
		"usage: galaga [--assets DIR] [--script PATH] [--frames N]"
		" [--autoplay] [--screenshot PATH] [--screenshot-frame N]"
		);
	}
    }
    SUCCEED_RETURN(errctx);
}

int main(int argc, char *argv[])
{
    char *assetdir = GALAGA_ASSET_DIR;
    char *script = GALAGA_SCRIPT_PATH;
    int frames = 0;
    uint16_t fontid = 0;
    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, parse_args(argc, argv, &assetdir, &script, &frames));
	CATCH(errctx, startup());
	CATCH(errctx, load_assets(assetdir));
	/* The engine refuses to start when the script will not boot: a game
	 * whose enemies cannot think is not a game missing a feature. */
	CATCH(errctx, galaga_script_boot(script));
	CATCH(errctx, akgl_ui_init(GALAGA_VIEW_WIDTH, GALAGA_VIEW_HEIGHT));
	CATCH(errctx, akgl_text_loadfont("hud", GALAGA_FONT_PATH, 28));
	CATCH(errctx, akgl_text_loadfont("banner", GALAGA_FONT_PATH, 84));
	CATCH(errctx, akgl_ui_font_register("hud", &fontid));
	CATCH(errctx, galaga_player_spawn());
	CATCH(errctx, galaga_player_controls());
	starfield_seed();
	galaga_game.screen = GALAGA_SCREEN_TITLE;
	galaga_game.lives = 3;
	CATCH(errctx, run(frames));
    } CLEANUP {
	shutdown_game();
    } PROCESS(errctx) {
    } HANDLE_DEFAULT(errctx) {
	LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run");
	/* Set a flag rather than returning: leaving a HANDLE block early
	 * skips FINISH's RELEASE_ERROR and leaks the context's pool slot. */
	FAILED = 1;
	/* FINISH_NORETURN rather than FINISH: FINISH expands a return that an
	 * int-returning function cannot compile. */
    } FINISH_NORETURN(errctx);

    /*
     * The readout is the evidence: exiting 0 is not proof the wave flew. A
     * headless CI log gets the same line a reader's terminal does, and the
     * script-error count is the line's whole reason to exist -- a wave of
     * dumb enemies still exits 0.
     */
    SDL_Log(
	"galaga: %d frames, screen %d, score %d, alive %d, kills bee %d bfly %d boss %d,"
	" shots bee %d bfly %d boss %d, script errors %d",
	galaga_game.frame,
	(int)galaga_game.screen,
	galaga_game.score,
	galaga_enemies_alive(),
	galaga_game.kills[GALAGA_ENEMY_BEE],
	galaga_game.kills[GALAGA_ENEMY_BUTTERFLY],
	galaga_game.kills[GALAGA_ENEMY_BOSS],
	galaga_game.shots[GALAGA_ENEMY_BEE],
	galaga_game.shots[GALAGA_ENEMY_BUTTERFLY],
	galaga_game.shots[GALAGA_ENEMY_BOSS],
	galaga_game.script_errors);
    return FAILED;
}
