// Space Taxi (JoeyLib port) -- main loop and game state machine.
//
// Build: see make/{dos,amiga,atarist,iigs}.mk -- target is
//        `EXAMPLE=spacetaxi`.
//
// Runtime layout:
//   1. jlInit + jlStageGet
//   2. stRenderInit loads tile and sprite asset banks from disk
//   3. State machine: title -> level-intro -> playing -> done
//   4. Each frame:
//        - jlInputPoll  (read joystick + keyboard)
//        - stEngineTick (taxi physics)
//        - stPassengerTick (passenger AI)
//        - stRenderFrame (sprite save/restore + draw + HUD)
//        - jlAudioFrameTick
//        - jlWaitVBL

#include <stdio.h>
#include <string.h>

#include "spacetaxi.h"
#include "stDemoStreams.h"

// The C64 runs its physics + game logic once per GAME TICK, and a game
// tick is 2 video frames (the main loop waits on the raster twice per
// iteration, $5F94 + $5FA3) = ~30 Hz NTSC. The port renders at each
// platform's own frame rate (70 Hz DOS, 50 Hz ST/Amiga, ~15 fps IIgs),
// so gameplay is stepped on a fixed 33 ms accumulator rather than once
// per rendered frame. Without this the cab accelerates ~2.3x too fast
// on DOS and half-speed on the IIgs -- every per-tick constant (accel
// 14, gravity 1, the $7D8F templates, the demo RLE counts) is authored
// for this 30 Hz cadence, so only stepping at 30 Hz reproduces the
// original feel, identically on every port.
#define ST_GAME_TICK_MS      33u
// Cap catch-up ticks per rendered frame so a hitch (disk stall, cold
// cache) drops backlog instead of spiraling; ~165 ms simulated max.
#define ST_MAX_CATCHUP_TICKS 5u


static void  applyInput(StGameT *game);
static void  demoEnter(StGameT *game);
static void  demoExitToTitle(StGameT *game);
static void  demoTick(StGameT *game);
static void  demoTickInput(StGameT *game);
static bool  loadLevelByIndex(StGameT *game, uint8_t idx);
static void  playingTick(StGameT *game);


// All 24 canonical Space Taxi levels (A..X), extracted from the C64
// ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A",
// level24 = scene 23 = "X".
static const char *gLevelPaths[] = {
    "levels/level01.dat", "levels/level02.dat",
    "levels/level03.dat", "levels/level04.dat",
    "levels/level05.dat", "levels/level06.dat",
    "levels/level07.dat", "levels/level08.dat",
    "levels/level09.dat", "levels/level10.dat",
    "levels/level11.dat", "levels/level12.dat",
    "levels/level13.dat", "levels/level14.dat",
    "levels/level15.dat", "levels/level16.dat",
    "levels/level17.dat", "levels/level18.dat",
    "levels/level19.dat", "levels/level20.dat",
    "levels/level21.dat", "levels/level22.dat",
    "levels/level23.dat", "levels/level24.dat",
};

#define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0]))


// Attract-demo rotation cursor (mirrors $5143). Playback is stepped by
// the main loop's fixed game-tick accumulator, so no wall-clock here.
static uint8_t  gDemoRotation;


// Enter (or advance to the next screen of) the rolling attract demo:
// pick the rotation slot, load its level, seed the recorded stream
// ($6262 seeds mask/timer from the head), zero the demo score.
static void demoEnter(StGameT *game) {
    const StDemoEntryT *e = &kDemoRotation[gDemoRotation & 3u];

    gDemoRotation++;
    if (!loadLevelByIndex(game, e->levelIndex)) {
        demoExitToTitle(game);
        return;
    }
    stEngineReset(game);
    game->score      = 0u;
    game->demoStream = e->stream;
    game->demoLen    = e->length;
    game->demoMask   = e->stream[0];
    game->demoTimer  = e->stream[1];
    game->demoOff    = 2u;
    game->demoEnded  = false;
    game->state      = ST_STATE_DEMO;
}


static void demoExitToTitle(StGameT *game) {
    if (stLevelLoad(&game->level, "levels/title.dat")) {
        stRenderLevelChanged();
    }
    stRenderTitleReset();           // replay the intro -> attract cycle
    stAudioSfxThrust(false);
    game->state = ST_STATE_TITLE;
}


// Recorded-input playback, substituting for applyInput ($48F2): DEC
// the pair timer each game tick; at zero take the next [mask][count]
// pair. Mask bits 0-3 = UP/DOWN/LEFT/RIGHT (bit 7 is always set in
// the recordings, no fire bit -- the demo cab never lowers its gear,
// which is why every recorded ride ends in a crash).
static void demoTickInput(StGameT *game) {
    StTaxiT  *t = &game->taxi;
    uint8_t   mask;
    bool      up;
    bool      down;
    bool      left;
    bool      right;

    // Advance the RLE stream by exactly one game tick (the caller runs
    // this once per fixed 33 ms step, in lockstep with stEngineTick).
    if (game->demoTimer > 0u) {
        game->demoTimer--;
    }
    if (game->demoTimer == 0u) {
        if ((uint16_t)(game->demoOff + 1u) < game->demoLen) {
            game->demoMask  = game->demoStream[game->demoOff];
            game->demoTimer = game->demoStream[game->demoOff + 1u];
            game->demoOff   = (uint16_t)(game->demoOff + 2u);
        } else {
            // The C64 has no end-of-stream check (every recorded ride
            // crashes first); hold idle until then.
            game->demoMask  = 0x80u;
            game->demoTimer = 0xFFu;
        }
    }
    mask  = game->demoMask;
    up    = (mask & 0x01u) != 0u;
    down  = (mask & 0x02u) != 0u;
    left  = (mask & 0x04u) != 0u;
    right = (mask & 0x08u) != 0u;
    t->thrustDx  = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
    t->thrustDy  = (int8_t)((down  ? 1 : 0) - (up   ? 1 : 0));
    t->thrusting = (up || down || left || right);
    t->dirMask   = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
                             (left ? 4u : 0u) | (right ? 8u : 0u));
    if (left) {
        t->facing = ST_DIR_LEFT;
    } else if (right) {
        t->facing = ST_DIR_RIGHT;
    }
}


// One fixed game-tick of the attract demo: recorded input -> physics ->
// passenger. The main loop runs this in lockstep with the 30 Hz clock.
static void demoTick(StGameT *game) {
    demoTickInput(game);
    stEngineTick(game);
    stPassengerTick(game);
}


// One fixed game-tick of PLAYING: warp recede, or input -> physics ->
// passenger, with the land-SFX edge and the transporter-exit trigger.
// The thrust SFX is left to the caller (once per rendered frame).
static void playingTick(StGameT *game) {
    bool wasLanded;

    if (game->taxi.warpFrame > 0u) {
        if (stEngineWarpTick(game)) {
            stPassengerTransporterExit(game);
            game->state = ST_STATE_LEVEL_DONE;
        }
        return;
    }
    wasLanded = game->taxi.landed;
    applyInput(game);
    stEngineTick(game);
    stPassengerTick(game);
    if (!wasLanded && game->taxi.landed) {
        stAudioSfxLand();
    }
    // Flying up through the top-wall transporter exits the screen:
    // begin the shrink-warp (delivers a carried "UP PLEASE" fare and
    // advances the screen when it ends).
    if (game->taxi.crashTicks == 0u && stEngineInTransporter(game)) {
        stEngineStartWarp(game);
    }
}


static void applyInput(StGameT *game) {
    StTaxiT  *t = &game->taxi;
    int8_t    jx;
    int8_t    jy;
    bool      up;
    bool      down;
    bool      left;
    bool      right;

    // Joystick port 2 is the canonical Space Taxi control. Keyboard
    // is the fallback so the same binary is testable on hosts without
    // a stick. jlJoystickX/Y return -127..127; treat anything past
    // 1/4 deflection as "held in that direction".
    jx    = jlJoystickX(JOYSTICK_0);
    jy    = jlJoystickY(JOYSTICK_0);
    up    = jlKeyDown(KEY_UP)    || jy < -32;
    down  = jlKeyDown(KEY_DOWN)  || jy >  32;
    left  = jlKeyDown(KEY_LEFT)  || jx < -32;
    right = jlKeyDown(KEY_RIGHT) || jx >  32;

    // FIRE is the LANDING GEAR ($63DD): a press EDGE while airborne
    // toggles the gear with an SFX; pressing UP while parked on a pad
    // is the takeoff, which retracts the gear ($65AD AND #$FE). (The
    // old comment here claiming "fire is never tested in this path"
    // was wrong -- $63F1-$6401 tests it every tick.)
    {
        bool fire = jlKeyDown(KEY_SPACE) || jlJoyDown(JOYSTICK_0, JOY_BUTTON_0);
        if (fire && !t->fireHeld && !t->landed) {
            t->gearDown = !t->gearDown;
            stAudioSfxGear();
        }
        t->fireHeld = fire;
    }
    if (up && t->landed) {
        t->gearDown = false;            // takeoff retracts the gear
    }
    // Gear down strips lateral thrust BEFORE the direction mask is
    // built ($6190 AND #$13 runs upstream of the $60D6 mask store),
    // so the flame table never sees L/R with the gear down either.
    if (t->gearDown) {
        left  = false;
        right = false;
    }
    t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
    t->thrustDy = (int8_t)((down  ? 1 : 0) - (up   ? 1 : 0));
    t->thrusting = (up || down || left || right);
    // $716A-style mask for the flame cel table (1=UP 2=DOWN 4=LEFT
    // 8=RIGHT, same encoding kFlameCelByDirMask indexes).
    t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
                           (left ? 4u : 0u) | (right ? 8u : 0u));

    // Facing is purely cosmetic (sprite cel selection). Update when
    // horizontal thrust is commanded; keep last facing otherwise so
    // a stopped cab stays facing where it was.
    if (left) {
        t->facing = ST_DIR_LEFT;
    } else if (right) {
        t->facing = ST_DIR_RIGHT;
    }
}


static bool loadLevelByIndex(StGameT *game, uint8_t idx) {
    if (idx >= ST_LEVEL_COUNT) {
        return false;
    }
    if (!stLevelLoad(&game->level, gLevelPaths[idx])) {
        return false;
    }
    game->levelIndex = idx;
    stRenderLevelChanged();
    return true;
}


int main(void) {
    jlConfigT    config;
    jlSurfaceT  *stage;
    // game embeds a full StLevelT (tilemap + colormap = ~2 KB). As a
    // stack local it held ~2 KB across the whole program, overflowing the
    // IIgs (llvm-mos) soft stack once the deep asset-load call chain ran
    // (stRenderInit -> loadSpriteSheet -> jlSpriteBankLoad -> fopen) and
    // silently crashing back to GS/OS. It is a single program-lifetime
    // instance, so static (BSS) is both correct and stack-friendly.
    static StGameT game;

    // Sprite codegen arena: after Phase 11 (shared-walker rewrite of
    // the planar sprite path), sprites no longer consume arena bytes
    // -- the per-cel work happens inside halSpriteDrawPlanes /
    // halSpriteSavePlanes / halSpriteRestorePlanes as static lib code.
    // 32 KB is plenty for whatever other codegen needs are around.
    // Sprite codegen: the 26 drawn cels (taxi/warp/passenger/flame,
    // 24x24 = 3x3-tile) each compile to per-shift save/draw/restore asm.
    // Compiling them is what makes sprite blitting fast (the interpreter
    // costs ~200 ms/frame on the IIgs 65816). The arena is capped at one
    // 64 KB bank (codegenArena attrNoCross); 60 KB fits all 26 cels with
    // headroom. The old 32 KB was sized for procedural sprites only.
    config.codegenBytes = 60UL * 1024;
    config.audioBytes   = 32UL * 1024;     // music + SFX

    if (!jlInit(&config)) {
        fprintf(stderr, "jlInit: %s\n", jlLastError());
        return 1;
    }
    jlLogReset();
    jlLogF("spacetaxi: build=%s %s", __DATE__, __TIME__);

    stage = jlStageGet();
    if (stage == NULL) {
        jlLogF("spacetaxi: ! jlStageGet returned NULL");
        jlShutdown();
        return 1;
    }

    memset(&game, 0, sizeof(game));
    game.state      = ST_STATE_TITLE;
    game.lives      = 5;
    game.levelIndex = 0;
    game.fareTarget = 1;        // C64 default: 1 fare per game (see $48AF).

    jlLogF("spacetaxi: stRenderInit ...");
    stRenderInit(stage);
    jlLogF("spacetaxi: stAudioInit ...");
    stAudioInit();

    // Load the title-screen tilemap. raw.bin captured the C64 game
    // at the title screen, so its screen RAM at $0400 IS the title
    // (the big SPACE TAXI letters, JOHN F. BUTCHER credit, joystick
    // instructions, etc.). romToLevel.py emits title.dat from that
    // capture. Falls through to a black field if the asset is
    // missing.
    jlLogF("spacetaxi: stLevelLoad title.dat ...");
    if (stLevelLoad(&game.level, "levels/title.dat")) {
        jlLogF("spacetaxi:   title loaded, tilebank=%u",
               (unsigned)game.level.tileBankId);
        stRenderLevelChanged();
    } else {
        jlLogF("spacetaxi:   title load FAILED");
    }
    jlLogFlush();

    // Fixed-timestep gameplay clock. gTickAccumMs banks real elapsed
    // time; each frame consumes it in whole 30 Hz game ticks so physics
    // advance at the C64's rate regardless of the render frame rate.
    uint32_t gPrevTickMs  = jlMillisElapsed();
    uint32_t gTickAccumMs = 0u;

    for (;;) {
        uint8_t  pendingTicks;
        uint8_t  tick;

        jlInputPoll();
        if (jlKeyPressed(KEY_ESCAPE)) {
            break;
        }

        {
            uint32_t nowMs = jlMillisElapsed();
            uint32_t want;
            gTickAccumMs += (uint32_t)(nowMs - gPrevTickMs);
            gPrevTickMs   = nowMs;
            want          = gTickAccumMs / ST_GAME_TICK_MS;   // wide: no cast wrap
            if (want > ST_MAX_CATCHUP_TICKS) {
                // A long stall (level load, disk seek): resync to now
                // and skip the gap instead of spiraling. Zero the whole
                // accumulator so no backlog survives the truncated cast.
                want         = ST_MAX_CATCHUP_TICKS;
                gTickAccumMs = 0u;
            } else {
                gTickAccumMs -= want * ST_GAME_TICK_MS;       // keep sub-tick remainder
            }
            pendingTicks = (uint8_t)want;                     // guaranteed <= 5
        }

        switch (game.state) {
            case ST_STATE_TITLE:
            {
                // C64 title listens for UP (high scores), DOWN
                // (instructions), and FIRE. FIRE on the title does NOT
                // start gameplay -- it advances to the "GAME VARIATIONS"
                // options menu ($5295, reached via $4750 -> JSR $5295),
                // where the player selects the cabbie count before the
                // game begins. (UP/DOWN high-scores/instructions screens
                // are still TODO.)
                {
                    int8_t jy = jlJoystickY(JOYSTICK_0);
                    if (jlKeyDown(KEY_UP) || jy < -32) {
                        game.state = ST_STATE_HIGHSCORES;
                        break;
                    }
                    if (jlKeyDown(KEY_DOWN) || jy > 32) {
                        game.state = ST_STATE_INSTRUCTIONS;
                        break;
                    }
                }
                if (jlKeyPressed(KEY_SPACE) ||
                    jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
                    game.fareTarget = 1u;   // C64 default ($48AF sets 1)
                    game.state      = ST_STATE_OPTIONS;
                    break;
                }
                // Advance the sparkle -> walk -> takeoff intro (and the
                // logo flip) on the fixed 30 Hz clock, so pre-game
                // timing is identical on every port.
                for (tick = 0u;
                     tick < pendingTicks && game.state == ST_STATE_TITLE;
                     tick++) {
                    stRenderTitleAdvance();
                }
                // Intro finished (cab took off): roll the attract
                // demo, exactly like the C64's post-takeoff flow.
                if (stRenderTitleIntroDone()) {
                    demoEnter(&game);
                }
                break;
            }
            case ST_STATE_DEMO:
            {
                // Any real input exits the attract loop ($5006 tests
                // $DC00 != $7F).
                int8_t jx = jlJoystickX(JOYSTICK_0);
                int8_t jy = jlJoystickY(JOYSTICK_0);
                if (jlKeyPressed(KEY_SPACE) ||
                    jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) ||
                    jlKeyDown(KEY_UP) || jlKeyDown(KEY_DOWN) ||
                    jlKeyDown(KEY_LEFT) || jlKeyDown(KEY_RIGHT) ||
                    jx < -32 || jx > 32 || jy < -32 || jy > 32) {
                    demoExitToTitle(&game);
                    break;
                }
                if (game.demoEnded) {
                    // The recorded ride crashed out; $6B57 skips the
                    // life bookkeeping in demo mode -- back to title.
                    demoExitToTitle(&game);
                    break;
                }
                if (game.score != 0u) {
                    // Fare delivered: advance the rotation ($501D ->
                    // $50C6) without visiting the title.
                    demoEnter(&game);
                    break;
                }
                for (tick = 0u;
                     tick < pendingTicks && game.state == ST_STATE_DEMO &&
                     !game.demoEnded && game.score == 0u;
                     tick++) {
                    demoTick(&game);
                }
                stAudioSfxThrust(game.taxi.thrusting);
                break;
            }
            case ST_STATE_HIGHSCORES:
            case ST_STATE_INSTRUCTIONS:
                // Both are title sub-screens; FIRE returns to the title.
                // The screens cleared the stage, so force a full title
                // tilemap repaint on the way back.
                if (jlKeyPressed(KEY_SPACE) ||
                    jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
                    stRenderLevelChanged();
                    stRenderTitleReset();
                    game.state = ST_STATE_TITLE;
                }
                break;
            case ST_STATE_OPTIONS:
            {
                // "GAME VARIATIONS" menu ($5295). LEFT/RIGHT cycle the
                // cabbie count 1..4 (C64 stores 0..3 in $7213 and wraps
                // with AND #$03 -- so it rolls 4->1 and 1->4); FIRE
                // confirms and starts the game. LEFT/RIGHT are edge-
                // triggered so one press = one step.
                static bool prevLeft  = false;
                static bool prevRight = false;
                int8_t      jx    = jlJoystickX(JOYSTICK_0);
                bool        left  = jlKeyDown(KEY_LEFT)  || jx < -32;
                bool        right = jlKeyDown(KEY_RIGHT) || jx >  32;
                uint8_t     sel   = (uint8_t)((game.fareTarget - 1u) & 3u);

                if (right && !prevRight) {
                    sel = (uint8_t)((sel + 1u) & 3u);
                }
                if (left && !prevLeft) {
                    sel = (uint8_t)((sel - 1u) & 3u);
                }
                game.fareTarget = (uint8_t)(sel + 1u);
                prevLeft  = left;
                prevRight = right;

                if (jlKeyPressed(KEY_SPACE) ||
                    jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
                    game.score      = 0;
                    // "SELECT NUMBER OF CABBIES" ($7213) sets the number of
                    // taxi LIVES, not a fare quota: gFareCounter increments
                    // only on the crash path ($6BD6), and the game ends when
                    // crashes reach the cabbie count. Deliveries never end
                    // the game; you advance screens by flying up.
                    game.lives      = game.fareTarget;
                    game.levelIndex = 0;
                    if (!loadLevelByIndex(&game, 0)) {
                        jlLogF("spacetaxi: ! cannot load level 0 (DATA/levels/level01.dat)");
                        jlLogFlush();
                        goto shutdown;
                    }
                    stEngineReset(&game);
                    game.state = ST_STATE_PLAYING;
                }
                break;
            }
            case ST_STATE_PLAYING:
                // Advance physics at the fixed 30 Hz cadence -- each
                // playingTick handles the warp recede, physics, passenger,
                // land SFX, and transporter-exit trigger. Stop early if a
                // tick changed state (crash game-over, level done).
                for (tick = 0u;
                     tick < pendingTicks && game.state == ST_STATE_PLAYING;
                     tick++) {
                    playingTick(&game);
                }
                // Continuous thrust SFX reflects the final tick's state.
                stAudioSfxThrust(game.taxi.thrusting);
                break;
            case ST_STATE_LEVEL_DONE:
                // No music to stop -- gameplay is silent. The C64 plays
                // a score-screen jingle (song 7 / song 6) between levels;
                // not yet wired up here.
                if (loadLevelByIndex(&game, (uint8_t)(game.levelIndex + 1))) {
                    stEngineReset(&game);
                    game.state = ST_STATE_PLAYING;
                } else {
                    // ran out of levels -- back to title (player wins)
                    if (stLevelLoad(&game.level, "levels/title.dat")) {
                        stRenderLevelChanged();
                    }
                    stRenderTitleReset();
                    game.state = ST_STATE_TITLE;
                }
                break;
            case ST_STATE_GAME_OVER:
                if (jlKeyPressed(KEY_SPACE)) {
                    // Restore the title tilemap so the press-start
                    // overlay isn't sitting on top of the last-played
                    // level's art.
                    if (stLevelLoad(&game.level, "levels/title.dat")) {
                        stRenderLevelChanged();
                    }
                    stRenderTitleReset();
                    game.state = ST_STATE_TITLE;
                }
                break;
            default:
                game.state = ST_STATE_TITLE;
                break;
        }

        // Silence continuous thrust SFX whenever no cab is flying
        // under thrust (title, menus, game-over, level-done). The
        // attract DEMO is a recorded gameplay session and thrusts
        // audibly, so it keeps its own per-tick thrust SFX -- without
        // this exclusion the blanket silence overrode it and the demo
        // cab flamed in total silence.
        if (game.state != ST_STATE_PLAYING && game.state != ST_STATE_DEMO) {
            stAudioSfxThrust(false);
        }

        stRenderFrame(stage, &game);
        stAudioFrameTick();
        jlAudioFrameTick();
        // stRenderFrame already does jlWaitVBL right before its
        // jlStagePresent (sync-on-present), so an extra wait here
        // would just slow the loop to half framerate.
    }

shutdown:

    stAudioShutdown();
    stRenderShutdown();
    jlShutdown();
    return 0;
}
