const std = @import("std");
const rl = @import("raylib");
const rlg = @import("raygui");
const sin = std.math.sin;
const cos = std.math.cos;
const assert = std.debug.assert;

const cell_size = 32;
const cells_width = 16.0;
const cells_height = 9.0;
const world_width = cells_width * cell_size;
const world_height = cells_height * cell_size;

const bg_color = rl.Color.init(0x20, 0x2e, 0x37, 0xFF);
const highlight_color = rl.Color.init(0x57, 0x72, 0x77, 0xFF);

const debug = false;
const edit = debug and false;

var screen_width: i32 = 1280;
var screen_height: i32 = 720;

const konami_code = [_]rl.KeyboardKey{
    .up,
    .up,
    .down,
    .down,
    .left,
    .right,
    .left,
    .right,
    .b,
    .a,
    .enter,
};

// TODO: ideas for the future (after hackathon)
// "Heat system" (shooting lazers increases heat, heat decreases over time, if heat is too high, CPU throttles)
// "Cooling system" some way to have CPUs that are more resistant to heat

pub fn main() anyerror!void {
    // Initialization
    //--------------------------------------------------------------------------------------

    rl.setConfigFlags(.{ .window_resizable = false, .window_highdpi = true });
    rl.initWindow(screen_width, screen_height, "CPU vs AI");
    defer rl.closeWindow(); // Close window and OpenGL context

    rl.initAudioDevice();
    defer rl.closeAudioDevice();

    var game = Game.init();
    defer game.deinit();

    const music = rl.loadMusicStream("assets/sfx/music.wav") catch unreachable;
    rl.setMusicVolume(music, 0.5);
    rl.playMusicStream(music);

    rl.setWindowIcon(rl.loadImage("assets/img/icon.png") catch unreachable);

    rl.setTargetFPS(60); // Set our game to run at 60 frames-per-second
    rl.setExitKey(.caps_lock);
    //--------------------------------------------------------------------------------------

    // Main game loop
    while (!rl.windowShouldClose()) { // Detect window close button or ESC key
        // Update
        //----------------------------------------------------------------------------------
        rl.updateMusicStream(music);
        game.process();
        //----------------------------------------------------------------------------------

        // Draw
        //----------------------------------------------------------------------------------
        game.render();
        //----------------------------------------------------------------------------------
    }
}

/// Gets the mouse position, scaled by camera zoom and clamped to fix issues on MacOS
fn getMousePos(camera: *const rl.Camera2D) rl.Vector2 {
    const msp = rl.getMousePosition().scale(1 / camera.zoom);

    return .{
        .x = @max(msp.x, 0),
        .y = @max(msp.y, 0),
    };
}

fn readCode(counter: *u8, seq: []const rl.KeyboardKey) bool {
    if (counter.* >= seq.len) {
        counter.* = 0;
        return true;
    } else if (rl.isKeyPressed(seq[@as(usize, counter.*)])) {
        counter.* += 1;
    }

    return false;
}

fn textureButtonScaled(game: *Game, camera: *const rl.Camera2D, texture: rl.Texture2D, pos: rl.Vector2, rotation: f32, scale: f32) bool {
    const msp = getMousePos(camera);

    const rect = rl.Rectangle.init(pos.x, pos.y, cell_size * scale, cell_size * scale);
    const ms_rect = rl.Rectangle.init(msp.x, msp.y, 1, 1);
    const hovered = rect.checkCollision(ms_rect);
    const clicked = hovered and rl.isMouseButtonPressed(.left);
    const offset: f32 = if (hovered) 1 else 0;

    rl.drawTexturePro(
        texture,
        .{ .x = offset * cell_size, .y = 0, .width = cell_size, .height = cell_size },
        rect,
        rl.Vector2.zero(),
        rotation,
        rl.Color.white,
    );

    if (clicked) {
        rl.playSound(game.sound_map.get(.button_click).?);
    }

    return clicked;
}

fn labelButton(
    game: *Game,
    camera: *const rl.Camera2D,
    text: [:0]const u8,
    rect: rl.Rectangle,
    opt: struct {
        font: rl.Font,
        font_size: f32,
        spacing: f32,
        text_color: rl.Color = rl.Color.white,
    },
) bool {
    const msp = getMousePos(camera);
    const texture = game.texture_map.get(.button).?;

    const ms_rect = rl.Rectangle.init(msp.x, msp.y, 1, 1);
    const hovered = rect.checkCollision(ms_rect);
    const mouse_down = rl.isMouseButtonDown(.left);
    const clicked = hovered and rl.isMouseButtonReleased(.left);
    const offset: f32 = if (mouse_down) 2 else if (hovered) 1 else 0;

    assert(rect.width >= cell_size * 2); // Button needs at least 2 cells!
    assert(@mod(rect.width, cell_size) == 0); // Needs to be a multiple of cell size
    assert(rect.height == cell_size); // Needs to be a single row

    rl.drawTexturePro(
        texture,
        .{ .x = cell_size * 3 * offset, .y = 0, .width = cell_size, .height = cell_size },
        .{ .x = rect.x, .y = rect.y, .width = cell_size, .height = rect.height },
        rl.Vector2.zero(),
        0,
        rl.Color.white,
    );
    rl.drawTexturePro(
        texture,
        .{ .x = cell_size * 3 * offset + cell_size, .y = 0, .width = cell_size, .height = cell_size },
        .{ .x = rect.x + cell_size, .y = rect.y, .width = rect.width - cell_size, .height = rect.height },
        rl.Vector2.zero(),
        0,
        rl.Color.white,
    );
    rl.drawTexturePro(
        texture,
        .{ .x = cell_size * 3 * offset + (cell_size * 2), .y = 0, .width = cell_size, .height = cell_size },
        .{ .x = rect.x + rect.width - cell_size, .y = rect.y, .width = cell_size, .height = rect.height },
        rl.Vector2.zero(),
        0,
        rl.Color.white,
    );

    const text_vec = rl.measureTextEx(opt.font, text, opt.font_size, opt.spacing);
    rl.drawTextEx(
        opt.font,
        text,
        rl.Vector2.init(rect.x + ((rect.width - text_vec.x) / 2), rect.y + ((rect.height - text_vec.y) / 2)),
        opt.font_size,
        opt.spacing,
        opt.text_color,
    );

    if (mouse_down) {
        rl.playSound(game.sound_map.get(.button_click).?);
    }

    return clicked;
}

const BugKind = enum(u8) {
    nullptr_deref = 0,
    stack_overflow = 1,
    infinite_loop = 2,

    const count = @typeInfo(BugKind).@"enum".fields.len;
};

const Bug = struct {
    kind: BugKind,
    health: f32,
    position: rl.Vector2,
    previous: rl.Vector2, // Center of prev
    target: rl.Vector2, // Center of next cell
    animation_time: f32 = 0,
    animation_state: f32 = 0,
    prev_angle: f32 = 0,
    angle: f32 = 0,
    dead: bool = false,

    pub fn init(kind: BugKind, position: rl.Vector2) Bug {
        return Bug{
            .kind = kind,
            .health = maxHealth(kind),
            .position = position,
            .previous = position,
            .target = position.add(rl.Vector2.init(cell_size, 0)), // 1 unit to the right
        };
    }

    pub fn update(self: *Bug, delta_time: f32, wave: *Wave, ram: *f32, game: *Game) void {
        self.animation_time += delta_time;
        if (self.animation_time > animationSwitchThreshold(self.kind)) {
            self.animation_time = 0;
            self.animation_state = @mod((self.animation_state + 1), animationCount(self.kind));
        }

        var velocity: rl.Vector2 = self.target.subtract(self.position);
        velocity = velocity.normalize().scale(speed(self.kind));
        self.position = self.position.add(velocity);

        const prev_grid_x: usize = @intFromFloat(self.previous.x / cell_size);
        const prev_grid_y: usize = @intFromFloat(self.previous.y / cell_size);
        const grid_x: usize = @intFromFloat(self.position.x / cell_size);
        const grid_y: usize = @intFromFloat(self.position.y / cell_size);
        const target_grid_x: usize = @intFromFloat(self.target.x / cell_size);
        const target_grid_y: usize = @intFromFloat(self.target.y / cell_size);

        const c_lane_left = wave.get(grid_x -| 1, grid_y).isLaneConnected();
        const c_lane_right = wave.get(grid_x + 1, grid_y).isLaneConnected();
        const c_lane_top = wave.get(grid_x, grid_y -| 1).isLaneConnected();
        const c_lane_bottom = wave.get(grid_x, grid_y + 1).isLaneConnected();

        const matching = grid_x == target_grid_x and grid_y == target_grid_y;
        const prev_angle = self.prev_angle;

        // For animation rotation
        var desired_angle_delta: f32 = 0.0;
        if (c_lane_left and c_lane_bottom and !matching) {
            desired_angle_delta = 90;
        } else if (c_lane_right and c_lane_top and !matching) {
            desired_angle_delta = -90;
        } else if (c_lane_left and c_lane_top and !matching) {
            desired_angle_delta = -90;
        } else if (c_lane_right and c_lane_bottom and !matching) {
            desired_angle_delta = 90;
        }

        const distance = cell_size / 2;
        // const t = if (matching)
        //     self.position.distance(self.target) / distance
        // else
        //     self.position.distance(self.previous) / distance;
        const t = self.position.distance(self.previous) / distance;

        const desired_angle = prev_angle + desired_angle_delta;
        if (desired_angle_delta != 0) {
            // self.angle = rl.math.lerp(@min(prev_angle, desired_angle), @max(prev_angle, desired_angle), t);
            self.angle = rl.math.lerp(prev_angle, desired_angle, t);

            if (t > 0.99) {
                // self.prev_angle = self.angle;
                self.prev_angle = @round(self.angle / 90.0) * 90.0;
            }

            std.debug.print("------------------------\n", .{});
            std.debug.print("angle: {d}\n", .{self.angle});
            std.debug.print("t: {d}\n", .{t});
            std.debug.print("desired_angle_delta: {d}\n", .{desired_angle_delta});
            std.debug.print("desired_angle: {d}\n", .{desired_angle});
            std.debug.print("prev_angle: {d}\n", .{prev_angle});
            std.debug.print("self.prev_angle: {d}\n", .{self.prev_angle});
        }

        // 1 is 1/32th of a cell
        if (self.position.distanceSqr(self.target) < 1) {
            // Find new target

            const t_lane_left = wave.get(target_grid_x - 1, target_grid_y).isLaneConnected();
            const t_lane_right = wave.get(target_grid_x + 1, target_grid_y).isLaneConnected();
            const t_lane_top = wave.get(target_grid_x, target_grid_y - 1).isLaneConnected();
            const t_lane_bottom = wave.get(target_grid_x, target_grid_y + 1).isLaneConnected();

            var target: rl.Vector2 = rl.Vector2.init(0, 0);
            if (t_lane_left and prev_grid_x != target_grid_x - 1) {
                target = self.target.add(rl.Vector2.init(-cell_size, 0));
                std.debug.print("lane left\n", .{});
            }
            if (t_lane_right and prev_grid_x != target_grid_x + 1) {
                target = self.target.add(rl.Vector2.init(cell_size, 0));
                std.debug.print("lane right\n", .{});
            }
            if (t_lane_top and prev_grid_y != target_grid_y - 1) {
                target = self.target.add(rl.Vector2.init(0, -cell_size));
                std.debug.print("lane top\n", .{});
            }
            if (t_lane_bottom and prev_grid_y != target_grid_y + 1) {
                target = self.target.add(rl.Vector2.init(0, cell_size));
                std.debug.print("lane bottom\n", .{});
            }
            if (target.x == 0 and target.y == 0) {
                if (wave.get(target_grid_x, target_grid_y) == .ram) {
                    self.dead = true;
                    const prev_ram_percentage = ram.* / ScreenBattle.max_ram;
                    ram.* = @max(ram.* - self.memory(), 0);
                    const ram_percentage = ram.* / ScreenBattle.max_ram;

                    const is_75 = prev_ram_percentage > 0.75 and ram_percentage <= 0.75;
                    const is_50 = prev_ram_percentage > 0.5 and ram_percentage <= 0.5;
                    const is_25 = prev_ram_percentage > 0.25 and ram_percentage <= 0.25;
                    const is_0 = prev_ram_percentage > 0 and ram_percentage <= 0;
                    if (is_75 or is_50 or is_25 or is_0) {
                        rl.playSound(game.sound_map.get(.ram_destroyed).?);
                    } else {
                        rl.playSound(game.sound_map.get(.bug_attack).?);
                    }
                } else {
                    unreachable;
                }
            }

            self.previous = self.target;
            self.target = target;
        }
    }

    fn transistors(bug: Bug) u32 {
        return switch (bug.kind) {
            .nullptr_deref => return 10,
            .stack_overflow => return 20,
            .infinite_loop => return 5,
        };
    }

    pub fn maxHealth(kind: BugKind) f32 {
        return switch (kind) {
            .nullptr_deref => return 2,
            .stack_overflow => return 10,
            .infinite_loop => return 0.5,
        };
    }

    fn speed(kind: BugKind) f32 {
        return switch (kind) {
            .nullptr_deref => return 0.5,
            .stack_overflow => return 0.1,
            .infinite_loop => return 4,
        };
    }

    fn damage(self: *Bug, dmg: f32) bool {
        self.health -= dmg;
        if (self.health <= 0) {
            self.dead = true;
        }
        return self.dead;
    }

    fn memory(self: Bug) f32 {
        return switch (self.kind) {
            .nullptr_deref => return 10,
            .stack_overflow => return 20,
            .infinite_loop => return 6,
            // .nullptr_deref => return 1,
            // .stack_overflow => return 2,
            // .infinite_loop => return 0.5,
        } * self.health;
    }

    fn animationCount(kind: BugKind) f32 {
        return switch (kind) {
            .nullptr_deref => return 2,
            .stack_overflow => return 2,
            .infinite_loop => return 6,
        };
    }

    fn animationSwitchThreshold(kind: BugKind) f32 {
        return (1 / speed(kind)) / (animationCount(kind) * 3);
        // return switch (kind) {
        //     .nullptr_deref => return 0.3,
        //     .stack_overflow => return 0.3,
        //     .infinite_loop => return 0.05,
        // };
    }
};

const Instruction = struct {
    condition: Condition = .always,
    opcode: Opcode = .noop,

    const Condition = union(enum) {
        always,
        memory_leak: f32, // Health percentage, ram<0.25 or ram<0.5 (randomized)
        bug: BugKind, // Against this specific enemy type
        idle: f32, // Idle time in seconds, 1s-2s (randomized)
    };

    const Opcode = union(enum) {
        noop, // does nothing
        sleep: f32, // Slows enemies (multiplier of enemy speed, 0.5-0.9 randomized)
        prefetch: f32, // Larger radius (multiplier of cache size, 1.5-2 randomized)
        overclock: f32, // Deals more damage (multiplier of bus width, 1.5-2 randomized)
        // // overclock: f32, // Faster firerate (multiplier of clock speed, 1.1-1.5 randomized)
    };
};

const Cpu = struct {
    // clock_speed: f32 = 1, // Fire rate, every how many seconds to fire
    bus_width: f32 = 1, // Bus width, damage dealt
    cache_size: f32 = 1.5, // Cache size, range (distance for attack)
    debugs: u32 = 0, // How many bugs were killed
    instructions: [max_instructions]Instruction = [_]Instruction{.{}} ** max_instructions, // modifiers
    upgrades: u32 = 1, // TODO: remove this after hackathon, for a better instruction system

    const max_instructions = 5;

    fn damage(self: Cpu) f32 {
        return self.bus_width;
    }

    fn cores(self: Cpu) u32 {
        return switch (self.level()) {
            1 => 1,
            2 => 2,
            3 => 4,
            4 => 8,
            else => 16,
        };
    }

    fn level(self: Cpu) u32 {
        return switch (self.debugs) {
            // TODO: only for debug (change this once ready for play testing)
            // 0...2 => 1,
            // 3...4 => 2,
            // 5...6 => 3,
            // 7...8 => 4,
            // else => 5,
            0...9 => 1,
            10...24 => 2,
            25...74 => 3,
            75...250 => 4,
            else => 5,
        };
    }

    fn upgradeBusWidth(self: *Cpu) bool {
        if (self.upgrades < self.level()) {
            self.upgrades += 1;
            self.bus_width += 1;
            return true;
        }
        return false;
    }

    fn upgradeCacheSize(self: *Cpu) bool {
        if (self.upgrades < self.level()) {
            self.upgrades += 1;
            self.cache_size += 0.5;
            return true;
        }
        return false;
    }

    fn hasUpgrades(self: Cpu) bool {
        return self.upgrades < self.level();
    }
};

const Cell = union(enum) {
    none,
    socket,
    cpu: Cpu,
    ai,
    ram,
    lane,

    fn isLaneConnected(self: Cell) bool {
        return switch (self) {
            .lane => true,
            .ai => true,
            .ram => true,
            else => false,
        };
    }
};

const SpawnRule = struct {
    from_time_s: f32,
    to_time_s: f32,
    bugs: [BugKind.count]struct {
        last_spawn: f32 = 0,
        spawn_interval: f32,
    }, // index is enum
};

const Wave = struct {
    arena: std.heap.ArenaAllocator,
    map: [cells_height][cells_width]Cell = [_][cells_width]Cell{[_]Cell{.none} ** cells_width} ** cells_height,
    bugs: std.ArrayList(Bug),
    spawn_rules: std.ArrayList(SpawnRule),
    time_since_start: f32 = 0,

    fn init(wave_number: u8) Wave {
        const height_middle = 4;

        // TODO: probably fine for hackathon but post hackathon, this is really bad
        // We alloc 2 arraylists on this, we need a pool allocator
        var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);

        // This is horrible code, sorry
        var spawn_rules = std.ArrayList(SpawnRule).init(arena.allocator());
        var map: [cells_height][cells_width]Cell = undefined;
        if (wave_number == 1) {
            map = [_][cells_width]Cell{
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .socket, .socket, .socket, .socket, .socket, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .socket, .lane, .lane, .lane, .socket, .none, .none, .none, .none, .none, .none },
                .{ .none, .socket, .socket, .socket, .socket, .socket, .lane, .socket, .lane, .socket, .socket, .socket, .socket, .socket, .socket, .none },
                .{ .ai, .lane, .lane, .lane, .lane, .socket, .lane, .socket, .lane, .lane, .lane, .lane, .lane, .lane, .lane, .ram },
                .{ .none, .socket, .socket, .socket, .lane, .socket, .lane, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .none },
                .{ .none, .none, .none, .socket, .lane, .lane, .lane, .socket, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .socket, .socket, .socket, .socket, .socket, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
            };

            spawn_rules.append(.{
                .from_time_s = 0,
                .to_time_s = 4,
                .bugs = .{
                    .{ .spawn_interval = 3 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 7,
                .to_time_s = 20,
                .bugs = .{
                    .{ .spawn_interval = 2.5 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 24,
                .to_time_s = 32,
                .bugs = .{
                    .{ .spawn_interval = 2 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 35,
                .to_time_s = 36.1,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 1 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 40,
                .to_time_s = 65,
                .bugs = .{
                    .{ .spawn_interval = 1.2 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 60,
                .to_time_s = 60.15,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0.1 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 60,
                .to_time_s = 71,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 2 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 65,
                .to_time_s = 75,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 4 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;
        } else if (wave_number == 2) {
            spawn_rules.append(.{
                .from_time_s = 0,
                .to_time_s = 40,
                .bugs = .{
                    .{ .spawn_interval = 2 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 20,
                .to_time_s = 60,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 3 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 20,
                .to_time_s = 80,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 5 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 30,
                .to_time_s = 80,
                .bugs = .{
                    .{ .spawn_interval = 0.8 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 50,
                .to_time_s = 120,
                .bugs = .{
                    .{ .spawn_interval = 0.5 },
                    .{ .spawn_interval = 2 },
                    .{ .spawn_interval = 1 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 80,
                .to_time_s = 120,
                .bugs = .{
                    .{ .spawn_interval = 0.5 },
                    .{ .spawn_interval = 1 },
                    .{ .spawn_interval = 0.5 },
                },
            }) catch unreachable;

            map = .{
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .socket, .lane, .lane, .lane, .lane, .lane, .socket, .none, .none, .none, .none, .none, .none },
                .{ .none, .socket, .socket, .socket, .lane, .socket, .socket, .socket, .lane, .socket, .socket, .socket, .socket, .socket, .socket, .none },
                .{ .ai, .lane, .lane, .lane, .lane, .socket, .lane, .lane, .lane, .socket, .lane, .lane, .lane, .lane, .lane, .ram },
                .{ .none, .socket, .socket, .socket, .socket, .socket, .lane, .socket, .socket, .socket, .lane, .socket, .socket, .socket, .socket, .none },
                .{ .none, .none, .none, .none, .none, .socket, .lane, .lane, .lane, .lane, .lane, .socket, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
            };
        } else if (wave_number == 3) {
            spawn_rules.append(.{
                .from_time_s = 0,
                .to_time_s = 3,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0.9 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 3,
                .to_time_s = 40,
                .bugs = .{
                    .{ .spawn_interval = 1.8 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 30,
                .to_time_s = 43,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 3 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 20,
                .to_time_s = 40,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 5 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 40,
                .to_time_s = 41,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0.03 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 40,
                .to_time_s = 100,
                .bugs = .{
                    .{ .spawn_interval = 0.5 },
                    .{ .spawn_interval = 1 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 70,
                .to_time_s = 71,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0.01 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 80,
                .to_time_s = 82,
                .bugs = .{
                    .{ .spawn_interval = 0.1 },
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            spawn_rules.append(.{
                .from_time_s = 87,
                .to_time_s = 88,
                .bugs = .{
                    .{ .spawn_interval = 0 },
                    .{ .spawn_interval = 0.1 },
                    .{ .spawn_interval = 0 },
                },
            }) catch unreachable;

            map = .{
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
                .{ .none, .socket, .socket, .socket, .socket, .socket, .none, .none, .none, .none, .none, .none, .socket, .socket, .socket, .none },
                .{ .ai, .lane, .lane, .lane, .lane, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .socket, .lane, .lane, .ram },
                .{ .none, .socket, .socket, .socket, .lane, .socket, .socket, .lane, .lane, .lane, .lane, .lane, .socket, .lane, .socket, .none },
                .{ .none, .none, .none, .socket, .lane, .lane, .lane, .lane, .socket, .socket, .socket, .lane, .lane, .lane, .socket, .none },
                .{ .none, .none, .none, .socket, .socket, .socket, .socket, .socket, .socket, .none, .socket, .socket, .socket, .socket, .socket, .none },
                .{ .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none, .none },
            };
        } else {
            unreachable;
        }

        var wave = Wave{
            .arena = arena,
            .bugs = std.ArrayList(Bug).init(arena.allocator()),
            .spawn_rules = spawn_rules,
            .map = map,
        };

        wave.map[height_middle][0] = .ai;
        wave.map[height_middle][cells_width - 1] = .ram;

        return wave;
    }

    fn update(self: *Wave, delta_time: f32, ram: *f32, game: *Game) bool {
        // TODO: quite a bit of logic here
        self.time_since_start += delta_time;

        const half_cell = cell_size / 2;
        const ai_position = rl.Vector2.init(half_cell, 4 * cell_size + half_cell);

        var wave_over = true;

        // TODO: post hackathon, optimize this
        for (self.spawn_rules.items) |*rules| {
            if (rules.to_time_s < self.time_since_start) {
                continue;
            }
            wave_over = false;
            if (rules.from_time_s > self.time_since_start) {
                continue;
            }

            for (0..rules.bugs.len) |i| {
                if (rules.bugs[i].spawn_interval == 0) {
                    continue;
                }

                rules.bugs[i].last_spawn += delta_time;
                if (rules.bugs[i].last_spawn >= rules.bugs[i].spawn_interval) {
                    rules.bugs[i].last_spawn -= rules.bugs[i].spawn_interval;
                    self.bugs.append(Bug.init(@enumFromInt(i), ai_position)) catch unreachable;
                }
            }
        }

        for (self.bugs.items) |*bug| {
            if (bug.dead) {
                continue;
            }
            wave_over = false;
            bug.update(delta_time, self, ram, game);
        }

        return wave_over;
    }

    fn deinit(self: *Wave) void {
        self.arena.deinit();
    }

    fn at(self: *Wave, x: usize, y: usize) ?*Cell {
        if (x >= cells_width or y >= cells_height or x < 0 or y < 0) {
            return null;
        }
        return &self.map[y][x];
    }

    fn get(self: *Wave, x: usize, y: usize) Cell {
        if (x >= cells_width or y >= cells_height or x < 0 or y < 0) {
            return .none;
        }
        return self.map[y][x];
    }

    fn set(self: *Wave, x: usize, y: usize, cell: Cell) void {
        if (x < cells_width and y < cells_height and x >= 0 and y >= 0) {
            self.map[y][x] = cell;
        }
    }
};

const TextureKind = enum {
    cpus_vs_bugs,
    power_button,
    socket,
    lane,
    ai,
    cpu,
    ram,
    bug_null,
    bug_while,
    bug_stackoverflow,
    transistor,
    cpu_pins,
    upgrade_popup,
    button,
};

const SoundKind = enum {
    bug_death,
    bug_attack,
    ram_destroyed,
    cpu_place,
    cpu_upgrade,
    button_click,
};

const Game = struct {
    global_arena: std.heap.ArenaAllocator,
    frame_arena: std.heap.ArenaAllocator,

    texture_map: std.AutoHashMap(TextureKind, rl.Texture2D),
    sound_map: std.AutoHashMap(SoundKind, rl.Sound),

    font_title: rl.Font,
    font_normal: rl.Font,

    screen_state: union(enum) {
        main: ScreenMainMenu,
        battle: ScreenBattle,
        gameover: ScreenGameOver,
        victory: ScreenVictory,
    },

    fn init() Game {
        var global_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
        const ga = global_arena.allocator();
        var texture_map = std.AutoHashMap(TextureKind, rl.Texture2D).init(ga);
        var sound_map = std.AutoHashMap(SoundKind, rl.Sound).init(ga);

        // TODO: make this texture loading more dynamic - DO THIS AFTER JAM
        const cpus_vs_bugs = rl.loadTexture("assets/img/cpus-vs-bugs.png") catch unreachable;
        const power_button = rl.loadTexture("assets/img/power-button.png") catch unreachable;
        const lane = rl.loadTexture("assets/img/lane.png") catch unreachable;
        const socket = rl.loadTexture("assets/img/socket.png") catch unreachable;
        const ai = rl.loadTexture("assets/img/ai.png") catch unreachable;
        const bug_so = rl.loadTexture("assets/img/stackoverflow.png") catch unreachable;
        const bug_null = rl.loadTexture("assets/img/nullptr-deref.png") catch unreachable;
        const bug_while = rl.loadTexture("assets/img/while1.png") catch unreachable;
        const ram = rl.loadTexture("assets/img/ram.png") catch unreachable;
        const transistor = rl.loadTexture("assets/img/transistor.png") catch unreachable;
        const cpu_pins = rl.loadTexture("assets/img/cpu_pins.png") catch unreachable;
        const cpu = rl.loadTexture("assets/img/cpu.png") catch unreachable;
        const upgrade_popup = rl.loadTexture("assets/img/upgrade-popup.png") catch unreachable;
        const button = rl.loadTexture("assets/img/button.png") catch unreachable;
        texture_map.put(.cpus_vs_bugs, cpus_vs_bugs) catch unreachable;
        texture_map.put(.power_button, power_button) catch unreachable;
        texture_map.put(.socket, socket) catch unreachable;
        texture_map.put(.lane, lane) catch unreachable;
        texture_map.put(.ai, ai) catch unreachable;
        texture_map.put(.bug_null, bug_null) catch unreachable;
        texture_map.put(.bug_stackoverflow, bug_so) catch unreachable;
        texture_map.put(.bug_while, bug_while) catch unreachable;
        texture_map.put(.ram, ram) catch unreachable;
        texture_map.put(.transistor, transistor) catch unreachable;
        texture_map.put(.cpu_pins, cpu_pins) catch unreachable;
        texture_map.put(.cpu, cpu) catch unreachable;
        texture_map.put(.upgrade_popup, upgrade_popup) catch unreachable;
        texture_map.put(.button, button) catch unreachable;

        const bug_death = rl.loadSound("assets/sfx/bug-death.wav") catch unreachable;
        rl.setSoundVolume(bug_death, 0.3);
        const bug_attack = rl.loadSound("assets/sfx/bug-attack.wav") catch unreachable;
        rl.setSoundVolume(bug_attack, 0.5);
        const ram_destroyed = rl.loadSound("assets/sfx/ram-destroyed.wav") catch unreachable;
        rl.setSoundVolume(ram_destroyed, 0.8);
        const cpu_place = rl.loadSound("assets/sfx/cpu-place.mp3") catch unreachable;
        rl.setSoundVolume(cpu_place, 0.8);
        const cpu_upgrade = rl.loadSound("assets/sfx/cpu-upgrade.wav") catch unreachable;
        rl.setSoundVolume(cpu_upgrade, 0.8);
        const button_click = rl.loadSound("assets/sfx/button-click.mp3") catch unreachable;
        rl.setSoundVolume(button_click, 0.5);
        sound_map.put(.bug_death, bug_death) catch unreachable;
        sound_map.put(.bug_attack, bug_attack) catch unreachable;
        sound_map.put(.ram_destroyed, ram_destroyed) catch unreachable;
        sound_map.put(.cpu_place, cpu_place) catch unreachable;
        sound_map.put(.cpu_upgrade, cpu_upgrade) catch unreachable;
        sound_map.put(.button_click, button_click) catch unreachable;

        const font_title = rl.loadFontEx("assets/font/DepartureMonoNerdFontMono-Regular.otf", 80, null) catch unreachable;
        const font_normal = rl.loadFontEx("assets/font/GohuFont14NerdFontMono-Regular.ttf", 80, null) catch unreachable;

        return .{
            .global_arena = global_arena,
            .frame_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator),
            .texture_map = texture_map,
            .sound_map = sound_map,
            .font_title = font_title,
            .font_normal = font_normal,
            .screen_state = .{
                .main = .init(), // MARK
                // .gameover = .init(),
                // .victory = .init(30.53288888, false, 0.5732),
                // .battle = .init(),
            },
        };
    }

    fn deinit(self: *Game) void {
        self.global_arena.deinit();
        self.frame_arena.deinit();
    }

    fn frameStart(self: *Game) void {
        const reset_successful = self.frame_arena.reset(.retain_capacity);
        assert(reset_successful);
    }

    fn process(self: *Game) void {
        const dt = rl.getFrameTime();

        // std.debug.print("Delta time: {d}\n", .{dt});

        // TODO: find a better method of mutating the original
        switch (self.screen_state) {
            .main => {
                self.screen_state.main.update(self, dt);
            },
            .battle => {
                self.screen_state.battle.update(self, dt);
            },
            .gameover => {
                self.screen_state.gameover.update(self, dt);
            },
            .victory => {
                self.screen_state.victory.update(self, dt);
            },
        }
    }

    fn render(self: *Game) void {
        rl.beginDrawing();
        defer rl.endDrawing();

        // TODO: maybe add a system for each screen to be able to give a color to set the background?
        // just so the render method of the screens doesn't have to directly call rl.clearBackground
        rl.clearBackground(bg_color);

        // TODO: find a better method of mutating the original
        switch (self.screen_state) {
            .main => {
                self.screen_state.main.render(self);
            },
            .battle => {
                self.screen_state.battle.render(self);
            },
            .gameover => {
                self.screen_state.gameover.render(self);
            },
            .victory => {
                self.screen_state.victory.render(self);
            },
        }
    }
};

const ScreenMainMenu = struct {
    camera: rl.Camera2D,
    pressed_start: bool = false,

    const start_button_scaling = 5;
    const start_button_size: f32 = @floatFromInt(cell_size * start_button_scaling);
    const title_size: f32 = @floatFromInt(cell_size * 5);

    fn init() ScreenMainMenu {
        return .{
            .camera = .{
                .target = .{ .x = 128, .y = 128 },
                .offset = .{
                    .x = @as(f32, @floatFromInt(screen_width)) / 2,
                    .y = @as(f32, @floatFromInt(screen_height)) / 2,
                },
                .rotation = 0,
                .zoom = @as(f32, @floatFromInt(screen_height)) / world_height,
            },
        };
    }

    fn update(self: *ScreenMainMenu, game: *Game, dt: f32) void {
        _ = dt;

        self.updateCamera();

        if (self.pressed_start) {
            game.screen_state = .{ .battle = .init() };
        }
    }

    fn render(self: *ScreenMainMenu, game: *Game) void {
        rl.beginMode2D(self.camera);
        defer rl.endMode2D();

        const scaling_factor = 2;
        const title_pos: rl.Vector2 = .{
            .x = (world_width - (ScreenMainMenu.title_size * scaling_factor)) / 2,
            .y = cell_size / 2, // padding
        };

        rl.drawTexturePro(
            game.texture_map.get(.cpus_vs_bugs).?,
            .{
                .x = 0,
                .y = 0,
                .width = ScreenMainMenu.title_size,
                .height = cell_size,
            },
            .{
                .x = title_pos.x,
                .y = title_pos.y,
                .width = ScreenMainMenu.title_size * scaling_factor,
                .height = cell_size * scaling_factor,
            },
            rl.Vector2.zero(),
            0,
            rl.Color.white,
        );

        const padding = cell_size + cell_size / 2;
        self.pressed_start = textureButtonScaled(
            game,
            &self.camera,
            game.texture_map.get(.power_button).?,
            rl.Vector2.init(
                (world_width - ScreenMainMenu.start_button_size) / 2,
                (world_height - ScreenMainMenu.start_button_size) / 2 + padding,
            ),
            0,
            ScreenMainMenu.start_button_scaling,
        );
    }

    fn updateCamera(self: *ScreenMainMenu) void {
        screen_width = rl.getScreenWidth();
        screen_height = rl.getScreenHeight();

        self.camera.offset = .{
            .x = @as(f32, @floatFromInt(screen_width)) / 2,
            .y = @as(f32, @floatFromInt(screen_height)) / 2,
        };

        self.camera.target = .{
            .x = world_width / 2.0,
            .y = world_height / 2.0,
        };

        // Take the average between the ratios
        // This avoids "cheating" by changing the ratio to an extreme value
        // in order to see more terrain in a certain axis
        const width_ratio = @as(f32, @floatFromInt(screen_width)) / world_width;
        const height_ratio = @as(f32, @floatFromInt(screen_height)) / world_height;
        self.camera.zoom = (width_ratio + height_ratio) / 2;
    }
};

const ScreenGameOver = struct {
    camera: rl.Camera2D,
    pressed_retry: bool = false,
    pressed_menu: bool = false,
    dt: f32 = -1.0,
    game_over_text: [:0]const u8 = "Game Over",
    game_over_size: f32 = 22,
    game_over_spacing: f32 = 1.0,
    game_over_color: rl.Color = rl.Color.red,
    game_over_measurement: rl.Vector2 = .{ .x = 0, .y = 0 },
    game_over_widths: [9]f32 = undefined,
    game_over_height: f32 = 0,
    game_over_speed: f32 = 2.0,
    game_over_animation_offset: f32 = 0.2,
    game_over_animation_size: f32 = 0.3,
    initiated: bool = false,

    const button_menu_width: f32 = @floatFromInt(cell_size * 7);
    const button_menu_height: f32 = @floatFromInt(cell_size);

    fn init() ScreenGameOver {
        return .{
            .camera = .{
                .target = .{ .x = 128, .y = 128 },
                .offset = .{
                    .x = @as(f32, @floatFromInt(screen_width)) / 2,
                    .y = @as(f32, @floatFromInt(screen_height)) / 2,
                },
                .rotation = 0,
                .zoom = @as(f32, @floatFromInt(screen_height)) / world_height,
            },
        };
    }

    fn update(self: *ScreenGameOver, game: *Game, dt: f32) void {
        if (self.initiated) {
            self.dt += dt;
        }

        self.updateCamera();

        // Must be the last thing
        if (self.pressed_retry) {
            game.screen_state = .{ .battle = .init() };
        } else if (self.pressed_menu) {
            game.screen_state = .{ .main = .init() };
        }
    }

    fn updateCamera(self: *ScreenGameOver) void {
        screen_width = rl.getScreenWidth();
        screen_height = rl.getScreenHeight();

        self.camera.offset = .{
            .x = @as(f32, @floatFromInt(screen_width)) / 2,
            .y = @as(f32, @floatFromInt(screen_height)) / 2,
        };

        self.camera.target = .{
            .x = world_width / 2.0,
            .y = world_height / 2.0,
        };

        // Take the average between the ratios
        // This avoids "cheating" by changing the ratio to an extreme value
        // in order to see more terrain in a certain axis
        const width_ratio = @as(f32, @floatFromInt(screen_width)) / world_width;
        const height_ratio = @as(f32, @floatFromInt(screen_height)) / world_height;
        self.camera.zoom = (width_ratio + height_ratio) / 2;
    }

    fn render(self: *ScreenGameOver, game: *Game) void {
        rl.beginMode2D(self.camera);
        defer rl.endMode2D();

        if (!self.initiated) {
            var i: usize = 0;
            const len: usize = self.game_over_text.len;
            if (len != self.game_over_widths.len) {
                std.log.err("Game over text length isn't covered properly. adjust ScreenGameOver.game_over_widths and ScreenGameOver.game_over_characters accordingly to {} instead of {}", .{ len, self.game_over_widths.len });
                return;
            }
            while (i < len) : (i += 1) {
                const substring = self.game_over_text[i..];
                const measurement = rl.measureTextEx(game.font_title, substring, self.game_over_size, self.game_over_spacing);
                if (i == 0) {
                    self.game_over_height = measurement.y;
                }
                self.game_over_widths[i] = measurement.x;
            }
            self.initiated = true;
        }

        // total width and height for the game over text
        const tx = self.game_over_widths[0];
        const ty = self.game_over_height;

        // we have widths for the title with the start chopped off.
        // let's calculate the right side
        const rx = (world_width + tx) / 2;
        const ry = (world_height - ty) / 2;
        var i: usize = 0;
        const len: usize = self.game_over_text.len;
        while (i < len) {
            const fi: f32 = @as(f32, @floatFromInt(i));
            const width = self.game_over_widths[i];
            const x = rx - width;
            // feel free to find a better way to extract single characters:
            const char = rl.textFormat("%c", .{self.game_over_text[i]});
            const y = ry - ty * self.game_over_animation_size * sin((self.dt + self.game_over_animation_offset * fi) * self.game_over_speed);
            rl.drawTextEx(game.font_title, char, .{ .x = x, .y = y }, self.game_over_size, self.game_over_spacing, self.game_over_color);
            i += 1;
        }

        self.pressed_menu = labelButton(
            game,
            &self.camera,
            "Back to main menu",
            rl.Rectangle.init(
                (world_width - ScreenGameOver.button_menu_width) / 2,
                (world_height - ScreenGameOver.button_menu_height) / 2 + cell_size * 1.25,
                ScreenGameOver.button_menu_width,
                ScreenGameOver.button_menu_height,
            ),
            .{
                .font = game.font_title,
                .font_size = 10,
                .spacing = 5,
            },
        );
        self.pressed_retry = labelButton(
            game,
            &self.camera,
            "Retry",
            rl.Rectangle.init(
                (world_width - ScreenGameOver.button_menu_width) / 2,
                (world_height - ScreenGameOver.button_menu_height) / 2 + cell_size * 2.25,
                ScreenGameOver.button_menu_width,
                ScreenGameOver.button_menu_height,
            ),
            .{
                .font = game.font_title,
                .font_size = 10,
                .spacing = 5,
            },
        );
    }
};

const ScreenVictory = struct {
    camera: rl.Camera2D,
    pressed_play_again: bool = false,
    pressed_menu: bool = false,
    dt: f32 = -1.0,
    text: [:0]const u8 = "You win!",
    size: f32 = 22,
    spacing: f32 = 1.0,
    color: rl.Color = rl.Color.green,
    measurement: rl.Vector2 = .{ .x = 0, .y = 0 },
    widths: [8]f32 = undefined,
    height: f32 = 0,
    speed: f32 = 2.0,
    animation_offset: f32 = 0.2,
    animation_size: f32 = 0.3,
    initiated: bool = false,
    completion_time: f32,
    skipped: bool,
    ram_percentage: f32,

    const button_menu_width: f32 = @floatFromInt(cell_size * 7);
    const button_menu_height: f32 = @floatFromInt(cell_size);

    fn init(completion_time: f32, skipped: bool, ram_percentage: f32) ScreenVictory {
        return .{
            .camera = .{
                .target = .{ .x = 128, .y = 128 },
                .offset = .{
                    .x = @as(f32, @floatFromInt(screen_width)) / 2,
                    .y = @as(f32, @floatFromInt(screen_height)) / 2,
                },
                .rotation = 0,
                .zoom = @as(f32, @floatFromInt(screen_height)) / world_height,
            },
            .completion_time = completion_time,
            .skipped = skipped,
            .ram_percentage = ram_percentage,
        };
    }

    fn update(self: *ScreenVictory, game: *Game, dt: f32) void {
        if (self.initiated) {
            self.dt += dt;
        }

        self.updateCamera();

        // Must be the last thing
        if (self.pressed_play_again) {
            game.screen_state = .{ .battle = .init() };
        } else if (self.pressed_menu) {
            game.screen_state = .{ .main = .init() };
        }
    }

    fn updateCamera(self: *ScreenVictory) void {
        screen_width = rl.getScreenWidth();
        screen_height = rl.getScreenHeight();

        self.camera.offset = .{
            .x = @as(f32, @floatFromInt(screen_width)) / 2,
            .y = @as(f32, @floatFromInt(screen_height)) / 2,
        };

        self.camera.target = .{
            .x = world_width / 2.0,
            .y = world_height / 2.0,
        };

        // Take the average between the ratios
        // This avoids "cheating" by changing the ratio to an extreme value
        // in order to see more terrain in a certain axis
        const width_ratio = @as(f32, @floatFromInt(screen_width)) / world_width;
        const height_ratio = @as(f32, @floatFromInt(screen_height)) / world_height;
        self.camera.zoom = (width_ratio + height_ratio) / 2;
    }

    fn render(self: *ScreenVictory, game: *Game) void {
        rl.beginMode2D(self.camera);
        defer rl.endMode2D();

        if (!self.initiated) {
            var i: usize = 0;
            const len: usize = self.text.len;
            assert(len == self.widths.len);
            while (i < len) : (i += 1) {
                const substring = self.text[i..];
                const measurement = rl.measureTextEx(game.font_title, substring, self.size, self.spacing);
                if (i == 0) {
                    self.height = measurement.y;
                }
                self.widths[i] = measurement.x;
            }
            self.initiated = true;
        }

        // total width and height for the victory text
        const tx = self.widths[0];
        const ty = self.height;

        // we have widths for the title with the start chopped off.
        // let's calculate the right side
        const rx = (world_width + tx) / 2;
        const ry = (world_height - ty) / 2 - (cell_size * 2);
        var i: usize = 0;
        const len: usize = self.text.len;
        while (i < len) {
            const fi: f32 = @as(f32, @floatFromInt(i));
            const width = self.widths[i];
            const x = rx - width;
            // feel free to find a better way to extract single characters:
            const char = rl.textFormat("%c", .{self.text[i]});
            const y = ry - ty * self.animation_size * sin((self.dt + self.animation_offset * fi) * self.speed);
            rl.drawTextEx(game.font_title, char, .{ .x = x, .y = y }, self.size, self.spacing, self.color);
            i += 1;
        }

        const skipped_text = if (self.skipped) "Skipped: true" else "Skipped: false";
        const skipped_dim = rl.measureTextEx(game.font_title, skipped_text, self.size, self.spacing);
        rl.drawTextEx(
            game.font_title,
            "Skipped: false",
            .{
                .x = (world_width - skipped_dim.x) / 2,
                .y = (world_height - skipped_dim.y) / 2 + (cell_size * 3.5),
            },
            self.size,
            self.spacing,
            .white,
        );
        const completion_time_text = std.fmt.allocPrintZ(
            game.frame_arena.allocator(),
            "Time: {d:.2}s",
            .{self.completion_time},
        ) catch unreachable;
        const completion_time_dim = rl.measureTextEx(game.font_title, completion_time_text, self.size, self.spacing);
        rl.drawTextEx(
            game.font_title,
            completion_time_text,
            .{
                .x = (world_width - completion_time_dim.x) / 2,
                .y = (world_height - completion_time_dim.y) / 2 - (cell_size / 2) - (cell_size / 4),
            },
            self.size,
            self.spacing,
            .white,
        );

        const ram_percentage_text = std.fmt.allocPrintZ(
            game.frame_arena.allocator(),
            "RAM: {d}%",
            .{std.math.round(self.ram_percentage * 100)},
        ) catch unreachable;
        const ram_percentage_dim = rl.measureTextEx(game.font_title, ram_percentage_text, self.size, self.spacing);
        rl.drawTextEx(
            game.font_title,
            ram_percentage_text,
            .{
                .x = (world_width - ram_percentage_dim.x) / 2,
                .y = (world_height - ram_percentage_dim.y) / 2 + (cell_size / 4),
            },
            self.size,
            self.spacing,
            .white,
        );

        self.pressed_menu = labelButton(
            game,
            &self.camera,
            "Back to main menu",
            rl.Rectangle.init(
                (world_width - ScreenVictory.button_menu_width) / 2,
                (world_height - ScreenVictory.button_menu_height) / 2 + cell_size * 1.25,
                ScreenVictory.button_menu_width,
                ScreenVictory.button_menu_height,
            ),
            .{
                .font = game.font_title,
                .font_size = 10,
                .spacing = 5,
            },
        );
        self.pressed_play_again = labelButton(
            game,
            &self.camera,
            "Play again",
            rl.Rectangle.init(
                (world_width - ScreenVictory.button_menu_width) / 2,
                (world_height - ScreenVictory.button_menu_height) / 2 + cell_size * 2.25,
                ScreenVictory.button_menu_width,
                ScreenVictory.button_menu_height,
            ),
            .{
                .font = game.font_normal,
                .font_size = 10,
                .spacing = 5,
            },
        );
    }
};

const ScreenBattle = struct {
    camera: rl.Camera2D,
    wave: Wave,
    wave_number: u8,
    ram: f32, // health of the player
    transistors: u32 = initial_cpu_transistor_cost,
    cpu_transistor_cost: u32 = initial_cpu_transistor_cost,
    wave_over_timer: f32 = 0,
    skipped: bool = false,
    completion_time: f32 = 0,
    popup: bool = false,
    konami_counter: u8 = 0,

    const max_ram = 100;
    const initial_cpu_transistor_cost = 50;
    const wave_continue_delay = 2; // seconds

    fn init() ScreenBattle {
        const wave_number: u8 = 3; // MARK
        return .{
            .wave_number = wave_number,
            .wave = .init(wave_number),
            .camera = .{
                .target = .{ .x = 128, .y = 128 },
                .offset = .{
                    .x = @as(f32, @floatFromInt(screen_width)) / 2,
                    .y = @as(f32, @floatFromInt(screen_height)) / 2,
                },
                .rotation = 0,
                .zoom = @as(f32, @floatFromInt(screen_height)) / world_height,
            },
            .ram = max_ram,
        };
    }

    fn deinit(self: *ScreenBattle) void {
        self.wave.deinit();
    }

    fn update(self: *ScreenBattle, game: *Game, dt: f32) void {
        self.completion_time += dt;
        self.handlePlayerInput(game, dt);

        self.updateCpuAndBugs(game, dt);
        const wave_over = self.wave.update(dt, &self.ram, game);
        if (wave_over) {
            self.wave_over_timer += dt;
        }

        self.updateCamera();

        if (self.ram <= 0) {
            self.deinit();
            game.screen_state = .{ .gameover = .init() };
        } else if (self.wave_over_timer >= wave_continue_delay) {
            self.wave_over_timer = 0;
            self.wave_number += 1;
            self.cpu_transistor_cost = initial_cpu_transistor_cost;
            self.transistors = self.cpu_transistor_cost;
            if (self.wave_number > 3) {
                self.deinit();
                game.screen_state = .{ .victory = .init(self.completion_time, self.skipped, self.ram / max_ram) };
            } else {
                self.wave.deinit();
                self.wave = .init(self.wave_number);
            }
        }
    }

    fn handlePlayerInput(self: *ScreenBattle, game: *Game, dt: f32) void {
        _ = dt;

        const msp = getMousePos(&self.camera);

        const mx: usize = @intFromFloat(msp.x / cell_size);
        const my: usize = @intFromFloat(msp.y / cell_size);

        if (rl.isMouseButtonPressed(rl.MouseButton.left)) {
            if (self.wave.get(mx, my) == .lane and edit) {
                self.wave.set(mx, my, .socket);
            } else if (self.wave.get(mx, my) == .socket and edit) {
                self.wave.set(mx, my, .lane);
            } else if (self.wave.get(mx, my) == .none and edit) {
                self.wave.set(mx, my, .socket);
            }

            if (self.wave.get(mx, my) == .socket and self.transistors >= self.cpu_transistor_cost) {
                self.wave.set(mx, my, .{ .cpu = .{} });
                self.transistors -= self.cpu_transistor_cost;
                self.cpu_transistor_cost *= 2;
                rl.playSound(game.sound_map.get(.cpu_place).?);
            } else if (self.wave.get(mx, my) == .cpu) {
                var cpu = &self.wave.at(mx, my).?.cpu;
                if (cpu.upgradeBusWidth()) {
                    rl.playSound(game.sound_map.get(.cpu_upgrade).?);
                }
            }
        } else if (rl.isMouseButtonPressed(rl.MouseButton.right)) {
            if (self.wave.get(mx, my) == .cpu) {
                var cpu = &self.wave.at(mx, my).?.cpu;
                if (cpu.upgradeCacheSize()) {
                    rl.playSound(game.sound_map.get(.cpu_upgrade).?);
                }
            }

            if (edit) {
                self.wave.set(mx, my, .none);
            }
        }

        if (rl.isKeyPressed(rl.KeyboardKey.s) and debug) {
            const f = std.io.getStdErr().writer();
            std.zon.stringify.serializeArbitraryDepth(self.wave.map, .{
                .whitespace = false,
            }, f) catch unreachable;
            f.writeByte('\n') catch unreachable;
            unreachable;
        }

        var mut_konami_code = konami_code;

        if (readCode(&self.konami_counter, &mut_konami_code)) {
            self.transistors += 10000;
        }
    }

    fn updateCpuAndBugs(self: *ScreenBattle, game: *Game, dt: f32) void {
        const map = self.wave.map;

        for (0..map.len) |y| {
            for (0..map[0].len) |x| {
                const cell = self.wave.at(x, y).?;
                switch (cell.*) {
                    .cpu => {
                        var cpu = &cell.cpu;

                        const cpu_center = rl.Vector2.init(
                            @as(f32, @floatFromInt(x)) * cell_size + cell_size / 2,
                            @as(f32, @floatFromInt(y)) * cell_size + cell_size / 2,
                        );

                        var count: u32 = 0;
                        const radius = cpu.cache_size * cell_size;
                        for (self.wave.bugs.items) |*bug| {
                            if (bug.dead) {
                                continue;
                            }
                            if (count >= cpu.cores()) {
                                break;
                            }

                            const distance = cpu_center.subtract(bug.position).length();
                            if (distance > radius) {
                                continue;
                            }

                            if (bug.damage(cpu.damage() * dt)) {
                                cpu.debugs += 1;
                                rl.playSound(game.sound_map.get(.bug_death).?);
                                self.transistors += bug.transistors();
                            }

                            count += 1;
                        }
                    },
                    else => continue,
                }
            }
        }
    }

    fn updateCamera(self: *ScreenBattle) void {
        screen_width = rl.getScreenWidth();
        screen_height = rl.getScreenHeight();

        self.camera.offset = .{
            .x = @as(f32, @floatFromInt(screen_width)) / 2,
            .y = @as(f32, @floatFromInt(screen_height)) / 2,
        };

        self.camera.target = .{
            .x = world_width / 2.0,
            .y = world_height / 2.0,
        };

        // Take the average between the ratios
        // This avoids "cheating" by changing the ratio to an extreme value
        // in order to see more terrain in a certain axis
        const width_ratio = @as(f32, @floatFromInt(screen_width)) / world_width;
        const height_ratio = @as(f32, @floatFromInt(screen_height)) / world_height;
        self.camera.zoom = (width_ratio + height_ratio) / 2;
    }

    fn render(self: *ScreenBattle, game: *Game) void {
        const a = game.frame_arena.allocator();
        const map = self.wave.map;

        self.popup = false; // reset popup

        {
            rl.beginMode2D(self.camera);
            defer rl.endMode2D();

            // TODO: make bugs render between (on top of) lanes and (under) AI
            for (0..map.len) |y| {
                for (0..map[0].len) |x| {
                    self.drawCell(game, x, y);
                }
            }

            for (0..map.len) |y| {
                for (0..map[0].len) |x| {
                    const cell = self.wave.get(x, y);
                    switch (cell) {
                        .cpu => |cpu| {
                            const cpu_center = rl.Vector2.init(
                                @as(f32, @floatFromInt(x)) * cell_size + cell_size / 2,
                                @as(f32, @floatFromInt(y)) * cell_size + cell_size / 2,
                            );

                            var count: u32 = 0;
                            const radius = cpu.cache_size * cell_size;
                            for (self.wave.bugs.items) |bug| {
                                if (bug.dead) {
                                    continue;
                                }
                                if (count >= cpu.cores()) {
                                    break;
                                }

                                const distance = cpu_center.subtract(bug.position).length();
                                if (distance > radius) {
                                    continue;
                                }

                                rl.drawLineEx(cpu_center, bug.position, cpu.bus_width, rl.Color.red);
                                count += 1;
                            }

                            if (debug) {
                                rl.drawCircleLinesV(cpu_center, cpu.cache_size * cell_size, rl.Color.green);
                            }
                        },
                        else => continue,
                    }
                }
            }

            for (self.wave.bugs.items) |bug| {
                if (bug.dead) {
                    continue;
                }
                const texture = switch (bug.kind) {
                    .nullptr_deref => game.texture_map.get(.bug_null).?,
                    .stack_overflow => game.texture_map.get(.bug_stackoverflow).?,
                    .infinite_loop => game.texture_map.get(.bug_while).?,
                };

                // const angle_deg = bug.angle;
                const angle_deg = 0;
                const angle_rad = angle_deg * (std.math.pi / 180.0);
                const origin_x = cos(angle_rad) * (cell_size / 2) - sin(angle_rad) * (cell_size / 2);
                const origin_y = sin(angle_rad) * (cell_size / 2) + cos(angle_rad) * (cell_size / 2);

                const draw_x = bug.position.x - origin_x;
                const draw_y = bug.position.y - origin_y;

                rl.drawTexturePro(
                    texture,
                    .{ .x = bug.animation_state * cell_size, .y = 0, .width = cell_size, .height = cell_size },
                    .{ .x = draw_x, .y = draw_y, .width = cell_size, .height = cell_size },
                    rl.Vector2.zero(),
                    angle_deg,
                    rl.Color.white,
                );
            }

            const font_size = 24;

            const transistor_texture = game.texture_map.get(.transistor).?;
            rl.drawTexture(transistor_texture, 0, cell_size / 4, rl.Color.white);

            const transistors_count = std.fmt.allocPrintZ(a, "{d}", .{self.transistors}) catch unreachable;
            rl.drawTextEx(
                game.font_title,
                transistors_count,
                .{ .x = cell_size, .y = (cell_size + font_size) / 4 },
                font_size,
                1,
                .white,
            );

            const offset = world_height - cell_size;
            const cpu_texture = game.texture_map.get(.cpu_pins).?;
            rl.drawTexture(cpu_texture, 0, offset, rl.Color.white);
            rl.drawTextureEx(
                transistor_texture,
                .{ .x = cell_size + cell_size / 3, .y = offset + 6 },
                0,
                0.5,
                rl.Color.white,
            );

            const cpu_cost = std.fmt.allocPrintZ(a, "= {d}", .{self.cpu_transistor_cost}) catch unreachable;
            rl.drawTextEx(
                game.font_title,
                cpu_cost,
                .{ .x = cell_size, .y = offset + (font_size) / 8 },
                font_size,
                1,
                .white,
            );

            if (self.popup) {
                const msp = getMousePos(&self.camera);
                const upgrade_texture = game.texture_map.get(.upgrade_popup).?;
                const scale = 2;
                const pos = msp.subtract(.{ .x = cell_size / 2 * scale, .y = cell_size * scale });
                rl.drawTexturePro(
                    upgrade_texture,
                    .{ .x = 0, .y = 0, .width = cell_size, .height = cell_size },
                    .{ .x = pos.x, .y = pos.y, .width = cell_size * scale, .height = cell_size * scale },
                    rl.Vector2.zero(),
                    0,
                    rl.Color.white,
                );
            }
        }

        if (debug) {
            rl.drawRectangle(
                10,
                10,
                @divTrunc(screen_width, 5),
                @divTrunc(screen_height, 5),
                rl.fade(rl.Color.white, 0.6),
            );
            rl.drawRectangleLines(
                10,
                10,
                @divTrunc(screen_width, 5),
                @divTrunc(screen_height, 5),
                rl.Color.black,
            );

            const debug_font_size = @divTrunc(screen_width, 80);

            const debug_info = std.fmt.allocPrintZ(a,
                \\FPS: {}
                \\Screen: {}x{}
                \\World: {}x{} ({})
                \\Bugs: {}
                \\Ram: {d}/{d}
                \\Transistors: {d}
            , .{
                rl.getFPS(),
                screen_width,
                screen_height,
                world_width,
                world_height,
                cell_size,
                self.wave.bugs.items.len,
                self.ram,
                max_ram,
                self.transistors,
            }) catch return;

            rl.drawText(debug_info, 20, 20, debug_font_size, .black);
        }
    }

    fn drawCell(self: *ScreenBattle, game: *Game, x: usize, y: usize) void {
        switch (self.wave.map[y][x]) {
            .none => return,
            .socket => {
                const texture = game.texture_map.get(.socket).?;
                rl.drawTexture(texture, @intCast(x * cell_size), @intCast(y * cell_size), rl.Color.white);
            },
            .lane => {
                const texture = game.texture_map.get(.lane).?;

                const lane_left = self.wave.get(x - 1, y).isLaneConnected();
                const lane_right = self.wave.get(x + 1, y).isLaneConnected();
                const lane_top = self.wave.get(x, y - 1).isLaneConnected();
                const lane_bottom = self.wave.get(x, y + 1).isLaneConnected();

                var offset: f32 = undefined;
                // Choose the correct sprite
                if (lane_top and lane_left) {
                    offset = 3;
                } else if (lane_top and lane_right) {
                    offset = 2;
                } else if (lane_bottom and lane_left) {
                    offset = 5;
                } else if (lane_bottom and lane_right) {
                    offset = 4;
                } else if (lane_left or lane_right) {
                    offset = 0;
                } else if (lane_top or lane_bottom) {
                    offset = 1;
                }

                rl.drawTextureRec(
                    texture,
                    .{ .x = offset * cell_size, .y = 0, .width = cell_size, .height = cell_size },
                    .{ .x = @floatFromInt(x * cell_size), .y = @floatFromInt(y * cell_size) },
                    rl.Color.white,
                );
            },
            .cpu => |cpu| {
                const texture = game.texture_map.get(.cpu).?;

                const msp = getMousePos(&self.camera);
                const mx: usize = @intFromFloat(msp.x / cell_size);
                const my: usize = @intFromFloat(msp.y / cell_size);
                const hovered = mx == x and my == y;
                if (hovered) {
                    rl.drawRectangle(
                        @intCast(x * cell_size),
                        @intCast(y * cell_size),
                        cell_size,
                        cell_size,
                        highlight_color,
                    );
                }

                const offset: f32 = @floatFromInt(cpu.level() - 1);
                rl.drawTextureRec(
                    texture,
                    .{ .x = offset * cell_size, .y = 0, .width = cell_size, .height = cell_size },
                    .{ .x = @floatFromInt(x * cell_size), .y = @floatFromInt(y * cell_size) },
                    rl.Color.white,
                );

                if (cpu.hasUpgrades() and hovered) {
                    self.popup = true;
                }
            },
            .ai => {
                const texture = game.texture_map.get(.ai).?;
                rl.drawTexture(texture, @intCast(x * cell_size), @intCast(y * cell_size), rl.Color.white);
            },
            .ram => {
                const texture = game.texture_map.get(.ram).?;

                const offset: f32 = if (self.ram == 0) 4 else if (self.ram / max_ram > 0.75) 0 else if (self.ram / max_ram > 0.5) 1 else if (self.ram / max_ram > 0.25) 2 else 3;

                rl.drawTextureRec(
                    texture,
                    .{ .x = offset * cell_size, .y = 0, .width = cell_size, .height = cell_size * 3 },
                    .{ .x = @floatFromInt(x * cell_size), .y = @floatFromInt(y * cell_size - cell_size) },
                    rl.Color.white,
                );
            },
        }
    }
};
