// Match FX & score popups (P6.2) — a PURELY VISUAL, transient layer played over
// a committed move. It never touches the model: it reads the recorded AnimMove
// (per-round matched cells + the model's own awarded points) and spawns short-
// lived particle bursts at the cleared cells plus one floating "+points" popup,
// all driven by the frame loop's delta_time. Everything is gone shortly after
// the move settles, and none of it gates input (that stays on BoardAnim.active).
//
// The provided art (assets/fx/particle.png) is a WHITE soft-glow sparkle; the
// engine's image path can't tint or fade a texture at draw time (it samples
// texture*white), so the white sprite is tinted per gem/combo colour HERE at
// load time into one texture per colour, and a burst animates by SCALE (grow →
// shrink to nothing) rather than alpha — the soft texture edges carry the fade.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";

// Burst timing/size. A burst fires when its round's gems start clearing and
// lingers a touch into the fall, growing in two ways so deeper cascades read as
// more exciting: a per-move DEPTH boost lifts every burst of a deep cascade from
// its first round (`FX_BURST_DEPTH` per combo level, escalating in lockstep with
// the cascade SFX), and a per-round bump grows the later rounds within a move
// (`FX_BURST_COMBO`, capped). Sizes are in CELL units (1.0 == one grid cell).
FX_BURST_LIFE   :f32: 0.70;
FX_BURST_BASE   :f32: 2.50;
FX_BURST_COMBO  :f32: 0.72;   // extra peak size per round index within a move (capped)
FX_BURST_DEPTH  :f32: 0.45;   // extra peak size per cascade combo level (whole move)

// Popup timing/motion. Rises ~1.2 cells over its life and fades out. A combo
// (depth > 1) popup is gold and grows one step per combo level, topped by a
// `COMBO xN` label naming the cascade depth; both escalate in lockstep with the
// cascade SFX cue (see `fx_combo_level`).
FX_POPUP_LIFE   :f32: 1.40;
FX_POPUP_RISE   :f32: 1.2;
FX_POPUP_FONT   :f32: 34.0;
FX_POPUP_COMBO_FONT :f32: 48.0;
FX_POPUP_COMBO_STEP :f32: 8.0;   // extra popup font per combo level past the base
FX_COMBO_LABEL_RATIO :f32: 0.55; // `COMBO xN` label font as a fraction of the +points font
FX_COMBO_LABEL_GAP   :f32: 0.12; // gap (cell units) between the label and +points

// Vivid candy tints so a soft glow reads brightly over the dark board, in gem
// order (red, orange, yellow, green, blue, purple). Saturated a touch past the
// pastel — the low channel is trimmed while the dominant/mid channel is lifted —
// so every burst pops as a punchier colour without losing luminance.
fx_tint :: (i: s64) -> Color {
    if i == 0 { return Color.{ r = 255, g = 92,  b = 62,  a = 255 }; }
    if i == 1 { return Color.{ r = 255, g = 164, b = 44,  a = 255 }; }
    if i == 2 { return Color.{ r = 255, g = 240, b = 72,  a = 255 }; }
    if i == 3 { return Color.{ r = 112, g = 250, b = 112, a = 255 }; }
    if i == 4 { return Color.{ r = 96,  g = 192, b = 255, a = 255 }; }
    Color.{ r = 224, g = 124, b = 255, a = 255 }
}

FX_POPUP_COLOR       :: Color.{ r = 255, g = 255, b = 255, a = 255 };
FX_POPUP_COMBO_COLOR :: Color.{ r = 255, g = 222, b = 130, a = 255 };   // base gold (depth 2)
FX_POPUP_COMBO_HOT   :: Color.{ r = 255, g = 248, b = 214, a = 255 };   // hot near-white gold (deepest)

// Cascade depth (`mv.rounds.len`) -> combo emphasis level. Mirrors audio.sx's
// `cascade_cue_index` clamp EXACTLY (depth <= 1 -> 0, depth >= 5 -> the max), so
// the on-screen combo emphasis (popup size/colour + burst boost) steps up in
// lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the
// equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx).
FX_COMBO_MAX_LEVEL :: 4;   // == audio.sx COMBO_CLIPS - 1
fx_combo_level :: (depth: s64) -> s64 {
    if depth <= 1 { return 0; }
    if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; }
    depth - 1
}

// Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1)
// uses the plain size; a combo starts at the base combo size and grows one step
// per combo level past the first, clamped at the deepest level.
fx_popup_font :: (depth: s64) -> f32 {
    if depth <= 1 { return FX_POPUP_FONT; }
    FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1)
}

// Popup colour for a cascade `depth` rounds deep: white for a single clear, else
// the gold lerped toward a hot near-white as the cascade deepens.
fx_popup_color :: (depth: s64) -> Color {
    if depth <= 1 { return FX_POPUP_COLOR; }
    t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1);
    Color.{
        r = fx_lerp_u8(FX_POPUP_COMBO_COLOR.r, FX_POPUP_COMBO_HOT.r, t),
        g = fx_lerp_u8(FX_POPUP_COMBO_COLOR.g, FX_POPUP_COMBO_HOT.g, t),
        b = fx_lerp_u8(FX_POPUP_COMBO_COLOR.b, FX_POPUP_COMBO_HOT.b, t),
        a = 255,
    }
}

fx_lerp_u8 :: (lo: u8, hi: u8, t: f32) -> u8 {
    cast(u8) (cast(f32) lo + (cast(f32) hi - cast(f32) lo) * t)
}

// Upload an RGBA buffer as a texture, returning its handle. Mirrors
// board_view.load_texture's upload half but takes an in-memory buffer (the
// per-colour tinted particle) instead of a file path.
upload_rgba :: (pixels: [*]u8, w: s32, h: s32, gpu: ?GPU) -> u32 {
    if gpu != null {
        return xx gpu.create_texture(w, h, .rgba8, xx pixels);
    }
    tex : u32 = 0;
    glGenTextures(1, @tex);
    glBindTexture(GL_TEXTURE_2D, tex);
    glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
    tex
}

// Loads the white particle once and bakes one tinted copy per colour. The white
// source's RGB is uniform, so a tint is just (tint.rgb, source.alpha) per pixel.
BoardFxAssets :: struct {
    tex: [GEM_COUNT]u32;
    loaded: bool;

    init :: (self: *BoardFxAssets) {
        for 0..GEM_COUNT: (t) { self.tex[t] = 0; }
        self.loaded = false;
    }

    load :: (self: *BoardFxAssets, gpu: ?GPU) {
        w : s32 = 0;
        h : s32 = 0;
        ch : s32 = 0;
        src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
        if xx src == 0 {
            out("WARNING: could not load assets/fx/particle.png\n");
            self.loaded = false;
            return;
        }
        n := cast(s64) w * cast(s64) h;
        buf : [*]u8 = xx context.allocator.alloc(n * 4);
        // Loop locals are hoisted: a block-scoped local declared inside a body
        // that runs hundreds of thousands of times grows the stack per iteration
        // (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars.
        i : s64 = 0;
        o : s64 = 0;
        for 0..GEM_COUNT: (t) {
            col := fx_tint(t);
            i = 0;
            while i < n {
                o = i * 4;
                buf[o]   = col.r;
                buf[o+1] = col.g;
                buf[o+2] = col.b;
                buf[o+3] = src[o+3];
                i += 1;
            }
            self.tex[t] = upload_rgba(buf, w, h, gpu);
        }
        stbi_image_free(xx src);
        self.loaded = true;
    }
}

// A live burst: a soft glow centred on a board cell that grows then shrinks to
// nothing. `tint` indexes BoardFxAssets.tex; `delay` holds it invisible until
// its round's clear begins; `peak` is the peak size in cell units.
FxParticle :: struct {
    col: f32;
    row: f32;
    tint: s64;
    delay: f32;
    age: f32;
    life: f32;
    peak: f32;
}

// A floating "+points" popup anchored at the initial clear's centroid, rising
// and fading over its life. `depth` is the cascade depth (`mv.rounds.len`): it
// drives the combo styling at render time (gold size step + `COMBO xN` label for
// depth > 1). Stores the raw points (not a formatted string): the label is built
// at render time in the frame's arena, so nothing allocated here has to outlive
// the spawning event.
FxPopup :: struct {
    col: f32;
    row: f32;
    points: s64;
    depth: s64;
    delay: f32;
    age: f32;
    life: f32;
}

// Live FX state for the in-flight move. Heap-allocated (like BoardAnim) so it
// survives BoardView's per-frame rebuild; `tick` ages the FX and prunes the
// dead, and BoardView draws what is live.
BoardFx :: struct {
    particles: List(FxParticle);
    popups: List(FxPopup);

    init :: (self: *BoardFx) {
        self.particles = List(FxParticle).{};
        self.popups = List(FxPopup).{};
    }

    clear :: (self: *BoardFx) {
        self.particles.len = 0;
        self.popups.len = 0;
    }

    // Spawn the FX for a committed legal move: a coloured burst at every cleared
    // cell of every cascade round (timed to its clear), plus one popup showing
    // the model's awarded points at the first round's centroid. Illegal moves
    // (no clears, no award) spawn nothing.
    begin :: (self: *BoardFx, mv: *AnimMove) {
        self.clear();
        if !mv.legal or mv.rounds.len == 0 { return; }

        // Whole-move depth boost: a deeper cascade makes every burst bigger from
        // its first round, escalating in lockstep with the cascade SFX cue.
        depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len);
        for 0..mv.rounds.len: (k) {
            rd := @mv.rounds.items[k];
            t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
            extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2);
            for 0..BOARD_CELLS: (idx) {
                if rd.matched.cells[idx] {
                    g := rd.before[idx];
                    if g != .empty {
                        self.particles.append(FxParticle.{
                            col = cast(f32) (idx % BOARD_COLS) + 0.5,
                            row = cast(f32) (idx / BOARD_COLS) + 0.5,
                            tint = cast(s64) g,
                            delay = t0,
                            age = 0.0,
                            life = FX_BURST_LIFE,
                            peak = FX_BURST_BASE + extra,
                        });
                    }
                }
            }
        }

        // One popup for the whole move at the first clear's centroid.
        rd0 := @mv.rounds.items[0];
        sc : s64 = 0;
        sr : s64 = 0;
        cnt : s64 = 0;
        for 0..BOARD_CELLS: (idx) {
            if rd0.matched.cells[idx] {
                sc += idx % BOARD_COLS;
                sr += idx / BOARD_COLS;
                cnt += 1;
            }
        }
        if cnt == 0 { return; }
        self.popups.append(FxPopup.{
            col = cast(f32) sc / cast(f32) cnt + 0.5,
            row = cast(f32) sr / cast(f32) cnt + 0.5,
            points = mv.awarded,
            depth = mv.rounds.len,
            delay = SWAP_ANIM_DUR,
            age = 0.0,
            life = FX_POPUP_LIFE,
        });
    }

    // Advance every live FX by `dt` and drop those past their lifetime. Kept
    // simple: compact each list in place by overwriting dead entries.
    tick :: (self: *BoardFx, dt: f32) {
        w : s64 = 0;
        i : s64 = 0;
        while i < self.particles.len {
            p := self.particles.items[i];
            p.age += dt;
            if p.age < p.delay + p.life {
                self.particles.items[w] = p;
                w += 1;
            }
            i += 1;
        }
        self.particles.len = w;

        w = 0;
        i = 0;
        while i < self.popups.len {
            q := self.popups.items[i];
            q.age += dt;
            if q.age < q.delay + q.life {
                self.popups.items[w] = q;
                w += 1;
            }
            i += 1;
        }
        self.popups.len = w;
    }
}

// Burst size envelope over local progress 0..1: a fast rise to a peak then a
// fade back to zero, so a burst pops in and shrinks out (no alpha needed). 0
// outside [0,1].
fx_pop_env :: (t: f32) -> f32 {
    if t <= 0.0 or t >= 1.0 { return 0.0; }
    sin(PI * sqrt(t))
}

// Popup fade over local progress 0..1: full then ease-out to transparent.
fx_popup_fade :: (t: f32) -> f32 {
    if t <= 0.0 { return 1.0; }
    if t >= 1.0 { return 0.0; }
    u := 1.0 - t;
    u * u
}
