/*
 * This software is licensed under the terms of the MIT License.
 * See COPYING for further information.
 * ---
 * Copyright (c) 2011-2024, Lukas Weber <laochailan@web.de>.
 * Copyright (c) 2012-2024, Andrei Alexeyev <akari@taisei-project.org>.
 */

#include "stage.h"

#include "audio/audio.h"
#include "common_tasks.h"  // IWYU pragma: keep
#include "config.h"
#include "dynstage.h"
#include "eventloop/eventloop.h"
#include "events.h"
#include "global.h"
#include "lasers/draw.h"
#include "log.h"
#include "menu/gameovermenu.h"
#include "menu/ingamemenu.h"
#include "player.h"
#include "replay/demoplayer.h"
#include "replay/stage.h"
#include "replay/state.h"
#include "replay/struct.h"
#include "resource/bgm.h"
#include "stagedraw.h"
#include "stageinfo.h"
#include "stageobjects.h"
#include "stagetext.h"
#include "util/env.h"
#include "watchdog.h"

typedef struct StageFrameState {
	StageInfo *stage;
	ResourceGroup *rg;
	CallChain cc;
	CoSched sched;
	Replay *quicksave;
	bool quicksave_is_automatic;
	bool quickload_requested;
	bool was_skipping;
	bool paused;
	uint32_t dynstage_generation;
	int transition_delay;
	int desync_check_freq;
	uint16_t last_replay_fps;
	float view_shake;
	int bgm_start_time;
	double bgm_start_pos;
} StageFrameState;

static StageFrameState *_current_stage_state;  // TODO remove this shitty hack

#define BGM_FADE_LONG (2.0 * FADE_TIME / (double)FPS)
#define BGM_FADE_SHORT (FADE_TIME / (double)FPS)

static inline bool is_quickloading(StageFrameState *fstate) {
	return fstate->quicksave && fstate->quicksave == global.replay.input.replay;
}

static inline bool should_skip_frame(StageFrameState *fstate) {
	return
		is_quickloading(fstate) || (
			global.replay.input.replay &&
			global.replay.input.play.skip_frames > 0
		);
}

bool stage_is_demo_mode(void) {
	return global.replay.input.replay && global.replay.input.play.demo_mode;
}

static void sync_bgm(StageFrameState *fstate) {
	if(stage_is_demo_mode()) {
		return;
	}

	double t = fstate->bgm_start_pos + (global.frames - fstate->bgm_start_time) / (double)FPS;
	audio_bgm_seek_realtime(t);
}

static void recover_after_skip(StageFrameState *fstate) {
	if(fstate->was_skipping) {
		fstate->was_skipping = false;
		sync_bgm(fstate);
		audio_sfx_set_enabled(true);
	}
}

#ifdef HAVE_SKIP_MODE

// TODO refactor this unholy mess
// somehow reconcile with what's implemented for quickloads and demos.

static struct {
	const char *skip_to_bookmark;
	bool skip_to_dialog;
	bool was_skip_mode;
} skip_state;

void _stage_bookmark(const char *name) {
	log_debug("Bookmark [%s] reached at %i", name, global.frames);

	if(skip_state.skip_to_bookmark && !strcmp(skip_state.skip_to_bookmark, name)) {
		skip_state.skip_to_bookmark = NULL;
		global.plr.iddqd = false;
	}
}

DEFINE_EXTERN_TASK(stage_bookmark) {
	_stage_bookmark(ARGS.name);
}

bool stage_is_skip_mode(void) {
	return skip_state.skip_to_bookmark || skip_state.skip_to_dialog;
}

static void skipstate_init(void) {
	skip_state.skip_to_dialog = env_get_int("TAISEI_SKIP_TO_DIALOG", 0);
	skip_state.skip_to_bookmark = env_get_string_nonempty("TAISEI_SKIP_TO_BOOKMARK", NULL);
}

static LogicFrameAction skipstate_handle_frame(void) {
	if(skip_state.skip_to_dialog && dialog_is_active(global.dialog)) {
		skip_state.skip_to_dialog = false;
		global.plr.iddqd = false;
	}

	bool skip_mode = stage_is_skip_mode();

	if(!skip_mode && skip_state.was_skip_mode) {
		sync_bgm(_current_stage_state);
	}

	skip_state.was_skip_mode = skip_mode;

	if(skip_mode) {
		return LFRAME_SKIP_ALWAYS;
	}

	if(gamekeypressed(KEY_SKIP)) {
		return LFRAME_SKIP;
	}

	return LFRAME_WAIT;
}

static void skipstate_shutdown(void) {
	memset(&skip_state, 0, sizeof(skip_state));
}

#else

INLINE LogicFrameAction skipstate_handle_frame(void) { return LFRAME_WAIT; }
INLINE void skipstate_init(void) { }
INLINE void skipstate_shutdown(void) { }

#endif

static void stage_start(StageInfo *stage) {
	global.frames = 0;
	global.gameover = 0;
	global.voltage_threshold = 0;

	player_stage_pre_init(&global.plr);

	stats_stage_reset(&global.plr.stats);

	if(stage->type == STAGE_SPELL) {
		global.is_practice_mode = true;
		global.plr.lives = 0;
		global.plr.bombs = 0;
	} else if(global.is_practice_mode) {
		global.plr.lives = PLR_STGPRACTICE_LIVES;
		global.plr.bombs = PLR_STGPRACTICE_BOMBS;
	}

	if(global.is_practice_mode) {
		global.plr.power_stored = config_get_int(CONFIG_PRACTICE_POWER);
	}

	global.plr.power_stored = clamp(global.plr.power_stored, 0, PLR_MAX_POWER_STORED);

	reset_all_sfx();
}

static bool ingame_menu_interrupts_bgm(void) {
	return global.stage->type != STAGE_SPELL;
}

static inline bool is_quicksave_allowed(void) {
#ifndef DEBUG
	if(!global.is_practice_mode) {
		return false;
	}
#endif

	if(global.gameover != GAMEOVER_NONE) {
		return false;
	}

	return true;
}

typedef struct IngameMenuCallContext {
	CallChain next;
	BGM *saved_bgm;
	double saved_bgm_pos;
	bool bgm_interrupted;
} IngameMenuCallContext;

static void setup_ingame_menu_bgm(IngameMenuCallContext *ctx, BGM *bgm) {
	if(ingame_menu_interrupts_bgm()) {
		ctx->bgm_interrupted = true;

		if(bgm) {
			ctx->saved_bgm = audio_bgm_current();
			ctx->saved_bgm_pos = audio_bgm_tell();
			audio_bgm_play(bgm, true, 0, 1);
		} else {
			audio_bgm_pause();
		}
	}
}

static void resume_bgm(IngameMenuCallContext *ctx) {
	if(ctx->bgm_interrupted) {
		if(ctx->saved_bgm) {
			audio_bgm_play(ctx->saved_bgm, true, ctx->saved_bgm_pos, 0.5);
			ctx->saved_bgm = NULL;
		} else {
			audio_bgm_resume();
		}

		ctx->bgm_interrupted = false;
	}
}

static void stage_leave_ingame_menu(CallChainResult ccr) {
	IngameMenuCallContext *ctx = ccr.ctx;
	MenuData *m = ccr.result;

	if(m->state != MS_Dead) {
		return;
	}

	if(global.gameover > 0) {
		stop_all_sfx();

		if(ctx->bgm_interrupted) {
			audio_bgm_stop(global.gameover == GAMEOVER_RESTART ? BGM_FADE_SHORT : BGM_FADE_LONG);
		}
	} else {
		resume_bgm(ctx);
		events_emit(TE_GAME_PAUSE_STATE_CHANGED, false, NULL, NULL);
	}

	resume_all_sfx();

	run_call_chain(&ctx->next, NULL);
	mem_free(ctx);
}

static void stage_enter_ingame_menu(MenuData *m, BGM *bgm, CallChain next) {
	events_emit(TE_GAME_PAUSE_STATE_CHANGED, true, NULL, NULL);
	auto ctx = ALLOC(IngameMenuCallContext, { .next = next });
	setup_ingame_menu_bgm(ctx, bgm);
	pause_all_sfx();
	enter_menu(m, CALLCHAIN(stage_leave_ingame_menu, ctx));
}

static void stage_unpause(CallChainResult ccr) {
	StageFrameState *fstate = ccr.ctx;
	fstate->paused = false;
}

static void stage_pause(StageFrameState *fstate) {
	if(fstate->paused) {
		log_debug("Pause request ignored, already paused");
		return;
	}

	if(global.gameover == GAMEOVER_TRANSITIONING || stage_is_skip_mode()) {
		return;
	}

	MenuData *m;

	if(global.replay.input.replay) {
		m = create_ingame_menu_replay();
	} else {
		m = create_ingame_menu();
	}

	fstate->paused = true;
	stage_enter_ingame_menu(m, NULL, CALLCHAIN(stage_unpause, fstate));
}

static void stage_do_quickload(StageFrameState *fstate);

static void stage_continue_game(void) {
	log_info("The game is being continued...");
	player_event(&global.plr, &global.replay.input, &global.replay.output, EV_CONTINUE, 0);
}

static void gameover_menu_result(CallChainResult ccr) {
	GameoverMenuAction *_action = ccr.ctx;
	auto action = *_action;
	mem_free(_action);

	switch(action) {
		case GAMEOVERMENU_ACTION_QUIT:
			if(global.plr.stats.total.continues_used >= MAX_CONTINUES) {
				global.gameover = GAMEOVER_DEFEAT;
			} else {
				global.gameover = GAMEOVER_ABORT;
			}
			break;

		case GAMEOVERMENU_ACTION_CONTINUE:
			stage_continue_game();
			break;

		case GAMEOVERMENU_ACTION_RESTART:
			global.gameover = GAMEOVER_RESTART;
			break;

		case GAMEOVERMENU_ACTION_QUICKLOAD:
			stage_do_quickload(_current_stage_state);
			break;

		default: UNREACHABLE;
	}
}

void stage_gameover(void) {
	if(global.stage->type == STAGE_SPELL && config_get_int(CONFIG_SPELLSTAGE_AUTORESTART)) {
		auto fstate = _current_stage_state;
		if(fstate->quicksave) {
			stage_do_quickload(fstate);
		} else {
			global.gameover = GAMEOVER_RESTART;
		}
		return;
	}

	BGM *bgm = NULL;

	if(ingame_menu_interrupts_bgm()) {
		bgm = res_bgm("gameover");
		progress_unlock_bgm("gameover");
	}

	auto action = ALLOC(GameoverMenuAction);
	auto menu = create_gameover_menu(&(GameoverMenuParams) {
		.output = action,
		.quickload_shown = is_quicksave_allowed(),
		.quickload_enabled = _current_stage_state->quicksave != NULL,
	});

	stage_enter_ingame_menu(menu, bgm, CALLCHAIN(gameover_menu_result, action));
}

static bool stage_input_common(SDL_Event *event, void *arg) {
	StageFrameState *fstate = NOT_NULL(arg);
	TaiseiEvent type = TAISEI_EVENT(event->type);
	int32_t code = event->user.code;

	switch(type) {
		case TE_GAME_KEY_DOWN:
			switch(code) {
				case KEY_STOP:
					stage_finish(GAMEOVER_DEFEAT);
					return true;

				case KEY_RESTART:
					stage_finish(GAMEOVER_RESTART);
					return true;
			}

			break;

		case TE_GAME_PAUSE:
			stage_pause(fstate);
			break;

		default:
			break;
	}

	return false;
}

static bool stage_input_key_filter(KeyIndex key, bool is_release) {
	if(key == KEY_HAHAIWIN) {
		IF_DEBUG(
			if(!is_release) {
				stage_finish(GAMEOVER_WIN);
			}
		);

		return false;
	}

	IF_NOT_DEBUG(
		if(
			key == KEY_IDDQD ||
			key == KEY_POWERUP ||
			key == KEY_POWERDOWN
		) {
			return false;
		}
	);

	if(stage_is_cleared()) {
		if(key == KEY_SHOT) {
			if(
				global.gameover == GAMEOVER_SCORESCREEN &&
				global.frames - global.gameover_time > GAMEOVER_SCORE_DELAY * 2
			) {
				if(!is_release) {
					stage_finish(GAMEOVER_WIN);
				}
			}
		}

		if(key == KEY_BOMB || key == KEY_SPECIAL) {
			return false;
		}
	}

	return true;
}

attr_nonnull_all
static Replay *create_quicksave_replay(ReplayStage *rstg_src) {
	ReplayStage *rstg = memdup(rstg_src, sizeof(*rstg));
	rstg->num_events = 0;
	memset(&rstg->events, 0, sizeof(rstg->events));

	dynarray_ensure_capacity(&rstg->events, rstg_src->events.num_elements + 1);
	dynarray_set_elements(&rstg->events, rstg_src->events.num_elements, rstg_src->events.data);

	replay_stage_event(rstg, global.frames, EV_RESUME, 0);
	replay_stage_update_final_stats(rstg, &global.plr.stats);

	auto rpy = ALLOC(Replay);
	rpy->stages.num_elements = rpy->stages.capacity = 1;
	rpy->stages.data = rstg;

	log_info("Created quicksave replay on frame %i", global.frames);
	return rpy;
}

static void stage_do_quicksave(StageFrameState *fstate, bool isauto) {
	if(isauto && fstate->quicksave && !fstate->quicksave_is_automatic) {
		// Do not overwrite a manual quicksave with an auto quicksave
		return;
	}

	if(fstate->quicksave) {
		replay_reset(fstate->quicksave);
		mem_free(fstate->quicksave);
	}

	fstate->quicksave = create_quicksave_replay(global.replay.output.stage);
	fstate->quicksave_is_automatic = isauto;
}

static void stage_do_quickload(StageFrameState *fstate) {
	if(fstate->quicksave) {
		fstate->quickload_requested = true;
	} else {
		log_info("No active quicksave");
	}
}

static bool stage_input_handler_gameplay(SDL_Event *event, void *arg) {
	StageFrameState *fstate = NOT_NULL(arg);

	if(event->type == SDL_KEYDOWN && !event->key.repeat && is_quicksave_allowed()) {
		if(event->key.keysym.scancode == config_get_int(CONFIG_KEY_QUICKSAVE)) {
			stage_do_quicksave(fstate, false);
		} else if(event->key.keysym.scancode == config_get_int(CONFIG_KEY_QUICKLOAD)) {
			stage_do_quickload(fstate);
		}

		return false;
	}

	TaiseiEvent type = TAISEI_EVENT(event->type);
	int32_t code = event->user.code;

	if(stage_input_common(event, arg)) {
		return false;
	}

	if(
		(type == TE_GAME_KEY_DOWN || type == TE_GAME_KEY_UP) &&
		code == KEY_SHOT &&
		config_get_int(CONFIG_SHOT_INVERTED)
	) {
		type = type == TE_GAME_KEY_DOWN ? TE_GAME_KEY_UP : TE_GAME_KEY_DOWN;
	}

	ReplayState *rpy = &global.replay.output;

	switch(type) {
		case TE_GAME_KEY_DOWN:
			if(stage_input_key_filter(code, false)) {
				player_event(&global.plr, NULL, rpy, EV_PRESS, code);
			}
			break;

		case TE_GAME_KEY_UP:
			if(stage_input_key_filter(code, true)) {
				player_event(&global.plr, NULL, rpy, EV_RELEASE, code);
			}
			break;

		default: break;
	}

	return false;
}

static bool stage_input_handler_replay(SDL_Event *event, void *arg) {
	stage_input_common(event, arg);
	return false;
}

static bool stage_input_handler_demo(SDL_Event *event, void *arg) {
	if(event->type == SDL_KEYDOWN && !event->key.repeat) {
		goto exit;
	}

	switch(TAISEI_EVENT(event->type)) {
		case TE_GAME_KEY_DOWN:
		case TE_GAMEPAD_BUTTON_DOWN:
		case TE_GAMEPAD_AXIS_DIGITAL:
		exit:
			stage_finish(GAMEOVER_ABORT);
	}

	return false;
}

struct replay_event_arg {
	ReplayState *st;
	ReplayEvent *resume_event;
};

static void handle_replay_event(ReplayEvent *e, void *arg) {
	struct replay_event_arg *a = NOT_NULL(arg);

	if(UNLIKELY(a->resume_event != NULL)) {
		log_warn(
			"Got replay event [%i:%02x:%04x] after resume event in the same frame, ignoring",
			e->frame, e->type, e->value
		);
		return;
	}

	switch(e->type) {
		case EV_OVER:
			global.gameover = GAMEOVER_DEFEAT;
			break;

		case EV_RESUME:
			a->resume_event = e;
			break;

		default:
			player_event(&global.plr, a->st, &global.replay.output, e->type, e->value);
			break;
	}
}

static void leave_replay_mode(StageFrameState *fstate, ReplayState *rp_in) {
	replay_state_deinit(rp_in);
}

static void replay_input(StageFrameState *fstate) {
	if(!should_skip_frame(fstate)) {
		events_poll((EventHandler[]){
			{
				.proc =
					stage_is_demo_mode()
						? stage_input_handler_demo
						: stage_input_handler_replay,
				.arg = fstate,
			},
			{ NULL }
		}, EFLAG_GAME);
	}

	ReplayState *rp_in = &global.replay.input;

	if(UNLIKELY(rp_in->mode == REPLAY_NONE)) {
		return;
	}

	struct replay_event_arg a = { .st = rp_in };
	replay_state_play_advance(rp_in, global.frames, handle_replay_event, &a);
	player_applymovement(&global.plr);

	if(a.resume_event) {
		leave_replay_mode(fstate, rp_in);
	}
}

static void display_bgm_title(void) {
	BGM *bgm = audio_bgm_current();
	const char *title = bgm ? bgm_get_title(bgm) : NULL;

	if(title) {
		char txt[strlen(title) + 6];
		snprintf(txt, sizeof(txt), "BGM: %s", title);
		stagetext_add(txt, VIEWPORT_W-15 + I * (VIEWPORT_H-20), ALIGN_RIGHT, res_font("standard"), RGB(1, 1, 1), 30, 180, 35, 35);
	}
}

static bool stage_handle_bgm_change(SDL_Event *evt, void *a) {
	StageFrameState *fstate = NOT_NULL(a);
	fstate->bgm_start_time = global.frames;
	fstate->bgm_start_pos = audio_bgm_tell();

	if(dialog_is_active(global.dialog)) {
		INVOKE_TASK_WHEN(&global.dialog->events.fadeout_began, common_call_func, display_bgm_title);
	} else {
		display_bgm_title();
	}

	return false;
}

static void stage_input(StageFrameState *fstate) {
	if(stage_is_skip_mode()) {
		events_poll((EventHandler[]){
			{
				.proc = stage_handle_bgm_change,
				.event_type = MAKE_TAISEI_EVENT(TE_AUDIO_BGM_STARTED),
				.arg = fstate,
			},
			{NULL}
		}, EFLAG_NOPUMP);
	} else {
		events_poll((EventHandler[]){
			{ .proc = stage_input_handler_gameplay, .arg = fstate },
			{
				.proc = stage_handle_bgm_change,
				.event_type = MAKE_TAISEI_EVENT(TE_AUDIO_BGM_STARTED),
				.arg = fstate,
			},
			{NULL}
		}, EFLAG_GAME);
	}

	player_fix_input(&global.plr, &global.replay.output);
	player_applymovement(&global.plr);
}

void stage_clear_hazards_predicate(bool (*predicate)(EntityInterface *ent, void *arg), void *arg, ClearHazardsFlags flags) {
	bool force = flags & CLEAR_HAZARDS_FORCE;

	if(flags & CLEAR_HAZARDS_BULLETS) {
		for(Projectile *p = global.projs.first, *next; p; p = next) {
			next = p->next;

			if(!force && !projectile_is_clearable(p)) {
				continue;
			}

			if(!predicate || predicate(&p->ent, arg)) {
				clear_projectile(p, flags);
			}
		}
	}

	if(flags & CLEAR_HAZARDS_LASERS) {
		for(Laser *l = global.lasers.first, *next; l; l = next) {
			next = l->next;

			if(!force && !laser_is_clearable(l)) {
				continue;
			}

			if(!predicate || predicate(&l->ent, arg)) {
				clear_laser(l, flags);
			}
		}
	}
}

void stage_clear_hazards(ClearHazardsFlags flags) {
	stage_clear_hazards_predicate(NULL, NULL, flags);
}

static bool proximity_predicate(EntityInterface *ent, void *varg) {
	Circle *area = varg;

	switch(ent->type) {
		case ENT_TYPE_ID(Projectile): {
			Projectile *p = ENT_CAST(ent, Projectile);
			return cabs(p->pos - area->origin) < area->radius;
		}

		case ENT_TYPE_ID(Laser): {
			Laser *l = ENT_CAST(ent, Laser);
			return laser_intersects_circle(l, *area);
		}

		default: UNREACHABLE;
	}
}

static bool ellipse_predicate(EntityInterface *ent, void *varg) {
	Ellipse *e = varg;

	switch(ent->type) {
		case ENT_TYPE_ID(Projectile): {
			Projectile *p = ENT_CAST(ent, Projectile);
			return point_in_ellipse(p->pos, *e);
		}

		case ENT_TYPE_ID(Laser): {
			Laser *l = ENT_CAST(ent, Laser);
			return laser_intersects_ellipse(l, *e);
		}

		default: UNREACHABLE;
	}
}

void stage_clear_hazards_at(cmplx origin, double radius, ClearHazardsFlags flags) {
	Circle area = { origin, radius };

	if(UNLIKELY(radius <= 0)) {
		return;
	}

	stage_clear_hazards_predicate(proximity_predicate, &area, flags);
}

void stage_clear_hazards_in_ellipse(Ellipse e, ClearHazardsFlags flags) {
	stage_clear_hazards_predicate(ellipse_predicate, &e, flags);
}

TASK(clear_dialog) {
	assert(global.dialog != NULL);
	// dialog_deinit() should've been called by dialog_end() at this point
	global.dialog = NULL;
}

void stage_begin_dialog(Dialog *d) {
	assert(global.dialog == NULL);
	global.dialog = d;
	dialog_init(d);
	INVOKE_TASK_WHEN(&d->events.fadeout_ended, clear_dialog);
}

static void stage_free(void) {
	delete_enemies(&global.enemies);
	delete_items();
	delete_lasers();

	delete_projectiles(&global.projs);
	delete_projectiles(&global.particles);

	if(global.dialog) {
		dialog_deinit(global.dialog);
		global.dialog = NULL;
	}

	if(global.boss) {
		free_boss(global.boss);
		global.boss = NULL;
	}

	lasers_shutdown();
	projectiles_free();
	stagetext_free();
}

static void stage_finalize(CallChainResult ccr) {
	global.gameover = (intptr_t)ccr.ctx;
}

void stage_finish(int gameover) {
	if(global.gameover == GAMEOVER_TRANSITIONING) {
		return;
	}

	int prev_gameover = global.gameover;
	global.gameover_time = global.frames;

	if(gameover == GAMEOVER_SCORESCREEN) {
		global.gameover = GAMEOVER_SCORESCREEN;
	} else {
		global.gameover = GAMEOVER_TRANSITIONING;
		CallChain cc = CALLCHAIN(stage_finalize, (void*)(intptr_t)gameover);
		set_transition(TransFadeBlack, FADE_TIME, FADE_TIME*2, cc);

		if(!stage_is_demo_mode()) {
			audio_bgm_stop(BGM_FADE_LONG);
		}
	}

	if(
		global.replay.input.replay == NULL &&
		prev_gameover != GAMEOVER_SCORESCREEN &&
		(gameover == GAMEOVER_SCORESCREEN || gameover == GAMEOVER_WIN)
	) {
		StageProgress *p = NOT_NULL(stageinfo_get_progress(global.stage, global.diff, true));
		progress_register_stage_cleared(p, global.plr.mode);
	}

	if(gameover == GAMEOVER_SCORESCREEN && stage_is_skip_mode()) {
		// don't get stuck in an infinite loop
		taisei_quit();
	}
}

static void stage_preload(StageInfo *si, ResourceGroup *rg) {
	difficulty_preload(rg);
	projectiles_preload(rg);
	player_preload(rg);
	items_preload(rg);
	boss_preload(rg);
	laserdraw_preload(rg);
	enemies_preload(rg);

	if(si->type != STAGE_SPELL) {
		res_group_preload(rg, RES_BGM, RESF_OPTIONAL, "gameover", NULL);
		dialog_preload(rg);
	}

	si->procs->preload(rg);
}

static void display_stage_title(StageInfo *info) {
	if(stage_is_demo_mode()) {
		return;
	}

	stagetext_add(info->title,    VIEWPORT_W/2 + I * (VIEWPORT_H/2-40), ALIGN_CENTER, res_font("big"), RGB(1, 1, 1), 50, 85, 35, 35);
	stagetext_add(info->subtitle, VIEWPORT_W/2 + I * (VIEWPORT_H/2),    ALIGN_CENTER, res_font("standard"), RGB(1, 1, 1), 60, 85, 35, 35);
}

TASK(start_bgm, { BGM *bgm; }) {
	_current_stage_state->bgm_start_time = global.frames + 1;
	audio_bgm_play(ARGS.bgm, true, 0, 0);
}

void stage_start_bgm(const char *bgm) {
	if(stage_is_demo_mode()) {
		return;
	}

	INVOKE_TASK_DELAYED(1, start_bgm, res_bgm(bgm));
}

void stage_set_voltage_thresholds(uint easy, uint normal, uint hard, uint lunatic) {
	switch(global.diff) {
		case D_Easy:    global.voltage_threshold = easy;    return;
		case D_Normal:  global.voltage_threshold = normal;  return;
		case D_Hard:    global.voltage_threshold = hard;    return;
		case D_Lunatic: global.voltage_threshold = lunatic; return;
		default: UNREACHABLE;
	}
}

bool stage_is_cleared(void) {
	return global.gameover == GAMEOVER_SCORESCREEN || global.gameover == GAMEOVER_TRANSITIONING;
}

static void stage_update_fps(StageFrameState *fstate) {
	if(global.replay.output.replay) {
		uint16_t replay_fps = (uint16_t)rint(global.fps.logic.fps);

		if(replay_fps != fstate->last_replay_fps) {
			replay_stage_event(global.replay.output.stage, global.frames, EV_FPS, replay_fps);
			fstate->last_replay_fps = replay_fps;
		}
	}
}

static void stage_give_clear_bonus(const StageInfo *stage, StageClearBonus *bonus) {
	memset(bonus, 0, sizeof(*bonus));

	// FIXME: this is clunky...
	if(!global.is_practice_mode && stage->type == STAGE_STORY) {
		StageInfo *next = stageinfo_get_by_id(stage->id + 1);

		if(next == NULL || next->type != STAGE_STORY) {
			bonus->all_clear.base = global.plr.point_item_value * 100 + global.plr.points / 10;
			bonus->all_clear.diff_multiplier = difficulty_value(1.0, 1.1, 1.3, 1.6);
			bonus->all_clear.diff_bonus = bonus->all_clear.base * (bonus->all_clear.diff_multiplier - 1.0);
		}
	}

	if(stage->type == STAGE_STORY) {
		bonus->base = stage->id * 1000000;
	}

	bonus->voltage = max(0, (int)global.plr.voltage - (int)global.voltage_threshold) * (global.plr.point_item_value / 25);
	bonus->lives = global.plr.lives * global.plr.point_item_value * 5;

	// TODO: maybe a difficulty multiplier?

	bonus->total = (
		bonus->base +
		bonus->voltage +
		bonus->lives +
		bonus->graze +
		bonus->all_clear.base +
		bonus->all_clear.diff_bonus +
	0);
	player_add_points(&global.plr, bonus->total, global.plr.pos);
}

static void stage_replay_sync(StageFrameState *fstate) {
	uint16_t desync_check = (rng_u64() ^ global.plr.points) & 0xFFFF;
	ReplaySyncStatus rpsync = replay_state_check_desync(&global.replay.input, global.frames, desync_check);

	if(
		global.replay.output.stage &&
		fstate->desync_check_freq > 0 &&
		!(global.frames % fstate->desync_check_freq)
	) {
		replay_stage_event(global.replay.output.stage, global.frames, EV_CHECK_DESYNC, desync_check);
	}

	if(rpsync == REPLAY_SYNC_FAIL) {
		if(
			global.is_replay_verification &&
			!global.replay.output.stage
		) {
			exit(1);
		}

		if(fstate->quicksave && fstate->quicksave == global.replay.input.replay) {
			log_warn("Quicksave replay desynced; resuming prematurely!");
			leave_replay_mode(fstate, &global.replay.input);
		}
	}
}

static LogicFrameAction stage_logic_frame(void *arg) {
	StageFrameState *fstate = arg;
	StageInfo *stage = fstate->stage;

	stage_update_fps(fstate);

	if(watchdog_signaled() && !stage_is_demo_mode()) {
		global.gameover = GAMEOVER_ABORT;
	}

	if(stage_is_skip_mode()) {
		global.plr.iddqd = true;
	}

	fapproach_p(&fstate->view_shake, 0, 1);
	fapproach_asymptotic_p(&fstate->view_shake, 0, 0.05, 1e-2);

	if(
		global.replay.input.replay == NULL &&
		fstate->dynstage_generation != dynstage_get_generation()
	) {
		log_info("Stages library updated, attempting to hot-reload");
		stage_do_quicksave(fstate, true);   // no-op if user has a manual save
		stage_do_quickload(fstate);
	}

	if(global.gameover == GAMEOVER_TRANSITIONING) {
		// Usually stage_comain will do this
		events_poll(NULL, 0);
	} else {
		cosched_run_tasks(&fstate->sched);
		update_all_sfx();
		stage_replay_sync(fstate);

		global.frames++;

		/*
		 * TODO: Investigate why/if any of this needs to happen after global.frames++,
		 *       and possibly fix that.
		 */

		stagetext_update();

		if(global.replay.input.replay && global.gameover != GAMEOVER_TRANSITIONING) {
			ReplayStage *rstg = global.replay.input.stage;
			ReplayEvent *last_event = dynarray_get_ptr(
				&rstg->events, rstg->events.num_elements - 1);

			if(
				global.frames == last_event->frame - FADE_TIME &&
				last_event->type != EV_RESUME
			) {
				stage_finish(GAMEOVER_DEFEAT);
			}
		}

		if(
			global.gameover == GAMEOVER_SCORESCREEN &&
			global.frames - global.gameover_time == GAMEOVER_SCORE_DELAY
		) {
			StageClearBonus b;
			stage_give_clear_bonus(stage, &b);
			stage_display_clear_screen(&b);
		}

		if(stage->type == STAGE_SPELL && !global.boss && !fstate->transition_delay) {
			fstate->transition_delay = 120;
		}
	}

	if(fstate->transition_delay) {
		if(!--fstate->transition_delay) {
			stage_finish(GAMEOVER_WIN);
		}
	} else if(!(should_skip_frame(fstate) && stage_is_demo_mode())) {
		update_transition();
	}

	if(global.replay.input.replay == NULL) {
		StageProgress *p = NOT_NULL(stageinfo_get_progress(fstate->stage, global.diff, true));
		progress_register_hiscore(p, global.plr.mode, global.plr.points);
	}

	if(fstate->quickload_requested) {
		log_info("Quickload initiated");
		return LFRAME_STOP;
	}

	if(global.gameover > 0) {
		return LFRAME_STOP;
	}

	if(should_skip_frame(fstate)) {
		fstate->was_skipping = true;
		return LFRAME_SKIP_ALWAYS;
	}

	recover_after_skip(fstate);


	LogicFrameAction skipmode = skipstate_handle_frame();
	if(skipmode != LFRAME_WAIT) {
		return skipmode;
	}

	if(global.replay.input.replay && gamekeypressed(KEY_SKIP)) {
		return LFRAME_SKIP;
	}

	return LFRAME_WAIT;
}

static RenderFrameAction stage_render_frame(void *arg) {
	StageFrameState *fstate = arg;
	StageInfo *stage = fstate->stage;

	if(stage_is_skip_mode()) {
		return RFRAME_DROP;
	}

	rng_lock(&global.rand_game);
	rng_make_active(&global.rand_visual);
	BEGIN_DRAW_CODE();
	stage_draw_scene(stage);
	END_DRAW_CODE();
	rng_unlock(&global.rand_game);
	rng_make_active(&global.rand_game);
	draw_transition();

	return RFRAME_SWAP;
}

static void stage_end_loop(void *ctx);

static void stage_stub_proc(void) { }
static void stage_preload_stub_proc(ResourceGroup *rg) { }

static void process_input(StageFrameState *fstate) {
	if(global.replay.input.replay != NULL) {
		replay_input(fstate);
	} else {
		stage_input(fstate);
	}
}

TASK(stage_comain, { StageFrameState *fstate; }) {
	StageFrameState *fstate = ARGS.fstate;
	StageInfo *stage = fstate->stage;

	stage->procs->begin();
	player_stage_post_init(&global.plr);

	if(global.stage->type != STAGE_SPELL) {
		display_stage_title(stage);
	}

	YIELD;

	bool first_frame = true;

	for(;;) {
		process_input(fstate);
		process_boss(&global.boss);
		process_enemies(&global.enemies);
		process_projectiles(&global.projs, true);
		process_items();
		process_lasers();
		process_projectiles(&global.particles, false);

		if(global.dialog) {
			dialog_update(global.dialog);

			if((global.plr.inputflags & INFLAG_SKIP) && dialog_is_active(global.dialog)) {
				dialog_page(global.dialog);
			}
		}

		if(stage_is_skip_mode()) {
			if(dialog_is_active(global.dialog)) {
				dialog_page(global.dialog);
			}

			if(global.boss) {
				ent_damage(&global.boss->ent, &(DamageInfo) { 400, DMG_PLAYER_SHOT } );
			}
		}

		if(first_frame) {
			// HACK: Run frame 0 logic twice for compatibility with a v1.4 bug
			first_frame = false;
		} else {
			YIELD;
		}
	}
}

static void _stage_enter(
	StageInfo *stage, ResourceGroup *rg, CallChain next, Replay *quickload, bool quicksave_is_automatic
) {
	assert(stage);
	assert(stage->procs);

	if(global.gameover == GAMEOVER_WIN) {
		global.gameover = 0;
	} else if(global.gameover) {
		run_call_chain(&next, NULL);
		return;
	}

	uint32_t dynstage_generation = dynstage_get_generation();
	stageinfo_reload();

	#define STUB_PROC(proc, stub) do {\
		if(!stage->procs->proc) { \
			stage->procs->proc = stub; \
			log_debug(#proc " proc is missing"); \
		} \
	} while(0)

	static const ShaderRule shader_rules_stub[1] = { NULL };

	STUB_PROC(preload, stage_preload_stub_proc);
	STUB_PROC(begin, stage_stub_proc);
	STUB_PROC(end, stage_stub_proc);
	STUB_PROC(draw, stage_stub_proc);
	STUB_PROC(shader_rules, (ShaderRule*)shader_rules_stub);

	if(quickload) {
		assert(global.replay.input.stage == NULL);
		ReplayStage *qload_stage = dynarray_get_ptr(&quickload->stages, 0);
		assert(qload_stage->stage == stage->id);
		replay_state_init_play(&global.replay.input, quickload, qload_stage);
	}

	// I really want to separate all of the game state from the global struct sometime
	global.stage = stage;

	ent_init();
	stage_objpools_init();
	stage_draw_preload(rg);
	stage_preload(stage, rg);
	stage_draw_init();
	lasers_init();

	rng_make_active(&global.rand_game);
	stage_start(stage);

	uint64_t start_time, seed;

	if(global.replay.input.replay) {
		ReplayStage *rstg = NOT_NULL(global.replay.input.stage);
		assert(stageinfo_get_by_id(rstg->stage) == stage);
		assert(!rstg->skip_frames || !quickload);

		start_time = rstg->start_time;
		seed = rstg->rng_seed;
		global.diff = rstg->diff;

		log_debug("REPLAY_PLAY mode: %d events, stage: \"%s\"", rstg->events.num_elements, stage->title);
	} else {
		start_time = (uint64_t)time(0);
		seed = makeseed();

		StageProgress *p = NOT_NULL(stageinfo_get_progress(stage, global.diff, true));
		progress_register_stage_played(p, global.plr.mode);
	}

	rng_seed(&global.rand_game, seed);

	if(global.replay.input.replay) {
		player_init(&global.plr);
		replay_stage_sync_player_state(global.replay.input.stage, &global.plr);
	}

	if(global.replay.output.replay) {
		global.replay.output.stage = replay_stage_new(
			global.replay.output.replay,
			stage,
			start_time,
			seed,
			global.diff,
			&global.plr
		);
		player_init(&global.plr);
		replay_stage_sync_player_state(global.replay.output.stage, &global.plr);
	}

	plrmode_preload(global.plr.mode, rg);

	res_purge();

	auto fstate = ALLOC(StageFrameState, {
		.stage = stage,
		.cc = next,
		.quicksave = quickload,
		.quicksave_is_automatic = quicksave_is_automatic,
		.desync_check_freq = env_get("TAISEI_REPLAY_DESYNC_CHECK_FREQUENCY", FPS * 5),
		.dynstage_generation = dynstage_generation,
		.rg = rg,
	});

	cosched_init(&fstate->sched);
	_current_stage_state = fstate;

	skipstate_init();

	if(should_skip_frame(fstate)) {
		audio_sfx_set_enabled(false);
	}

	if(!is_quickloading(fstate)) {
		demoplayer_suspend();
	}

	SCHED_INVOKE_TASK(&fstate->sched, stage_comain, fstate);
	eventloop_enter(fstate, stage_logic_frame, stage_render_frame, stage_end_loop, FPS);
}

void stage_enter(StageInfo *stage, ResourceGroup *rg, CallChain next) {
	_stage_enter(stage, rg, next, NULL, false);
}

void stage_end_loop(void *ctx) {
	StageFrameState *s = ctx;
	assert(s == _current_stage_state);

	recover_after_skip(s);

	Replay *quicksave = s->quicksave;
	bool quicksave_is_automatic = s->quicksave_is_automatic;
	bool is_quickload = s->quickload_requested;

	if(is_quickload) {
		assume(quicksave != NULL);
	}

	if(global.replay.output.replay) {
		if(is_quickload) {
			// rollback this stage, as we're about to replay it
			global.replay.output.replay->stages.num_elements--;
			replay_stage_destroy_events(global.replay.output.stage);
		} else {
			replay_stage_event(global.replay.output.stage, global.frames, EV_OVER, 0);
			global.replay.output.stage->plr_points_final = global.plr.points;

			if(global.gameover == GAMEOVER_WIN) {
				global.replay.output.stage->flags |= REPLAY_SFLAG_CLEAR;
			}

			replay_stage_update_final_stats(global.replay.output.stage, &global.plr.stats);
		}
	}

	if(quicksave && !is_quickload) {
		replay_reset(quicksave);
		mem_free(quicksave);
	}

	s->stage->procs->end();
	stage_draw_shutdown();
	cosched_finish(&s->sched);
	stage_free();
	player_free(&global.plr);
	ent_shutdown();
	rng_make_active(&global.rand_visual);
	stop_all_sfx();

	taisei_commit_persistent_data();
	skipstate_shutdown();

	if(taisei_quit_requested()) {
		global.gameover = GAMEOVER_ABORT;
	}

	_current_stage_state = NULL;

	StageInfo *stginfo = s->stage;
	CallChain cc = s->cc;
	ResourceGroup *rg = s->rg;

	mem_free(s);

	if(is_quickload) {
		_stage_enter(stginfo, rg, cc, quicksave, quicksave_is_automatic);
	} else {
		demoplayer_resume();
		run_call_chain(&cc, NULL);
	}
}

void stage_unlock_bgm(const char *bgm) {
	if(global.replay.input.replay == NULL && !global.plr.stats.total.continues_used) {
		progress_unlock_bgm(bgm);
	}
}

void stage_shake_view(float strength) {
	assume(strength >= 0);
	_current_stage_state->view_shake += strength;
}

float stage_get_view_shake_strength(void) {
	if(_current_stage_state) {
		return _current_stage_state->view_shake;
	}

	return 0;
}

void stage_load_quicksave(void) {
	stage_do_quickload(NOT_NULL(_current_stage_state));
}

CoSched *stage_get_sched(void) {
	return &NOT_NULL(_current_stage_state)->sched;
}
