// BoardView (P4.3) — render the seeded match-3 board with real gem sprites.
//
// Modeled on game/chess/board_view.sx: a `View` that lays out an 8×8 grid and
// draws tiles/sprites through RenderContext.add_image / add_image_uv, sampling
// the gem sprite sheet by UV column. The background image fills the whole view;
// the grid is a centered square inside the safe-area inset.
#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 "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/font.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "swipe.sx";
#import "audio.sx";

// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
// inside its cell tile rather than touching the tile's edges.
GEM_FILL_FRAC :f32: 0.84;

// Content margin layered on top of the platform safe-area insets: frames the
// grid off the left/right screen bezel so the gems aren't flush to the edge.
// The grid is width-constrained on a portrait phone, so this is the inset that
// actually sizes it; vertical centering inside the safe area is unchanged.
BOARD_INSET_X :f32: 16.0;

// Selection overlay (P11.3): a soft candy "glow" halo, a warm wash over the cell,
// a bright rim topped by a glossy inner highlight, and a wet sheen on the chosen
// gem. `add_stroked_rect` paints the border band in its FILL colour (the shader
// ignores the separate stroke colour), so each ring colour is passed as the fill.
// The engine can't tint/fade a texture at draw time (issue 0002), so every layer
// here is a rect/overlay — never a gem-texture tint.
SELECT_GLOW_OUT :: Color.{ r = 255, g = 232, b = 140, a = 30 };  // wide faint outer bloom
SELECT_GLOW_IN  :: Color.{ r = 255, g = 238, b = 150, a = 70 };  // brighter near-edge halo
SELECT_FILL     :: Color.{ r = 255, g = 244, b = 150, a = 80 };  // warm wash over the gem
SELECT_RIM      :: Color.{ r = 255, g = 234, b = 92,  a = 255 }; // bright candy rim
SELECT_RIM_HI   :: Color.{ r = 255, g = 255, b = 232, a = 220 }; // glossy inner highlight ring
SELECT_GLOSS    :: Color.{ r = 255, g = 255, b = 255, a = 96 };  // wet sheen on the selected gem

// HUD (P12.2): a glossy candy card with the score and remaining moves, in the
// loaded Lato font. Placed in the empty band above the centered grid (inside the
// safe area). The fill is a bright grape candy, lifted by a translucent top sheen
// and a bright rounded rim; cream text rides a soft purple shadow for punch.
HUD_FONT       :f32: 34.0;
HUD_PAD        :f32: 14.0;
HUD_LINE_GAP   :f32: 6.0;
HUD_RADIUS     :f32: 20.0;
HUD_TEXT     :: Color.{ r = 255, g = 252, b = 245, a = 255 }; // warm cream text
HUD_TEXT_SH  :: Color.{ r = 56,  g = 18,  b = 80,  a = 150 }; // soft purple text shadow
HUD_PANEL    :: Color.{ r = 92,  g = 46,  b = 150, a = 224 }; // bright grape candy fill
HUD_PANEL_HI :: Color.{ r = 196, g = 138, b = 240, a = 92  }; // glossy top sheen
HUD_PANEL_RIM:: Color.{ r = 236, g = 204, b = 255, a = 150 }; // bright candy rim

// Win/lose banner (P12.2): a warm dim over the board, a glossy candy panel, the
// win/lose headline, and a playful restart button. Built from text + rects only —
// the engine's image path can't tint/fade at draw time (issue 0002), but rects and
// text DO honour colour + alpha, so the whole overlay is drawn with them. Each
// candy surface is a fill + a top sheen + a bright rounded rim; titles and the
// button label ride a tinted drop shadow so they pop off the panel.
BANNER_DIM        :: Color.{ r = 26,  g = 10,  b = 44,  a = 184 }; // warm purple dim
BANNER_PANEL      :: Color.{ r = 96,  g = 50,  b = 156, a = 244 }; // grape candy panel
BANNER_PANEL_HI   :: Color.{ r = 198, g = 140, b = 242, a = 110 }; // glossy panel sheen
BANNER_PANEL_RIM  :: Color.{ r = 240, g = 208, b = 255, a = 168 }; // bright panel rim
BANNER_WIN_TEXT   :: Color.{ r = 255, g = 220, b = 96,  a = 255 }; // celebratory candy gold
BANNER_WIN_SH     :: Color.{ r = 120, g = 56,  b = 8,   a = 220 }; // warm amber shadow
BANNER_LOSE_TEXT  :: Color.{ r = 255, g = 104, b = 104, a = 255 }; // punchy candy coral
BANNER_LOSE_SH    :: Color.{ r = 92,  g = 14,  b = 32,  a = 220 }; // deep berry shadow
BANNER_BTN        :: Color.{ r = 255, g = 120, b = 178, a = 255 }; // bubblegum candy CTA
BANNER_BTN_HI     :: Color.{ r = 255, g = 198, b = 222, a = 150 }; // glossy button sheen
BANNER_BTN_RIM    :: Color.{ r = 255, g = 226, b = 240, a = 184 }; // bright button rim
BANNER_BTN_SHADE  :: Color.{ r = 198, g = 52,  b = 120, a = 210 }; // darker bevel lip (3D)
BANNER_BTN_TEXT   :: Color.{ r = 255, g = 255, b = 255, a = 255 };
BANNER_BTN_TEXT_SH:: Color.{ r = 120, g = 20,  b = 64,  a = 200 }; // button label shadow
BANNER_PANEL_RADIUS :f32: 24.0;
BANNER_BTN_RADIUS   :f32: 16.0;
BANNER_TITLE_FONT :f32: 52.0;
BANNER_BTN_FONT   :f32: 30.0;

// UV sub-rect of one gem column, spanning the sheet's full height.
GemUV :: struct {
    uv_min: Point;
    uv_max: Point;
}

// Loads and holds the three board textures (background, cell tile, gem sheet)
// and maps a gem index to its column UV. Modeled on chess's ChessPieces.
BoardAssets :: struct {
    bg_tex: u32;
    cell_tex: u32;
    gems_tex: u32;
    cell_u: f32;
    loaded: bool;

    init :: (self: *BoardAssets) {
        self.bg_tex = 0;
        self.cell_tex = 0;
        self.gems_tex = 0;
        // gems.png is GEM_COUNT columns wide and one row tall, so a gem's UV
        // column IS its gem index (0=red … 5=purple); cell_u is one column wide.
        self.cell_u = 1.0 / cast(f32) GEM_COUNT;
        self.loaded = false;
    }

    load :: (self: *BoardAssets, gpu: ?GPU) {
        self.bg_tex = load_texture("assets/board/background.png", gpu);
        self.cell_tex = load_texture("assets/board/cell.png", gpu);
        self.gems_tex = load_texture("assets/gems/gems.png", gpu);
        self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
    }

    gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
        u0 : f32 = xx index * self.cell_u;
        GemUV.{
            uv_min = Point.{ x = u0, y = 0.0 },
            uv_max = Point.{ x = u0 + self.cell_u, y = 1.0 }
        }
    }
}

// Decode an RGBA image and upload it as a texture, returning the handle (0 on
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
    w : s32 = 0;
    h : s32 = 0;
    ch : s32 = 0;
    pixels := stbi_load(path, @w, @h, @ch, 4);
    if pixels == null {
        out("WARNING: could not load texture: ");
        out(path);
        out("\n");
        return 0;
    }

    tex : u32 = 0;
    if gpu != null {
        tex = gpu.create_texture(w, h, .rgba8, xx pixels);
    } else {
        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);
    }

    stbi_image_free(pixels);
    return tex;
}

// Which board cell the player has currently selected, if any. Lives behind a
// pointer (heap-allocated in main) because BoardView is a value rebuilt every
// frame from `build_ui`, so the view itself cannot carry state across frames.
// A tap toggles this highlight; a swipe commits a swap (see DragInput) and
// clears it.
BoardSelection :: struct {
    active: bool;
    cell: Cell;
    // Animation clock value when this selection last became active, so the
    // selection-pop reaction (gem_anim) can age from the moment of the tap.
    since: f32;

    init :: (self: *BoardSelection) {
        self.active = false;
        self.cell = Cell.{ col = 0, row = 0 };
        self.since = 0.0;
    }

    clear :: (self: *BoardSelection) {
        self.active = false;
    }

    // Tapping a cell selects it; tapping the cell already selected clears the
    // selection, so a tap toggles its own cell and moves it to any other.
    toggle :: (self: *BoardSelection, c: Cell) {
        if self.active and self.cell.col == c.col and self.cell.row == c.row {
            self.active = false;
        } else {
            self.active = true;
            self.cell = c;
        }
    }
}

// Tracks an in-progress touch drag between its press and release so a swipe can
// be resolved on lift: the press records the start point, and release maps
// start→end through `swipe_intent` to an adjacent-swap intent. Heap-allocated
// (like BoardSelection) so it survives BoardView's per-frame rebuild between the
// down (touchesBegan → mouse_down) and up (touchesEnded → mouse_up) events.
DragInput :: struct {
    active: bool;
    start: Point;

    init :: (self: *DragInput) {
        self.active = false;
        self.start = Point.{ x = 0.0, y = 0.0 };
    }

    begin :: (self: *DragInput, p: Point) {
        self.active = true;
        self.start = p;
    }

    clear :: (self: *DragInput) {
        self.active = false;
    }
}

BoardView :: struct {
    board: *Board;
    assets: *BoardAssets;
    sel: *BoardSelection;
    drag: *DragInput;
    anim: *BoardAnim;
    fx: *BoardFx;
    fxassets: *BoardFxAssets;
    motion: *GemMotion;
    safe: EdgeInsets;
    // Seed for `restart`: the same fixed seed main seeded the board with, so the
    // restart button reproduces the identical starting level.
    seed: s64;

    // Where the grid sits + the touch↔cell mapping. Recomputed each render /
    // event from the current frame so the hit-test matches what was drawn.
    layout: BoardLayout;

    compute_layout :: (self: *BoardView, frame: Frame) {
        self.layout.compute(frame, self.content_insets());
    }

    // Platform safe-area insets widened by the content margin, so the grid (and
    // the hit-test / banner geometry derived from it) is framed off the screen
    // bezel. The HUD keeps using the bare safe insets, so it still hugs the top
    // below the notch / Dynamic Island rather than shifting in with the board.
    content_insets :: (self: *BoardView) -> EdgeInsets {
        EdgeInsets.{
            top    = self.safe.top,
            left   = self.safe.left  + BOARD_INSET_X,
            bottom = self.safe.bottom,
            right  = self.safe.right + BOARD_INSET_X,
        }
    }

    // Draw gem `gem_index`'s sprite-sheet column into `gf`.
    draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) {
        uv := self.assets.gem_uv(gem_index);
        ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
    }

    // Frame for a gem at a (possibly fractional) board position, inset inside its
    // cell. Fractional col/row is how the swap-slide and fall animations place a
    // gem partway between cells.
    gem_frame :: (self: *BoardView, fcol: f32, frow: f32, inset: f32, dim: f32) -> Frame {
        Frame.make(
            self.layout.origin.x + fcol * self.layout.cell_size + inset,
            self.layout.origin.y + frow * self.layout.cell_size + inset,
            dim,
            dim
        )
    }

    // Frame for a gem shrunk by `scale` about its cell centre — the clear
    // scale-out. At scale 0 the gem is a zero-size frame (gone).
    gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame {
        cs := self.layout.cell_size;
        cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5;
        cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5;
        d := dim * scale;
        Frame.make(cx - d * 0.5, cy - d * 0.5, d, d)
    }

    // Frame for a gem at cell (col,row) drawn with a per-gem animation pose: the
    // sprite is scaled about its cell centre and nudged by the pose offset (both
    // in cell units). A resting pose reproduces gem_frame exactly, so the t==0
    // idle pose draws identically to the static sprite.
    gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
        cs := self.layout.cell_size;
        cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
        cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
        w := dim * pose.scale_x;
        h := dim * pose.scale_y;
        Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
    }

    // The per-gem animation pose for a settled cell: the always-on idle breath,
    // plus a squash-bounce if the cell landed recently, plus a pop if it is the
    // selected cell. Purely visual — composed from gem_anim's pure functions.
    gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
        pose := idle_pose(self.motion.clock, col, row);

        sq := land_squash(self.motion.land_local(Board.idx(col, row)));
        pose.scale_x += sq;
        pose.scale_y -= sq;

        if self.sel != null and self.sel.active
        and self.sel.cell.col == col and self.sel.cell.row == row {
            ts := if self.motion.pinned then self.motion.clock else self.motion.clock - self.sel.since;
            sp := select_pop_scale(ts);
            pose.scale_x *= sp;
            pose.scale_y *= sp;
        }
        pose
    }

    // Settled-board gems: one sprite per non-empty cell, drawn with its live
    // per-gem animation pose. Used whenever no move is animating.
    render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
        for 0..BOARD_ROWS: (row) {
            for 0..BOARD_COLS: (col) {
                g := self.board.at(col, row);
                if g != .empty {
                    pose := self.gem_pose_at(col, row);
                    gf := self.gem_pose_frame(col, row, dim, pose);
                    self.draw_gem(ctx, gf, cast(s64) g);
                }
            }
        }
    }

    // Selection emphasis (P11.3): a glossier candy highlight on the chosen cell.
    // Two concentric stroked rings fake a soft outward glow (the renderer has no
    // blur), a warm wash tints the cell, a bright rim is doubled by a thin inner
    // highlight for a glassy edge, and a wet sheen rides the selected gem's live
    // pose. All rect/overlay layers (issue 0002 forbids a draw-time gem tint); the
    // selection-pop motion still comes from gem_anim, so the t==0 idle pose is
    // untouched.
    render_selection :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
        cs := self.layout.cell_size;
        cf := self.layout.cell_frame(self.sel.cell.col, self.sel.cell.row);

        // Glow halo: rings just outside the cell edge, brighter nearer the rim, so
        // the falloff reads as a soft bloom without tinting the gem interior.
        ctx.add_stroked_rect(cf.expand(cs * 0.16), SELECT_GLOW_OUT, SELECT_GLOW_OUT, cs * 0.16, cs * 0.30);
        ctx.add_stroked_rect(cf.expand(cs * 0.07), SELECT_GLOW_IN,  SELECT_GLOW_IN,  cs * 0.08, cs * 0.21);

        // Warm wash + bright rim + a thin glossy highlight ring just inside the rim.
        ctx.add_rounded_rect(cf, SELECT_FILL, cs * 0.14);
        rim_w := max(2.0, cs * 0.06);
        ctx.add_stroked_rect(cf, SELECT_RIM, SELECT_RIM, rim_w, cs * 0.14);
        hi_w := max(1.0, cs * 0.022);
        ctx.add_stroked_rect(cf.expand(0.0 - rim_w), SELECT_RIM_HI, SELECT_RIM_HI, hi_w, cs * 0.11);

        // Wet sheen on the selected gem: a bright pill in its upper third, sized to
        // the gem's live pose so it tracks the selection pop.
        pose := self.gem_pose_at(self.sel.cell.col, self.sel.cell.row);
        gf := self.gem_pose_frame(self.sel.cell.col, self.sel.cell.row, dim, pose);
        gw := gf.size.width;
        gh := gf.size.height;
        gloss := Frame.make(gf.origin.x + gw * 0.22, gf.origin.y + gh * 0.13, gw * 0.40, gh * 0.22);
        ctx.add_rounded_rect(gloss, SELECT_GLOSS, gh * 0.12);
    }

    // Play the active slice of the move timeline. Gem motion is clipped to the
    // grid so refilled gems slide in from behind the top edge rather than
    // overlapping the HUD band above the board.
    render_anim :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
        ph := self.anim.phase();
        cs := self.layout.cell_size;
        grid := Frame.make(
            self.layout.origin.x, self.layout.origin.y,
            cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
        );
        ctx.push_clip(grid);

        mv := @self.anim.move;
        if ph.kind == .swap {
            self.render_swap(ctx, mv, inset, dim, ph.t);
        } else if ph.kind == .clear {
            rd := @mv.rounds.items[ph.round];
            self.render_clear(ctx, rd, inset, dim, ph.t);
        } else if ph.kind == .fall {
            rd := @mv.rounds.items[ph.round];
            self.render_fall(ctx, rd, inset, dim, ph.t);
        } else {
            // Settled tail of the timeline — draw the final (model) board. tick()
            // normally clears `active` before this is reached, so it is the seam
            // safety net rather than a frame the player typically sees.
            for 0..BOARD_CELLS: (i) {
                g := mv.final[i];
                if g != .empty {
                    gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
                    self.draw_gem(ctx, gf, cast(s64) g);
                }
            }
        }

        ctx.pop_clip();
    }

    // Swap segment: the board sits still (pre-swap) except the two swapped gems,
    // which slide between their cells. A legal swap slides fully (a→b, b→a); an
    // illegal one pings out to the neighbour and back, ending where it started.
    render_swap :: (self: *BoardView, ctx: *RenderContext, mv: *AnimMove, inset: f32, dim: f32, t: f32) {
        ai := Board.idx(mv.a.col, mv.a.row);
        bi := Board.idx(mv.b.col, mv.b.row);

        for 0..BOARD_CELLS: (i) {
            if i == ai or i == bi { continue; }
            g := mv.pre[i];
            if g != .empty {
                gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
                self.draw_gem(ctx, gf, cast(s64) g);
            }
        }

        p : f32 = ---;
        if mv.legal {
            p = ease_out_cubic(t);
        } else if t < 0.5 {
            p = ease_out_cubic(t * 2.0);
        } else {
            p = ease_out_cubic((1.0 - t) * 2.0);
        }

        afc := cast(f32) mv.a.col; afr := cast(f32) mv.a.row;
        bfc := cast(f32) mv.b.col; bfr := cast(f32) mv.b.row;

        ga := mv.pre[ai];
        if ga != .empty {
            gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim);
            self.draw_gem(ctx, gf, cast(s64) ga);
        }
        gb := mv.pre[bi];
        if gb != .empty {
            gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim);
            self.draw_gem(ctx, gf, cast(s64) gb);
        }
    }

    // Clear segment: matched gems pop outward then collapse to nothing (a
    // satisfying pop, composing with the particle burst); the rest hold position.
    render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
        pop := clear_pop_scale(t);
        for 0..BOARD_CELLS: (i) {
            g := rd.before[i];
            if g == .empty { continue; }
            col := i % BOARD_COLS;
            row := i / BOARD_COLS;
            if rd.matched.cells[i] {
                gf := self.gem_frame_scaled(col, row, dim, pop);
                self.draw_gem(ctx, gf, cast(s64) g);
            } else {
                gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
                self.draw_gem(ctx, gf, cast(s64) g);
            }
        }
    }

    // Transient match FX (P6.2): coloured glow bursts at the cleared cells,
    // clipped to the grid so a burst's glow never bleeds over the HUD. Each
    // burst grows then shrinks to nothing; the soft texture carries the fade.
    render_fx_particles :: (self: *BoardView, ctx: *RenderContext) {
        if self.fx == null or self.fxassets == null or !self.fxassets.loaded { return; }
        if self.fx.particles.len == 0 { return; }
        cs := self.layout.cell_size;
        grid := Frame.make(
            self.layout.origin.x, self.layout.origin.y,
            cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
        );
        ctx.push_clip(grid);
        for 0..self.fx.particles.len: (i) {
            p := self.fx.particles.items[i];
            lt := (p.age - p.delay) / p.life;
            env := fx_pop_env(lt);
            if env > 0.0 {
                size := env * p.peak * cs;
                cx := self.layout.origin.x + p.col * cs;
                cy := self.layout.origin.y + p.row * cs;
                gf := Frame.make(cx - size * 0.5, cy - size * 0.5, size, size);
                ctx.add_image(gf, self.fxassets.tex[p.tint]);
            }
        }
        ctx.pop_clip();
    }

    // Floating "+points" popups: rise and fade above the initial clear. Drawn
    // unclipped (over everything) so the number stays legible as it lifts off
    // the grid. The text path honours the colour's alpha, so these truly fade.
    // A combo (depth > 1) escalates with cascade depth: gold and larger, topped
    // by a `COMBO xN` label naming the depth — the same depth the cascade SFX
    // escalates on — so deeper cascades read as more exciting.
    render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
        if self.fx == null or self.fx.popups.len == 0 { return; }
        cs := self.layout.cell_size;
        for 0..self.fx.popups.len: (i) {
            q := self.fx.popups.items[i];
            lt := (q.age - q.delay) / q.life;
            if lt >= 0.0 {
                fade := fx_popup_fade(lt);
                font := fx_popup_font(q.depth);
                base := fx_popup_color(q.depth);
                col := Color.{ r = base.r, g = base.g, b = base.b, a = cast(u8) (fade * 255.0) };
                txt := format("+{}", q.points);
                sz := measure_text(txt, font);
                cx := self.layout.origin.x + q.col * cs;
                cy := self.layout.origin.y + (q.row - lt * FX_POPUP_RISE) * cs;
                ctx.add_text(
                    Frame.make(cx - sz.width * 0.5, cy - sz.height * 0.5, sz.width, sz.height),
                    txt, font, col
                );
                if q.depth > 1 {
                    lfont := font * FX_COMBO_LABEL_RATIO;
                    ltxt := format("COMBO x{}", q.depth);
                    lsz := measure_text(ltxt, lfont);
                    lcy := cy - sz.height * 0.5 - cs * FX_COMBO_LABEL_GAP - lsz.height * 0.5;
                    ctx.add_text(
                        Frame.make(cx - lsz.width * 0.5, lcy - lsz.height * 0.5, lsz.width, lsz.height),
                        ltxt, lfont, col
                    );
                }
            }
        }
    }

    // Fall segment: every gem of the round's settled board slides from its source
    // row (above the board for refills) down to its destination cell.
    render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
        te := ease_out_cubic(t);
        for 0..BOARD_CELLS: (i) {
            g := rd.after[i];
            if g == .empty { continue; }
            col := i % BOARD_COLS;
            drow := i / BOARD_COLS;
            src := rd.src[i];
            cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
            gf := self.gem_frame(cast(f32) col, cur_row, inset, dim);
            self.draw_gem(ctx, gf, cast(s64) g);
        }
    }

    // Whether the win/lose banner is up: the level is over AND any in-flight move
    // animation has settled, so a winning/losing cascade plays to completion
    // before the banner covers the board. Board input stays frozen the whole time
    // the level is terminal (see handle_event), independent of this.
    banner_up :: (self: *BoardView) -> bool {
        if level_status(self.board) == .in_progress { return false; }
        self.anim == null or !self.anim.active
    }

    // Win/lose overlay (P7.2): dim the board, draw the centered panel, the
    // win/lose headline, and the restart button — all text + rects so colour and
    // alpha are honoured. The button rect comes from the shared BannerLayout, so
    // it sits exactly where handle_event hit-tests the restart tap.
    render_banner :: (self: *BoardView, ctx: *RenderContext, status: Status) {
        ctx.add_rect(self.layout.grid_frame(), BANNER_DIM);

        bl := self.layout.banner();

        // Candy panel: grape fill under a glossy top sheen and a bright rounded rim.
        // Geometry is the shared bl.panel — only colour / rounding / gloss change.
        ctx.add_rounded_rect(bl.panel, BANNER_PANEL, BANNER_PANEL_RADIUS);
        ctx.add_rounded_rect(top_sheen(bl.panel, 0.42, BANNER_PANEL_RADIUS * 0.6), BANNER_PANEL_HI, BANNER_PANEL_RADIUS * 0.8);
        prim := max(2.0, BANNER_PANEL_RADIUS * 0.12);
        ctx.add_stroked_rect(bl.panel, BANNER_PANEL_RIM, BANNER_PANEL_RIM, prim, BANNER_PANEL_RADIUS);

        title := if status == .won then "YOU WIN!" else "OUT OF MOVES";
        tcol := if status == .won then BANNER_WIN_TEXT else BANNER_LOSE_TEXT;
        tsh  := if status == .won then BANNER_WIN_SH else BANNER_LOSE_SH;
        tfont := fit_font(title, BANNER_TITLE_FONT, bl.title.size.width);
        tsz := measure_text(title, tfont);
        tfr := Frame.make(bl.title.mid_x() - tsz.width * 0.5, bl.title.mid_y() - tsz.height * 0.5, tsz.width, tsz.height);
        ctx.add_text(Frame.make(tfr.origin.x + 2.0, tfr.origin.y + 3.0, tfr.size.width, tfr.size.height), title, tfont, tsh);
        ctx.add_text(tfr, title, tfont, tcol);

        // Candy button: a darker bevel lip peeks under the bubblegum fill for a 3D
        // candy edge, lifted by a glossy sheen and a bright rim. The fill / hit rect
        // is the shared bl.button, so the restart hit-test is byte-for-byte unchanged.
        ctx.add_rounded_rect(Frame.make(bl.button.origin.x, bl.button.origin.y + 3.0, bl.button.size.width, bl.button.size.height), BANNER_BTN_SHADE, BANNER_BTN_RADIUS);
        ctx.add_rounded_rect(bl.button, BANNER_BTN, BANNER_BTN_RADIUS);
        ctx.add_rounded_rect(top_sheen(bl.button, 0.46, BANNER_BTN_RADIUS * 0.5), BANNER_BTN_HI, BANNER_BTN_RADIUS * 0.8);
        brim := max(2.0, BANNER_BTN_RADIUS * 0.14);
        ctx.add_stroked_rect(bl.button, BANNER_BTN_RIM, BANNER_BTN_RIM, brim, BANNER_BTN_RADIUS);

        btxt := "PLAY AGAIN";
        bfont := fit_font(btxt, BANNER_BTN_FONT, bl.button.size.width * 0.86);
        bsz := measure_text(btxt, bfont);
        bfr := Frame.make(bl.button.mid_x() - bsz.width * 0.5, bl.button.mid_y() - bsz.height * 0.5, bsz.width, bsz.height);
        ctx.add_text(Frame.make(bfr.origin.x + 1.5, bfr.origin.y + 2.0, bfr.size.width, bfr.size.height), btxt, bfont, BANNER_BTN_TEXT_SH);
        ctx.add_text(bfr, btxt, bfont, BANNER_BTN_TEXT);
    }

    // Restart action behind the banner's button: reseed the SAME starting level
    // through the model (board.restart) and drop every transient view layer
    // (selection, in-flight drag, move animation, FX, and the per-gem landing
    // bounce) so the board returns to a clean, resting in_progress state. Without
    // the motion reset a restart fired right after a terminal cascade would carry
    // that move's landing squash onto the freshly seeded board.
    do_restart :: (self: *BoardView) {
        self.board.restart(self.seed);
        self.sel.clear();
        self.drag.clear();
        if self.anim != null { self.anim.init(); }
        if self.fx != null { self.fx.clear(); }
        self.motion.reset_landings();
    }
}

// Scale `base` font size down so `text` fits within `max_w` (measure_text scales
// linearly with font size, so one division lands it). Never scales up — a short
// headline keeps its size; only an over-wide one shrinks to fit the panel.
fit_font :: (text: string, base: f32, max_w: f32) -> f32 {
    sz := measure_text(text, base);
    if sz.width <= max_w or sz.width <= 0.0 { return base; }
    base * max_w / sz.width
}

// A rounded rect covering the top `frac` of `f`, inset by `pad` on the sides and
// top — the glossy candy sheen sat over a panel/button fill. The renderer has no
// gradient, so a single brighter translucent cap fakes the gloss.
top_sheen :: (f: Frame, frac: f32, pad: f32) -> Frame {
    Frame.make(f.origin.x + pad, f.origin.y + pad, f.size.width - pad * 2.0, f.size.height * frac)
}

impl View for BoardView {
    size_that_fits :: (self: *BoardView, proposal: ProposedSize) -> Size {
        Size.{ width = proposal.width ?? 0.0, height = proposal.height ?? 0.0 }
    }

    layout :: (self: *BoardView, bounds: Frame) {
        self.compute_layout(bounds);
    }

    render :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
        self.compute_layout(frame);

        // 1. Background image fills the whole view, behind the grid.
        if self.assets.bg_tex != 0 {
            ctx.add_image(frame, self.assets.bg_tex);
        }

        // 2. One cell tile per board cell — the static grid, never animated.
        gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
        gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
        if self.assets.cell_tex != 0 {
            for 0..BOARD_ROWS: (row) {
                for 0..BOARD_COLS: (col) {
                    ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
                }
            }
        }

        // 2b. Gems: while a move is animating, play its swap/clear/fall timeline;
        //     otherwise draw the settled model board. The timeline ends exactly on
        //     the model state, so the seam back to the static path is invisible.
        if self.assets.gems_tex != 0 {
            if self.anim != null and self.anim.active {
                self.render_anim(ctx, gem_inset, gem_dim);
            } else {
                self.render_gems(ctx, gem_dim);
            }
        }

        // 3. Selection emphasis on the chosen cell: a soft candy glow halo under a
        //    warm wash, a bright glossy rim, and a wet sheen on the popped gem.
        if self.sel != null and self.sel.active {
            self.render_selection(ctx, gem_dim);
        }

        // 4. HUD card with score + remaining moves, in the band above the grid.
        avail := frame.inset(self.safe);
        render_hud(ctx, self.board, avail);

        // 5. Transient match FX over the board: coloured bursts at the cleared
        //    cells, then the floating "+points" popup on top. Purely visual and
        //    self-pruning, so they vanish once the move settles.
        self.render_fx_particles(ctx);
        self.render_fx_popups(ctx);

        // 6. Win/lose banner over everything, once the level is over and the
        //    final cascade has settled. Status comes from the model (P7.1); the
        //    view never recomputes win/lose.
        if self.banner_up() {
            self.render_banner(ctx, level_status(self.board));
        }
    }

    // Touch input. A press records the drag start; the release resolves the
    // gesture against the SAME layout it was drawn with. A swipe (start→end maps
    // to an adjacent-swap intent) is fed straight into `commit_swap`: a legal
    // swap applies, cascades, scores and spends a move, an illegal one reverts —
    // either way the next frame re-renders the board + HUD from the model. A
    // sub-threshold / off-board drag carries no intent and falls back to the tap
    // behaviour: toggle the selection on the pressed cell, or clear it off-board.
    handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool {
        self.compute_layout(frame);

        // A finished level (won/lost) freezes board input: swipes/taps on cells
        // are ignored. Status comes from the model (P7.1) — never recomputed
        // here. Once the banner is up its restart button is the only live target;
        // a tap inside it reseeds a fresh level through board.restart.
        if level_status(self.board) != .in_progress {
            if event.* == {
                case .mouse_down: (d) { return true; }
                case .mouse_up: (d) {
                    if self.banner_up() and self.layout.banner().button.contains(d.position) {
                        self.do_restart();
                    }
                    return true;
                }
            }
            return false;
        }

        if event.* == {
            case .mouse_down: (d) {
                // Gate input at gesture START: while a move animation is in
                // flight the board ignores new gestures for the WHOLE in-flight
                // window, so a press begun mid-animation never latches a drag and
                // so can't commit when the animation later ends. The press is
                // still consumed; input resumes once the timeline settles.
                if !accepts_input(self.anim) { return true; }
                self.drag.begin(d.position);
                return true;
            }
            case .mouse_up: (d) {
                if !self.drag.active { return false; }
                start := self.drag.start;
                self.drag.clear();
                if intent := swipe_intent(@self.layout, start, d.position) {
                    mv := plan_and_commit(self.board, intent.a, intent.b);
                    if self.anim != null { self.anim.begin(mv); }
                    if self.fx != null { self.fx.begin(@mv); }
                    // SFX (P10.3): additive cues for the committed gesture —
                    // never reads or writes board/score/move state. The swap
                    // slide cue plays for any committed gesture (legal or the
                    // reverted ping-back); a legal move adds the match pop on its
                    // first clearing round; a multi-round chain adds the escalating
                    // cascade cue keyed to recorded depth (mv.rounds.len), distinct
                    // from the match pop so a single clear is never doubled.
                    sfx_swap();
                    if mv.legal {
                        sfx_match();
                        if mv.rounds.len >= 2 { sfx_cascade(mv.rounds.len); }
                    }
                    self.sel.clear();
                } else {
                    if hit := self.layout.point_to_cell(start) {
                        self.sel.toggle(hit);
                        // Re-arm the selection-pop reaction from this tap's moment.
                        if self.sel.active { self.sel.since = self.motion.clock; }
                    } else {
                        self.sel.clear();
                    }
                }
                return true;
            }
        }
        false
    }
}

// Draw the HUD card — current score against the per-level goal and the remaining
// moves (out of the move limit) — centered horizontally in the top of `avail`,
// the safe-area-inset region the grid is centered in. Reads live model state
// (score, target_score, moves), so it tracks the goal progress as the game runs.
// A translucent panel sits behind the text for legibility over the board art.
render_hud :: (ctx: *RenderContext, board: *Board, avail: Frame) {
    score_str := format("SCORE  {} / {}", board.score, board.target_score);
    moves_str := format("MOVES  {}/{}", board.moves_remaining(), board.move_limit);

    score_sz := measure_text(score_str, HUD_FONT);
    moves_sz := measure_text(moves_str, HUD_FONT);
    text_w := max(score_sz.width, moves_sz.width);

    panel_w := text_w + HUD_PAD * 2.0;
    panel_h := score_sz.height + HUD_LINE_GAP + moves_sz.height + HUD_PAD * 2.0;
    panel_x := avail.origin.x + (avail.size.width - panel_w) * 0.5;
    panel_y := avail.origin.y + HUD_PAD;
    panel := Frame.make(panel_x, panel_y, panel_w, panel_h);

    // Candy card: grape fill, a glossy top sheen, then a bright rounded rim.
    ctx.add_rounded_rect(panel, HUD_PANEL, HUD_RADIUS);
    ctx.add_rounded_rect(top_sheen(panel, 0.46, HUD_RADIUS * 0.5), HUD_PANEL_HI, HUD_RADIUS * 0.8);
    rim := max(2.0, HUD_RADIUS * 0.12);
    ctx.add_stroked_rect(panel, HUD_PANEL_RIM, HUD_PANEL_RIM, rim, HUD_RADIUS);

    tx := panel_x + HUD_PAD;
    ty := panel_y + HUD_PAD;
    hud_line(ctx, Frame.make(tx, ty, score_sz.width, score_sz.height), score_str);
    ty += score_sz.height + HUD_LINE_GAP;
    hud_line(ctx, Frame.make(tx, ty, moves_sz.width, moves_sz.height), moves_str);
}

// One HUD text row: a soft purple shadow under the warm cream text, so the line
// stays legible over the grape card. Geometry is the caller's row frame.
hud_line :: (ctx: *RenderContext, f: Frame, text: string) {
    ctx.add_text(Frame.make(f.origin.x + 1.5, f.origin.y + 2.0, f.size.width, f.size.height), text, HUD_FONT, HUD_TEXT_SH);
    ctx.add_text(f, text, HUD_FONT, HUD_TEXT);
}
