#import "modules/std.sx";
#import "build.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/sdl3.sx";
#import "modules/math";
#import "modules/stb.sx";
#import "modules/stb_truetype.sx";
#import "modules/gpu/api.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/metal.sx";
#import "modules/ui";
#import "modules/platform/api.sx";
#import "modules/platform/sdl3.sx";
#import "modules/platform/uikit.sx";
#import "board.sx";
#import "board_view.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "audio.sx";

#run configure_build();

// libc is the implicit foreign-library handle the std allocators bind against;
// reused here to read the deterministic-capture environment variables at startup.
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
strlen :: (s: *u8) -> usize #foreign libc "strlen";

// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
BOARD_SEED :: 1337;

// Cleared-surface tone for both GPU paths (Metal on iOS, GL on desktop). Tuned to
// the candy background's mid-gradient lavender so the clear never reads as a dark
// seam past the background art; one source keeps the two paths from diverging.
CLEAR_R :f32: 0.765;
CLEAR_G :f32: 0.733;
CLEAR_B :f32: 0.933;

g_plat : Platform = ---;
g_pipeline : *UIPipeline = ---;
g_delta_time : f32 = 0.016;
g_viewport_w : f32 = 800.0;
g_viewport_h : f32 = 600.0;
g_safe_insets : EdgeInsets = .{};

// iOS-only concrete handles kept alongside the boxed `g_plat` so the frame loop
// can reach the CAMetalLayer pointer / pixel dims without going through the
// protocol box.
g_uikit_plat : *UIKitPlatform = null;
g_metal_gpu  : *MetalGPU      = null;

// The pure-sx model (board.sx) and its sprites, seeded once in main() and
// rendered every frame. Heap-allocated so the view holds stable pointers to
// the mutable state across frames.
g_board  : *Board       = null;
g_assets : *BoardAssets = null;

// Current cell selection (P4.4). Heap-allocated so it survives BoardView's
// per-frame rebuild; a tap hit-tests a cell and toggles this.
g_sel : *BoardSelection = null;

// In-progress touch drag (P5.2). Heap-allocated for the same reason: the press
// and release that bracket a swipe land on different per-frame BoardView values,
// so the drag start must persist between them.
g_drag : *DragInput = null;

// In-flight move animation (P6.1). Heap-allocated for the same reason: a swipe
// begins the swap/clear/fall timeline, which then plays out over many subsequent
// frames, so the timeline state must persist across BoardView's per-frame rebuild.
g_anim : *BoardAnim = null;

// Transient match FX + score popups (P6.2). Heap-allocated like the animation:
// a committed move spawns short-lived bursts/popups that play out (and prune
// themselves) over many later frames. `g_fxassets` holds the per-colour tinted
// particle textures, loaded once. Purely visual; neither gates input.
g_fx : *BoardFx = null;
g_fxassets : *BoardFxAssets = null;

// Per-gem idle/select/land animation state (P6.3). Heap-allocated like the rest:
// `clock` advances by delta_time each frame (or is pinned by capture mode) and
// drives every per-gem pose. Purely visual; does not gate input.
g_motion : *GemMotion = null;

// Tracks whether the move timeline was active last frame, so the frame loop can
// fire the landing squash-bounce on the exact frame a move settles.
g_anim_prev_active : bool = false;

// Tracks whether the win/lose banner was up last frame, so the frame loop fires
// the win/lose stinger (P10.3) EXACTLY ONCE — on the frame the level settles
// terminal and any final cascade has played out — instead of replaying it every
// frame the banner is up. Re-armed when a restart reopens the level.
g_banner_prev_up : bool = false;

// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View {
    BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED }
}

// Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot
// would be time-dependent; these env hooks pin the visual state so goldens are
// reproducible. M3TE_ANIM_TIME=<seconds> freezes the animation clock at a chosen
// phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
// be captured without injecting a tap. Absent → normal live behaviour.
read_env :: (name: [:0]u8) -> ?string {
    p := getenv(name);
    addr : s64 = xx p;
    if addr == 0 { return null; }
    n := cast(s64) strlen(p);
    if n == 0 { return ""; }
    buf := cstring(n);
    memcpy(buf.ptr, xx p, n);
    buf
}

// Digit arithmetic runs entirely in s64; the result converts to f32 only once at
// the end. Doing the digit math in f32 would unify the ASCII literals (45/46/48/
// 57) to f32 across the comparisons, which mis-types the byte compares.
parse_f32 :: (s: string) -> f32 {
    i : s64 = 0;
    neg : bool = false;
    if s.len > 0 {
        c0 : s64 = xx s[0];
        if c0 == 45 { neg = true; i = 1; }     // '-'
    }
    intval : s64 = 0;
    while i < s.len {
        c : s64 = xx s[i];
        if c < 48 or c > 57 { break; }
        intval = intval * 10 + (c - 48);
        i += 1;
    }
    fracval : s64 = 0;
    fracdiv : s64 = 1;
    if i < s.len {
        d : s64 = xx s[i];
        if d == 46 {                           // '.'
            i += 1;
            while i < s.len {
                c : s64 = xx s[i];
                if c < 48 or c > 57 { break; }
                fracval = fracval * 10 + (c - 48);
                fracdiv = fracdiv * 10;
                i += 1;
            }
        }
    }
    v : f32 = cast(f32) intval + cast(f32) fracval / cast(f32) fracdiv;
    if neg { v = 0.0 - v; }
    v
}

parse_s64 :: (s: string) -> s64 {
    i : s64 = 0;
    v : s64 = 0;
    while i < s.len {
        c : s64 = xx s[i];
        if c < 48 or c > 57 { break; }
        v = v * 10 + (c - 48);
        i += 1;
    }
    v
}

frame :: () {
    fc := g_plat.begin_frame();
    g_delta_time = fc.delta_time;
    g_viewport_w = fc.viewport_w;
    g_viewport_h = fc.viewport_h;
    g_safe_insets = g_plat.safe_insets();

    if fc.viewport_w != g_pipeline.screen_width or fc.viewport_h != g_pipeline.screen_height {
        g_pipeline.resize(fc.viewport_w, fc.viewport_h);
    }

    for g_plat.poll_events(): (*ev) {
        inline if OS != .ios {
            if ev == {
                case .key_up: (e) {
                    if e.key == .escape { g_plat.stop(); }
                }
            }
        }
        g_pipeline.dispatch_event(ev);
    }

    // Advance the in-flight move animation + its match FX by this frame's delta
    // before rendering, so the board view draws the timeline slice for the current
    // wall-clock time. Capture mode pins the animation clock (M3TE_ANIM_TIME);
    // while pinned the move/FX timelines stay frozen at the phase the startup
    // hooks set, so the FX-match scene (M3TE_FX) screenshots identically each run.
    if g_motion == null or !g_motion.pinned {
        if g_anim != null { g_anim.tick(g_delta_time); }
        if g_fx != null { g_fx.tick(g_delta_time); }
    }

    // Per-round cascade SFX (P10.10): as each cascade round's clear begins on the
    // move timeline, play the NEXT ascending combo cue (round 1 → combo1, round 2
    // → combo2, … clamped at combo5). Edge-triggered off `cascade_fired` so each
    // round's cue fires exactly once; only a real multi-round chain (rounds >= 2)
    // gets the run, so a single match stays the lone match pop. Additive — reads
    // only the recorded timeline, never board/score/move state.
    if g_anim != null and g_anim.move.rounds.len >= 2 {
        started := cascade_rounds_started(g_anim.elapsed, g_anim.move.rounds.len);
        while g_anim.cascade_fired < started {
            g_anim.cascade_fired += 1;
            sfx_cascade(g_anim.cascade_fired);
        }
    }

    // Advance the always-on per-gem animation clock (idle/select/land). Capture
    // mode pins the clock, so it only moves when not pinned. On the exact frame a
    // move timeline settles, stamp the landing bounce on every cell the move
    // changed, so the gems that actually moved squash-bounce on settle.
    if g_motion != null {
        if !g_motion.pinned { g_motion.clock += g_delta_time; }
        if g_anim != null {
            if g_anim_prev_active and !g_anim.active {
                mv := @g_anim.move;
                for 0..BOARD_CELLS: (i) {
                    if mv.pre[i] != mv.final[i] { g_motion.stamp_land(i); }
                }
            }
            g_anim_prev_active = g_anim.active;
        }
    }

    // Win/lose stinger (P10.3): edge-trigger on the banner coming up — the level
    // has settled won/lost AND any in-flight cascade has finished animating — so
    // the stinger plays once as the banner appears, never every frame it is up.
    // Status is read-only from the model (mirrors BoardView.banner_up); a restart
    // reopens the level, dropping the edge so a fresh win/lose re-fires.
    banner_now := level_status(g_board) != .in_progress and (g_anim == null or !g_anim.active);
    if banner_now and !g_banner_prev_up {
        if level_status(g_board) == .won { sfx_win(); } else { sfx_lose(); }
    }
    g_banner_prev_up = banner_now;

    inline if OS == .ios {
        // Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
        // installed the SxMetalView and its bounds have been measured; both can
        // lag the first CADisplayLink tick, and a zero-sized drawable aborts
        // via XPC.
        if g_uikit_plat.gl_layer == null { return; }
        if g_uikit_plat.pixel_w <= 0 or g_uikit_plat.pixel_h <= 0 { return; }
        if g_metal_gpu.layer == null {
            g_metal_gpu.init(g_uikit_plat.gl_layer, g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
        } else if g_metal_gpu.pixel_w != g_uikit_plat.pixel_w or g_metal_gpu.pixel_h != g_uikit_plat.pixel_h {
            g_metal_gpu.resize(g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
        }
        clear : ClearColor = .{ r = CLEAR_R, g = CLEAR_G, b = CLEAR_B, a = 1.0 };
        if !g_metal_gpu.begin_frame(clear) { return; }
        g_pipeline.tick();
        g_metal_gpu.end_frame(fc.target_present_time);
    } else {
        glViewport(0, 0, fc.pixel_w, fc.pixel_h);
        glClearColor(CLEAR_R, CLEAR_G, CLEAR_B, 1.0);
        glClear(GL_COLOR_BUFFER_BIT);
        g_pipeline.tick();
    }
    g_plat.end_frame();
}

main :: () -> void {
    inline if OS == .ios {
        u : *UIKitPlatform = xx context.allocator.alloc(size_of(UIKitPlatform));
        u.gpu_mode = .metal;
        if !u.init("m3te", 800, 600) { return; }
        g_plat = xx u;
        g_uikit_plat = u;

        // The CAMetalLayer doesn't exist until didFinishLaunching: runs after we
        // return into UIApplicationMain, so attach lazily on the first frame.
        // init(null, 0, 0) only needs the MTLDevice, which is enough for the
        // texture uploads below.
        g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU));
        // alloc returns uninitialized memory; struct field defaults are NOT
        // applied, so List caps/lens would be garbage without this memset.
        memset(xx g_metal_gpu, 0, size_of(MetalGPU));
        if !g_metal_gpu.init(null, 0, 0) { return; }
    } else {
        s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform));
        if !s.init("m3te", 800, 600) { return; }
        g_plat = xx s;
    }

    fc := g_plat.begin_frame();
    g_viewport_w = fc.viewport_w;
    g_viewport_h = fc.viewport_h;
    g_safe_insets = g_plat.safe_insets();

    g_pipeline = xx context.allocator.alloc(size_of(UIPipeline));
    // Same alloc caveat as above: zero so the optional `gpu` reads as null on
    // the desktop path (where set_gpu is not called) and the Lists start empty.
    memset(xx g_pipeline, 0, size_of(UIPipeline));
    inline if OS == .ios {
        g_pipeline.set_gpu(xx g_metal_gpu);
    }
    g_pipeline.init(fc.viewport_w, fc.viewport_h);
    g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale);

    g_board = xx context.allocator.alloc(size_of(Board));
    g_board.init(BOARD_SEED);

    g_assets = xx context.allocator.alloc(size_of(BoardAssets));
    g_assets.init();
    g_assets.load(g_pipeline.gpu);

    g_sel = xx context.allocator.alloc(size_of(BoardSelection));
    g_sel.init();

    g_drag = xx context.allocator.alloc(size_of(DragInput));
    g_drag.init();

    g_anim = xx context.allocator.alloc(size_of(BoardAnim));
    g_anim.init();

    g_fx = xx context.allocator.alloc(size_of(BoardFx));
    g_fx.init();

    g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
    g_fxassets.init();
    g_fxassets.load(g_pipeline.gpu);

    g_motion = xx context.allocator.alloc(size_of(GemMotion));
    g_motion.init();

    // SFX (P10.2). Loads the System Sound Services cue bank once; board_view
    // plays a cue per event. Purely additive — never touches score/board/move
    // state. On iOS the platform has already chdir'd to the bundle, so each
    // cue's relative path resolves. No-op off iOS.
    g_audio = xx context.allocator.alloc(size_of(GameAudio));
    memset(xx g_audio, 0, size_of(GameAudio));
    g_audio.init();

    // Deterministic-capture hooks: pin the animation clock and/or preselect a
    // cell so the always-on idle (and the select reaction) screenshot the same
    // way every time. No env set → fully live.
    if t := read_env("M3TE_ANIM_TIME") {
        g_motion.pinned = true;
        g_motion.clock = parse_f32(t);
    }
    if sc := read_env("M3TE_SELECT") {
        idx := parse_s64(sc);
        if idx >= 0 and idx < BOARD_CELLS {
            g_sel.active = true;
            g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
            g_sel.since = g_motion.clock;
        }
    }

    // Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move,
    // which the sim can't script (no public touch injection), so M3TE_FX forces a
    // representative match at startup the same way a swipe would: it commits the
    // n-th currently-legal swap (1-based, clamped; =1 is the first) via the normal
    // plan_and_commit path, then begins the move timeline + its FX. M3TE_ANIM_TIME
    // pins the phase — advancing both to that time, after which the frozen frame
    // loop holds them there — so the burst + "+points" popup screenshot identically
    // every run. A larger M3TE_ANIM_TIME lands past the timeline, capturing the
    // settled board with the FX fully pruned. Startup-only and unset → fully live.
    if fx := read_env("M3TE_FX") {
        swaps := legal_swaps(g_board);
        if swaps.len > 0 {
            n := parse_s64(fx);
            if n < 1 { n = 1; }
            if n > swaps.len { n = swaps.len; }
            sw := swaps.items[n - 1];
            mv := plan_and_commit(g_board, sw.a, sw.b);
            g_anim.begin(mv);
            g_fx.begin(@mv);
            g_anim.tick(g_motion.clock);
            g_fx.tick(g_motion.clock);
        }
    }

    // Level-state capture hooks (P7.2): override the goal / move budget so a
    // terminal status can be screenshot without scripting a swipe. M3TE_TARGET=0
    // makes the fresh board read WON immediately (score 0 ≥ goal 0);
    // M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With
    // M3TE_RESTART set non-zero the board is then restart()-ed, capturing the
    // fresh in_progress board the restart button produces.
    if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); }
    if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); }
    if rs := read_env("M3TE_RESTART") {
        if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); }
    }

    g_pipeline.set_body(closure(build_ui));

    g_plat.run_frame_loop(closure(frame));
    g_plat.shutdown();
}
