import { BoxGeometry, MeshStandardMaterial, TorusKnotGeometry } from "three";
import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
import { BoxShape3D } from "../../../core/geom/3d/shape/BoxShape3D.js";
import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
import { shape_mesh_from_geometry } from "../../../core/geom/3d/shape/shape_mesh_from_geometry.js";
import Quaternion from "../../../core/geom/Quaternion.js";
import Vector2 from "../../../core/geom/Vector2.js";
import Vector3 from "../../../core/geom/Vector3.js";
import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
import { Tag } from "../../ecs/components/Tag.js";
import Entity from "../../ecs/Entity.js";
import { obtainTerrain } from "../../ecs/terrain/util/obtainTerrain.js";
import { Transform } from "../../ecs/transform/Transform.js";
import { EngineHarness } from "../../EngineHarness.js";
import { CameraSystem } from "../../graphics/ecs/camera/CameraSystem.js";
import { ShadedGeometry } from "../../graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "../../graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { ShadedGeometrySystem } from "../../graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import {
    AmbientOcclusionPostProcessEffect
} from "../../graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
import InputController from "../../input/ecs/components/InputController.js";
import InputControllerSystem from "../../input/ecs/systems/InputControllerSystem.js";
import { BodyKind } from "../../physics/ecs/BodyKind.js";
import { Collider } from "../../physics/ecs/Collider.js";
import { ColliderObserverSystem } from "../../physics/ecs/ColliderObserverSystem.js";
import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
import { RigidBody } from "../../physics/ecs/RigidBody.js";
import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
import { LedgeGrab } from "./abilities/LedgeGrab.js";
import { Mantle } from "./abilities/Mantle.js";
import { Slide } from "./abilities/Slide.js";
import { WallJump } from "./abilities/WallJump.js";
import { WallRun } from "./abilities/WallRun.js";
import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
import { FirstPersonPlayerControllerSystem } from "./FirstPersonPlayerControllerSystem.js";
import { BreathRhythmEvaluator } from "./mastery/BreathRhythmEvaluator.js";
import { DecisionPoint } from "./mastery/DecisionPoint.js";
import { FootAsymmetryTurnEvaluator } from "./mastery/FootAsymmetryTurnEvaluator.js";
import { SlideInitiationTimingEvaluator } from "./mastery/SlideInitiationTimingEvaluator.js";
import { StrideTimingJumpEvaluator } from "./mastery/StrideTimingJumpEvaluator.js";
import { FirstPersonSensorsSystem } from "./sensors/FirstPersonSensorsSystem.js";

/**
 * Prototype harness for {@link FirstPersonPlayerController} — a parkour
 * "gym" laid out as discrete test stations around the spawn point.
 *
 * Controls:
 *   - Click to capture mouse (pointer lock); ESC to release
 *   - WASD       — move
 *   - Mouse      — look
 *   - Space      — jump (tap; holding does not auto-repeat)
 *   - Shift      — sprint (hold while moving forward)
 *   - C          — crouch (hold; sprint+crouch press → slide)
 *
 * Stations (compass directions from spawn):
 *
 *   N  Mantle row         four obstacles from too-low to too-tall
 *   NE Slide tunnel       sprint run-up + low ceiling
 *   E  Wall-run wall      one long wall to run alongside
 *   SE Gap jump           raised platforms with a gap to clear
 *   S  Ledge-grab wall    too tall to mantle; jump + catch the lip
 *   SW Stairs             a climbable flight + a too-tall blocking riser
 *   W  Wall-jump chimney  two parallel walls for back-and-forth jumps
 *   NW Ramp row           inclines from gentle (walkable) to too-steep (slide)
 *
 * Each station is colour-coded at the entrance. Look around: the
 * coloured pad on the ground tells you what's there.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */

const SPAWN_X = 100;
const SPAWN_Z = 100;

// Station palette — used for entry pads + matching obstacle colour.
const COLOR_MANTLE = 0x55ff55;
const COLOR_WALLRUN = 0xff5555;
const COLOR_WALLJUMP = 0xaa55ff;
const COLOR_LEDGE = 0xffaa44;
const COLOR_SLIDE = 0x44ddee;
const COLOR_GAP = 0xff66cc;
const COLOR_STAIRS = 0xeedd44;
const COLOR_RAMP = 0x3377dd;
const COLOR_NEUTRAL = 0xaaaaaa;

const eh = new EngineHarness();

async function main(engine) {
    const em = engine.entityManager;
    const ecd = engine.entityManager.dataset;

    // -- Systems ---------------------------------------------------------
    EngineHarness.addFpsCounter(engine);

    await EngineHarness.buildLights({
        engine,
        sunIntensity: 1.0,
        ambientIntensity: 0.25,
        shadowmapResolution: 2048,
        sunShadowDistance: 30,
    });

    engine.plugins.acquire(AmbientOcclusionPostProcessEffect);

    if (em.getSystem(CameraSystem) === null) {
        await em.addSystem(new CameraSystem(engine.graphics));
    }
    if (em.getSystem(InputControllerSystem) === null) {
        await em.addSystem(new InputControllerSystem(engine.devices));
    }
    if (em.getSystem(ShadedGeometrySystem) === null) {
        await em.addSystem(new ShadedGeometrySystem(engine));
    }

    // Physics — owns RigidBody+Collider lifecycle, fires contact events,
    // and serves spatial queries (raycast). Sensors and the ground
    // resolver both go through it. Added BEFORE the controller so the
    // sensors and ground resolver find it in their startup auto-acquire.
    const physicsSystem = new PhysicsSystem();
    await em.addSystem(physicsSystem);
    // ColliderObserver wires Collider components into PhysicsSystem
    // bodies as entities are built. Without it, every collider would
    // need an explicit attach_collider call.
    await em.addSystem(new ColliderObserverSystem(physicsSystem));

    const fpsSystem = new FirstPersonPlayerControllerSystem();
    // Built-in flat-ground OFF — the gym uses the physics-backed ground
    // resolver below (which finds the highest static surface under the
    // player from BVH raycasts). Leaving the flat baseline on would
    // pin the player to y=0 even when standing on a 2 m platform.
    fpsSystem.useBuiltInFlatGround = false;
    // Collision is resolved by the KinematicMover (recover + unified 3D
    // sweep-and-slide + ground categorize/stick/slope/stairs) whenever a
    // PhysicsSystem is present — which it is here. The mover probes the
    // physics world for ground itself, so no `groundResolver` is wired.
    // See DESIGN_COLLISION.md.
    await em.addSystem(fpsSystem);

    // Sensors system — populates wall/obstacle/ledge probes via
    // PhysicsSystem.raycast. Auto-acquires the physics system in startup.
    const sensorsSystem = new FirstPersonSensorsSystem();
    await em.addSystem(sensorsSystem);

    if (!ecd.isComponentTypeRegistered(Tag)) {
        ecd.registerComponentType(Tag);
    }
    if (!ecd.isComponentTypeRegistered(SerializationMetadata)) {
        ecd.registerComponentType(SerializationMetadata);
    }

    // -- World -----------------------------------------------------------
    await EngineHarness.buildTerrain({
        engine,
        size: new Vector2(200, 200),
        resolution: 8,
        enableWater: false,
        diffuse0: "data/textures/materials/terrain_township_set/512/Grass_3.png",
    });

    obtainTerrain(ecd); // no-op call to ensure terrain is built

    buildGym(ecd);

    // -- Player ----------------------------------------------------------
    const player = buildPlayerEntity(ecd);

    // Ground resolver: downward raycast through PhysicsSystem, filtered
    // to exclude the player's own capsule. Wired AFTER the player is
    // built so the filter has the right entity id. The controller's
    // vertical integrator falls through when the resolver returns null
    // (no platform below) — set up with `useBuiltInFlatGround = false`
    // above so that means actual airborne / void, not a hidden y=0
    // safety floor.
    fpsSystem.groundResolver = makePhysicsGroundResolver(physicsSystem, player.entity);

    // -- Inputs ----------------------------------------------------------
    buildInputBindings(ecd, player);

    setupPointerLock(engine);

    logHelp();
}

/**
 * Downward-raycast ground resolver, backed by {@link PhysicsSystem.raycast}.
 * Each call: from just above the player's feet, cast straight down
 * through static + dynamic broadphase; filter out the player's own
 * body; return the hit y, or null if no surface below.
 *
 * The controller's vertical integrator picks max(useBuiltInFlatGround,
 * resolver). In the gym we leave useBuiltInFlatGround off so the
 * resolver is the sole source of truth — gaps in the floor are real
 * gaps (the player falls into them).
 *
 * @param {PhysicsSystem} physicsSystem
 * @param {number} playerEntity  excluded from raycasts to skip the player capsule
 * @returns {(x:number, y:number, z:number) => number|null}
 */
function makePhysicsGroundResolver(physicsSystem, playerEntity) {
    // Probe starts slightly above the player feet so a grounded player
    // standing right on a platform still raycasts cleanly. Length is
    // longer than any reasonable gym platform.
    const PROBE_LIFT = 0.1;
    const PROBE_DOWN = 50;
    const filter = (entity) => entity !== playerEntity;
    // Reuse a single ray and surface-point across calls — the resolver
    // can fire many times per frame as the controller hunts for foothold.
    const ray = Ray3.from(0, 0, 0, 0, -1, 0, PROBE_DOWN);
    const hit = new PhysicsSurfacePoint();
    return function physicsGroundResolver(x, y, z) {
        ray.setOrigin(x, y + PROBE_LIFT, z);
        if (!physicsSystem.raycast(ray, hit, filter)) return null;
        // Direction is unit (0,-1,0); t is the distance to the surface
        // along that direction. Surface Y = origin Y minus t.
        return (y + PROBE_LIFT) - hit.t;
    };
}

// =====================================================================
// Gym construction
// =====================================================================

function buildGym(ecd) {
    buildGroundPlane(ecd);
    buildSpawnPlaza(ecd);
    buildMantleStation(ecd);
    buildWallRunStation(ecd);
    buildWallJumpChimney(ecd);
    buildLedgeGrabStation(ecd);
    buildSlideTunnel(ecd);
    buildGapJumpStation(ecd);
    buildStairsStation(ecd);
    buildRampStation(ecd);
    buildMeshShapeShowcase(ecd);
}

/**
 * Drop a torus-knot prop off to the side as a smoke test for
 * {@link MeshShape3D}: arbitrary indexed triangle mesh → tetrahedralised
 * collider via {@link shape_mesh_from_geometry}.
 *
 * Dynamic body, so the player capsule (kinematic) can bump it around.
 * KinematicPosition bodies don't receive impulses themselves (the
 * controller writes the Transform), so a static knot vs. kinematic
 * capsule produces zero solver response and the capsule clips through.
 * Making the knot dynamic flips the relationship: when the capsule
 * penetrates, the solver applies impulse to the knot — it bounces away
 * and the player gets a visible reaction.
 *
 * Placed off the spawn plaza's main travel path (radius 18 north-east of
 * spawn) so it's visible but not in the way of the parkour drills.
 */
function buildMeshShapeShowcase(ecd) {
    // TorusKnotGeometry (radius, tube, tubularSegments, radialSegments).
    // 64 × 8 = 512 segments → ~512 verts / ~1024 tris — plenty of detail
    // for visual interest, light enough that the per-vertex `support()`
    // scan stays fast.
    const geom = new TorusKnotGeometry(0.5, 0.15, 64, 8);
    geom.computeVertexNormals();

    // Three packs positions as Float32Array (interleaved x, y, z) and
    // indices as Uint16Array by default. The MeshShape3D factory wants
    // a Uint32Array index buffer — convert once at construction.
    const positions = geom.attributes.position.array;
    const src_indices = geom.index.array;
    const indices = src_indices instanceof Uint32Array
        ? src_indices
        : Uint32Array.from(src_indices);

    const shape = shape_mesh_from_geometry(positions, indices);

    const material = new MeshStandardMaterial({ color: 0xffaa66, roughness: 0.5 });
    const sg = ShadedGeometry.from(geom, material);
    sg.setFlag(ShadedGeometryFlags.CastShadow);
    sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
    sg.setFlag(ShadedGeometryFlags.Visible);

    const transform = new Transform();
    // North-east of spawn, lifted ~2m off the ground so the knot drops
    // onto the floor when the sim starts and the player can walk over,
    // bump it, and watch it tumble.
    transform.position.set(SPAWN_X + 18, 2, SPAWN_Z - 18);

    const rigidBody = new RigidBody();
    rigidBody.kind = BodyKind.Dynamic;
    rigidBody.mass = 1;
    // Bounding radius ≈ main_radius + tube_radius = 0.65. For a chunky
    // bumpable body matching the existing 1m cube reference, use the
    // same `(6, 6, 6)` inverse inertia — it's slightly under-rotational
    // for the torus knot's mass distribution but reads as solid.
    rigidBody.inverseInertiaLocal.set(6, 6, 6);
    rigidBody.linearDamping = 0.5;
    rigidBody.angularDamping = 0.5;

    const collider = new Collider();
    collider.shape = shape;
    collider.friction = 0.5;
    collider.restitution = 0.2;

    new Entity()
        .add(sg)
        .add(rigidBody)
        .add(collider)
        .add(transform)
        .add(Tag.fromJSON(["GymObstacle"]))
        .add(SerializationMetadata.Transient)
        .build(ecd);
}

/**
 * Reference markers around spawn so motion is visually obvious — a ring
 * of coloured cubes (circle-strafe targets) and a few tall pillars at
 * radius 22 for parallax-at-distance.
 */
function buildSpawnPlaza(ecd) {
    const unitCubeGeom = new BoxGeometry(1, 1, 1);
    const palette = [
        0xff5555, 0x55ff55, 0x5577ff, 0xffaa44,
        0xaa55ff, 0x44ddee, 0xff66cc, 0x66ffcc,
    ];

    // Inner ring at radius 6 — close enough to circle-strafe around.
    const RING_N = 8;
    for (let i = 0; i < RING_N; i++) {
        const a = (i / RING_N) * Math.PI * 2;
        spawnBox(ecd, {
            center: new Vector3(SPAWN_X + Math.cos(a) * 6, 0.5, SPAWN_Z + Math.sin(a) * 6),
            size: new Vector3(1, 1, 1),
            geometry: unitCubeGeom,
            color: palette[i % palette.length],
        });
    }

    // Tall pillars further out — parallax depth perception when running.
    const pillarGeom = new BoxGeometry(1, 6, 1);
    const PILLAR_N = 6;
    for (let i = 0; i < PILLAR_N; i++) {
        const a = (i / PILLAR_N) * Math.PI * 2 + 0.3;
        const r = 22;
        spawnBox(ecd, {
            center: new Vector3(SPAWN_X + Math.cos(a) * r, 3, SPAWN_Z + Math.sin(a) * r),
            size: new Vector3(1, 6, 1),
            geometry: pillarGeom,
            color: 0x888888,
            roughness: 0.9,
        });
    }
}

/**
 * Mantle row — directly north of spawn. Four obstacles in line, heights
 * stepping past the mantle range:
 *
 *   - 0.6m: well within mantle range; lowest "step-up" test
 *   - 1.0m: mid mantle range
 *   - 1.3m: near top of mantle range
 *   - 2.0m: too tall to mantle from the ground (heightDiff > maxHeight)
 *
 * The 2.0m block is also the canonical "LedgeGrab if you jump at it"
 * candidate, but the dedicated LedgeGrab station to the south has a
 * cleaner approach path.
 */
function buildMantleStation(ecd) {
    const baseZ = SPAWN_Z + 15;
    spawnPad(ecd, SPAWN_X, baseZ - 3, COLOR_MANTLE);

    const heights = [0.6, 1.0, 1.3, 2.0];
    for (let i = 0; i < heights.length; i++) {
        const h = heights[i];
        spawnBox(ecd, {
            center: new Vector3(SPAWN_X, h / 2, baseZ + i * 5),
            size: new Vector3(3, h, 1),
            color: COLOR_MANTLE,
            roughness: 0.6,
        });
    }
}

/**
 * Wall-run course — a dedicated traversal east of spawn. Two raised
 * platforms separated by a gap too wide to plain-jump, with a tall wall
 * down ONE side that spans the gap. Stairs climb onto each platform from
 * its outer end. The drill:
 *
 *   1. climb the west stairs onto platform A
 *   2. SPRINT east along the wall (a run, not a walk — wallRun.minSpeed 5.5)
 *   3. jump at the platform edge → the wall-run engages and carries you
 *      across the gap along the wall
 *   4. land on platform B and descend the east stairs
 *
 * The wall is on the −Z side, its north face flush with the platforms' south
 * faces, rising from the gap floor; it overlaps ~2 m onto each platform so
 * the run can engage before the gap and dismount onto the far side.
 */
function buildWallRunStation(ecd) {
    const z = SPAWN_Z;               // course centreline along Z
    const gapCenterX = SPAWN_X + 22; // east of spawn (clear of the inner ring)
    const H = 1.5;                   // platform top height
    const GAP = 7;                   // wider than a running jump (~5 m) → must wall-run
    const platX = 5, platZ = 4;      // platform footprint (X × Z)
    const halfX = platX / 2;
    const RISE = 0.25, DEPTH = 0.4, STEPS = Math.round(H / RISE); // 6 steps to y=H

    const aCenterX = gapCenterX - GAP / 2 - halfX; // platform A (west, launch)
    const bCenterX = gapCenterX + GAP / 2 + halfX; // platform B (east, land)
    const aWestEdge = aCenterX - halfX;
    const bEastEdge = bCenterX + halfX;

    // Entry pad on open ground, just west of the foot of the west stairs.
    spawnPad(ecd, aWestEdge - STEPS * DEPTH - 2.5, z, COLOR_WALLRUN);

    // Two platforms, solid from the ground (top at y=H).
    spawnBox(ecd, { center: new Vector3(aCenterX, H / 2, z), size: new Vector3(platX, H, platZ), color: COLOR_WALLRUN, roughness: 0.6 });
    spawnBox(ecd, { center: new Vector3(bCenterX, H / 2, z), size: new Vector3(platX, H, platZ), color: COLOR_WALLRUN, roughness: 0.6 });

    // The wall to run on — south edge, north face flush with the platforms'
    // −Z faces, spanning the gap plus 2 m onto each platform, rising from the
    // gap floor well above head height through the run arc.
    const wallH = 5;
    const wallZ = z - platZ / 2 - 0.25; // 0.5-thick wall; north face at the platform south edge
    spawnBox(ecd, {
        center: new Vector3(gapCenterX, wallH / 2, wallZ),
        size: new Vector3(GAP + 4, wallH, 0.5),
        color: COLOR_WALLRUN,
        roughness: 0.6,
    });

    // Stairs onto each platform from its outer end, each flight rising toward
    // the platform it serves.
    spawnStairFlight(ecd, aWestEdge, z, +1, STEPS, RISE, DEPTH, platZ, COLOR_WALLRUN); // west: ascend +X onto A
    spawnStairFlight(ecd, bEastEdge, z, -1, STEPS, RISE, DEPTH, platZ, COLOR_WALLRUN); // east: ascend −X onto B
}

/**
 * A flight of solid-pillar steps (the buildStairsStation profile) reaching
 * height `steps*rise`, flush at `topEdgeX`, descending to the ground away
 * from it. `ascendDir` is the climb direction along X (+1 toward +X, −1
 * toward −X); steps march the other way, edge-to-edge, each a pillar from the
 * ground up to its tread height.
 */
function spawnStairFlight(ecd, topEdgeX, z, ascendDir, steps, rise, depth, width, color) {
    for (let i = 1; i <= steps; i++) {
        const topY = i * rise;
        const cx = topEdgeX - ascendDir * (steps - i + 0.5) * depth;
        spawnBox(ecd, {
            center: new Vector3(cx, topY / 2, z),
            size: new Vector3(depth, topY, width),
            color,
            roughness: 0.6,
        });
    }
}

/**
 * Wall-jump chimney — two parallel walls on the west side, ~3m apart,
 * 6m tall. Player walks into the gap, jumps, hits one wall, wall-jumps
 * to the other side. Repeat to climb.
 *
 * The walls are wide enough that wall-jumping straight back to the
 * other side stays inside the chimney.
 */
function buildWallJumpChimney(ecd) {
    const baseX = SPAWN_X - 15;
    spawnPad(ecd, baseX + 3, SPAWN_Z, COLOR_WALLJUMP);

    // North wall of the chimney (pushes south).
    spawnBox(ecd, {
        center: new Vector3(baseX - 2, 3, SPAWN_Z + 2),
        size: new Vector3(4, 6, 0.5),
        color: COLOR_WALLJUMP,
        roughness: 0.6,
    });
    // South wall of the chimney (pushes north). 3m gap to the north wall.
    spawnBox(ecd, {
        center: new Vector3(baseX - 2, 3, SPAWN_Z - 1.25),
        size: new Vector3(4, 6, 0.5),
        color: COLOR_WALLJUMP,
        roughness: 0.6,
    });
}

/**
 * Ledge-grab station — a single 2m-tall wall just south of spawn.
 * Too tall to mantle from the ground (heightDiff = 2 > maxHeight 1.4),
 * but the player can run-jump at it and catch the lip on descent:
 *
 *   1. sprint south
 *   2. jump just before the wall
 *   3. on the way down, chest probe hits the wall face;
 *      ledgeAhead probe finds the top → LedgeGrab fires
 *   4. press jump to chain into Mantle (mantle-up release)
 *
 * The wall is 6m wide so the player doesn't have to aim perfectly.
 */
function buildLedgeGrabStation(ecd) {
    const baseZ = SPAWN_Z - 15;
    spawnPad(ecd, SPAWN_X, baseZ + 3, COLOR_LEDGE);

    spawnBox(ecd, {
        center: new Vector3(SPAWN_X, 1.0, baseZ - 1),
        size: new Vector3(6, 2.0, 2),
        color: COLOR_LEDGE,
        roughness: 0.6,
    });

    // A larger top-of-wall platform behind the lip, so once the player
    // climbs up they have somewhere to stand and look around.
    spawnBox(ecd, {
        center: new Vector3(SPAWN_X, 1.0, baseZ - 4.5),
        size: new Vector3(6, 2.0, 5),
        color: COLOR_LEDGE,
        roughness: 0.6,
    });
}

/**
 * Slide tunnel — a sprint run-up followed by a low ceiling that forces
 * the player to slide under. NE of spawn.
 *
 * Layout: a long flat lane (just an entry pad / marker, ground is
 * already flat), with an overhead box at 1m height. Approaching at
 * sprint + crouch press → slide → fits under the gap.
 */
function buildSlideTunnel(ecd) {
    const baseX = SPAWN_X + 11;
    const baseZ = SPAWN_Z + 11;
    spawnPad(ecd, baseX, baseZ, COLOR_SLIDE);

    // The "ceiling" — a slab suspended 1.0m above the ground. Sliding
    // brings the eye height down to ~0.4m; standing-eye-height would
    // collide.
    spawnBox(ecd, {
        center: new Vector3(baseX + 4, 1.5, baseZ + 4),
        size: new Vector3(4, 1.0, 4),
        color: COLOR_SLIDE,
        roughness: 0.6,
    });

    // Side pillars to make the tunnel feel like a tunnel.
    spawnBox(ecd, {
        center: new Vector3(baseX + 4, 1.0, baseZ + 2.0),
        size: new Vector3(4, 2.0, 0.5),
        color: COLOR_SLIDE,
        roughness: 0.7,
    });
    spawnBox(ecd, {
        center: new Vector3(baseX + 4, 1.0, baseZ + 6.0),
        size: new Vector3(4, 2.0, 0.5),
        color: COLOR_SLIDE,
        roughness: 0.7,
    });
}

/**
 * Gap jump — two elevated platforms with a 3m gap between them. Player
 * approaches via a mantle stair, runs across the first platform,
 * sprints + jumps the gap.
 *
 * The platforms sit 1.5m above the spawn ground (mantleable). The gap
 * doesn't actually break the floor — landing flat would still catch
 * the player on the y=0 floor below. That's fine for testing the
 * mid-flight feel of a jump; survival of misses is a feature here.
 */
function buildGapJumpStation(ecd) {
    const baseX = SPAWN_X + 11;
    const baseZ = SPAWN_Z - 11;
    spawnPad(ecd, baseX, baseZ, COLOR_GAP);

    // Approach mantle (step up to the first platform).
    spawnBox(ecd, {
        center: new Vector3(baseX + 2, 0.5, baseZ - 2),
        size: new Vector3(2, 1.0, 2),
        color: COLOR_GAP,
        roughness: 0.7,
    });

    // First platform — 4m long along Z.
    spawnBox(ecd, {
        center: new Vector3(baseX + 5, 0.75, baseZ - 4),
        size: new Vector3(4, 1.5, 4),
        color: COLOR_GAP,
        roughness: 0.6,
    });

    // Second platform — same height, 3m gap.
    spawnBox(ecd, {
        center: new Vector3(baseX + 5, 0.75, baseZ - 11),
        size: new Vector3(4, 1.5, 4),
        color: COLOR_GAP,
        roughness: 0.6,
    });
}

/**
 * Stairs station — a walkable staircase up to a landing, SW of spawn.
 * Exercises the KinematicMover's stair handling (DESIGN_COLLISION.md
 * Phase 3): the player walks up the steps staying grounded (no launch
 * off each lip) and walks back down staying grounded (stick-to-ground
 * snaps onto each lower step rather than going airborne).
 *
 * Each step rises `RISE` (0.2 m) — comfortably under the controller's
 * `stepHeight` (0.3 m), so the mover climbs it. A blocking reference
 * sits beside the stairs: a single 0.5 m riser (> stepHeight) the
 * player canNOT walk up, for contrast.
 *
 * Steps are solid pillars from the ground to each tread height, placed
 * edge-to-edge so they form a clean staircase profile (no floating
 * treads, no overlap). The player approaches from the +X (spawn) side
 * and walks −X up the flight.
 */
function buildStairsStation(ecd) {
    const baseX = SPAWN_X - 11;
    const baseZ = SPAWN_Z - 11;
    spawnPad(ecd, baseX, baseZ, COLOR_STAIRS);

    const RISE = 0.2;    // per-step rise — under stepHeight (0.3) → climbable
    const DEPTH = 0.45;  // tread depth
    const WIDTH = 4;     // generous width so aiming isn't required
    const STEPS = 7;     // → 1.4 m top
    const firstFrontX = baseX - 2; // near (east) edge of the first step

    for (let i = 1; i <= STEPS; i++) {
        const topY = i * RISE;
        spawnBox(ecd, {
            center: new Vector3(firstFrontX - (i - 0.5) * DEPTH, topY / 2, baseZ),
            size: new Vector3(DEPTH, topY, WIDTH),
            color: COLOR_STAIRS,
            roughness: 0.6,
        });
    }

    // Landing platform at the top, flush with the last tread.
    const topY = STEPS * RISE;
    spawnBox(ecd, {
        center: new Vector3(firstFrontX - STEPS * DEPTH - 1.5, topY / 2, baseZ),
        size: new Vector3(3, topY, WIDTH),
        color: COLOR_STAIRS,
        roughness: 0.6,
    });

    // Blocking reference — a single 0.5 m riser (> stepHeight) beside the
    // flight (offset +Z). Walk into it: the mover blocks rather than
    // climbing, the contrast to the stairs.
    spawnBox(ecd, {
        center: new Vector3(firstFrontX - 1, 0.25, baseZ + WIDTH / 2 + 2),
        size: new Vector3(3, 0.5, 3),
        color: COLOR_NEUTRAL,
        roughness: 0.8,
    });
}

/**
 * NW — Ramp row. A line of inclined slabs at increasing angle. The mover's
 * walkability gate is `minWalkNormal` 0.7 (≈ 45.6°), so the gentle ramps can
 * be walked / run up, while the steep ones can't be stood on — step onto one
 * and you slide back down. Approach from the south (the pad) and head +Z up
 * each.
 *
 * Each ramp is a thin slab rotated about X so its top face rises in +Z. The
 * lower half is buried in the ground plane (the realistic "ramp base sunk in
 * terrain" pattern), so the surface emerges flush — no lip to catch on.
 */
function buildRampStation(ecd) {
    const baseX = SPAWN_X - 11;
    const baseZ = SPAWN_Z + 11; // emergence line — ramps rise +Z (north) from here
    spawnPad(ecd, baseX, baseZ - 3, COLOR_RAMP);

    const angles = [20, 30, 40, 50, 60]; // ° — 20/30/40 walkable, 50/60 too steep (slide)
    const LENGTH = 7;   // slab length (half buried) → ~3.5·cosθ of usable run
    const WIDTH = 2.5;  // generous enough to aim up without precision
    const SPACING = 3;  // between ramp centres, along X
    const x0 = baseX - ((angles.length - 1) * SPACING) / 2; // centre the row on baseX

    angles.forEach((deg, i) => {
        spawnRamp(ecd, x0 + i * SPACING, baseZ, deg, LENGTH, WIDTH, COLOR_RAMP);
    });
}

/**
 * Spawn one inclined ramp slab whose top face emerges from the ground at
 * (ex, 0, ez) and rises in +Z at `angleDeg`. Built as a `length`×`THICK`×
 * `width`... actually width×THICK×length box (X=width, Y=thickness, Z=length)
 * rotated −angle about X, then offset so the top-face centre lands on the
 * emergence point (lower half buried, upper half the walkable incline).
 */
function spawnRamp(ecd, ex, ez, angleDeg, length, width, color) {
    const theta = (angleDeg * Math.PI) / 180;
    const THICK = 0.5;
    const h = THICK / 2;
    const rot = new Quaternion();
    rot.fromAxisAngle(new Vector3(1, 0, 0), -theta); // top face tilts up toward +Z
    spawnBox(ecd, {
        center: new Vector3(ex, -h * Math.cos(theta), ez + h * Math.sin(theta)),
        size: new Vector3(width, THICK, length),
        color,
        roughness: 0.6,
        rotation: rot,
    });
}

// =====================================================================
// Spawn helpers
// =====================================================================

/**
 * Add a box-shaped obstacle to the world. Position is the box centre.
 *
 * Single entity carrying both visual (ShadedGeometry) and physical
 * (RigidBody + Collider) representations so the player can stand on it,
 * sensors can raycast against it, and dynamic crates (when added) bump
 * off it. Bodies are {@link BodyKind.Static} — gym geometry never moves.
 *
 * @param {EntityComponentDataset} ecd
 * @param {object} p
 * @param {Vector3} p.center
 * @param {Vector3} p.size       w, h, d (box dimensions)
 * @param {number} p.color
 * @param {number} [p.roughness]
 * @param {THREE.BufferGeometry} [p.geometry]   reuse to avoid new geometry per spawn
 * @param {string} [p.tag]
 * @param {boolean} [p.collide]   default true; set false for decorative pads
 */
function spawnBox(ecd, { center, size, color, roughness = 0.7, geometry, tag = "GymObstacle", collide = true, rotation }) {
    const geom = geometry !== undefined ? geometry : new BoxGeometry(size.x, size.y, size.z);
    const material = new MeshStandardMaterial({ color, roughness });
    const sg = ShadedGeometry.from(geom, material);
    sg.setFlag(ShadedGeometryFlags.CastShadow);
    sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
    sg.setFlag(ShadedGeometryFlags.Visible);

    const transform = new Transform();
    transform.position.copy(center);
    // One Transform drives both the visual mesh and the collider, so a
    // rotation here tilts a box into a ramp consistently for render + physics.
    if (rotation !== undefined) transform.rotation.copy(rotation);

    const eb = new Entity()
        .add(sg)
        .add(Tag.fromJSON([tag]))
        .add(SerializationMetadata.Transient);

    if (collide) {
        const rigidBody = new RigidBody();
        rigidBody.kind = BodyKind.Static;
        const collider = new Collider();
        // BoxShape3D with explicit half-extents — routes through the
        // closed-form sphere-box / box-box / capsule-box narrowphase paths
        // instead of degenerating into GJK+EPA, which is unreliable on
        // smooth-surface pairs like capsule-vs-box.
        collider.shape = BoxShape3D.from_size(size.x, size.y, size.z);
        // Transform is added LAST so the (RigidBody, Transform) tuple
        // completes only after the body is present — PhysicsSystem.link
        // is the trigger, not the rigidBody add.
        eb.add(rigidBody).add(collider).add(transform).build(ecd);
    } else {
        eb.add(transform).build(ecd);
    }
}

/**
 * Lay a thin coloured "entry pad" on the ground at (x, z). Visual
 * landmark only — flush with the floor, no collision (a 5 cm-thick
 * static body in the player's walking path would bump the camera).
 */
function spawnPad(ecd, x, z, color) {
    spawnBox(ecd, {
        center: new Vector3(x, 0.025, z),
        size: new Vector3(4, 0.05, 4),
        color,
        roughness: 0.95,
        tag: "GymEntryPad",
        collide: false,
    });
}

/**
 * Lay a physics-only ground body underneath the entire gym so the
 * player has something to stand on. The terrain mesh provides the
 * grass visual at y=0; this is its physics counterpart (the player
 * controller's vertical integrator and sensors only look at physics
 * bodies, not the terrain heightfield).
 *
 * Sized to comfortably encompass all stations (the gym spans ~30 m
 * radius around spawn). The slab top sits at y=0 — same as the
 * terrain visual — so standing on it puts the player's feet on the
 * grass.
 */
function buildGroundPlane(ecd) {
    const SIZE = 120;
    const THICKNESS = 1.0;

    const rigidBody = new RigidBody();
    rigidBody.kind = BodyKind.Static;
    const collider = new Collider();
    collider.shape = BoxShape3D.from_size(SIZE, THICKNESS, SIZE);

    const transform = new Transform();
    // Top face at y=0 → centre at y = -thickness/2.
    transform.position.set(SPAWN_X, -THICKNESS / 2, SPAWN_Z);

    new Entity()
        .add(rigidBody)
        .add(collider)
        .add(Tag.fromJSON(["GymGroundPlane"]))
        .add(SerializationMetadata.Transient)
        .add(transform)
        .build(ecd);
}

// =====================================================================
// Player + input
// =====================================================================

/**
 * @param {EntityComponentDataset} ecd
 * @returns {{entity:number, controller:FirstPersonPlayerController}}
 */
function buildPlayerEntity(ecd) {
    const transform = new Transform();
    // Spawn standing on the ground at the centre of the plaza.
    transform.position.set(SPAWN_X, 0, SPAWN_Z);

    const controller = new FirstPersonPlayerController();

    // Movement abilities (priority order — see DESIGN_EXTENSIONS §2.2):
    //   Slide(10) < Mantle(30) < LedgeGrab(40) < WallRun(50) < WallJump(60).
    controller.abilities.add(new Slide());
    controller.abilities.add(new Mantle());
    controller.abilities.add(new LedgeGrab());
    controller.abilities.add(new WallRun());
    controller.abilities.add(new WallJump());

    // Mastery evaluators — small bonuses/penalties tied to gait phase,
    // foot asymmetry, and breath rhythm. Each fires on one (or a few)
    // DecisionPoint(s); contributions compose multiplicatively.
    controller.mastery.add(new StrideTimingJumpEvaluator());
    controller.mastery.add(new SlideInitiationTimingEvaluator());
    controller.mastery.add(new FootAsymmetryTurnEvaluator());
    controller.mastery.add(new BreathRhythmEvaluator());
    controller.mastery.add(new BreathRhythmEvaluator({
        decisionPoints: [DecisionPoint.GroundAccel],
        peakBonus: 0.015,
        troughPenalty: 0.005,
        requireExertionAbove: 0.3,
    }));

    // Kinematic capsule body: the controller writes Transform directly,
    // physics derives velocity from the per-step delta. Capsule centred
    // on the Transform — bottom hemisphere extends below the feet (this
    // is fine for spatial queries since sensor probes start at chest
    // height and the ground resolver filters out the player's own body).
    const bodyCfg = controller.config.body;
    const radius = bodyCfg.radius;
    const totalHeight = bodyCfg.height; // standing eye height ≈ body extent
    const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
    const rigidBody = new RigidBody();
    rigidBody.kind = BodyKind.KinematicPosition;
    rigidBody.mass = bodyCfg.mass;
    const collider = new Collider();
    // Capsule lives in [feet .. head] in world space. CapsuleShape3D is
    // centred at its local origin, so wrap it in a TransformedShape3D
    // with a +Y translation of totalHeight/2 — that puts the capsule
    // bottom at the player's feet (Transform.position) and the top at
    // the head. Without this, shape_cast queries against the ground
    // would stop the capsule centre at floor + half-extent, leaving
    // the player visibly floating half a body height above the floor.
    collider.shape = TransformedShape3D.from_translation(
        CapsuleShape3D.from(radius, cylinderHeight),
        [0, totalHeight / 2, 0],
    );

    // Build order matters: Transform is added LAST so the FirstPerson
    // controller system's link() sees the RigidBody already attached.
    // (Same pattern as the spec helpers.)
    const eb = new Entity()
        .add(controller)
        .add(rigidBody)
        .add(collider)
        .add(Tag.fromJSON(["Player"]))
        .add(SerializationMetadata.Transient)
        .add(transform);

    eb.build(ecd);

    return { entity: eb.id, controller };
}

/**
 * Wire keyboard + mouse to the controller's intent surface.
 *
 * @param {EntityComponentDataset} ecd
 * @param {{entity:number, controller:FirstPersonPlayerController}} player
 */
function buildInputBindings(ecd, player) {
    const ic = new InputController();
    const intent = player.controller.intent;

    // Mouse sensitivity — radians of view rotation per pixel of mouse delta.
    const SENSITIVITY = (Math.PI * 2) / 1200;

    // -- WASD movement --------------------------------------------------
    const held = { w: false, a: false, s: false, d: false };

    function recomputeMove() {
        let mx = 0, my = 0;
        if (held.d) mx += 1;
        if (held.a) mx -= 1;
        if (held.w) my += 1;
        if (held.s) my -= 1;
        intent.move.set(mx, my);
    }

    function bindMoveKey(key, prop) {
        ic.bind(`keyboard/keys/${key}/down`, () => {
            held[prop] = true;
            recomputeMove();
        });
        ic.bind(`keyboard/keys/${key}/up`, () => {
            held[prop] = false;
            recomputeMove();
        });
    }

    bindMoveKey("w", "w");
    bindMoveKey("a", "a");
    bindMoveKey("s", "s");
    bindMoveKey("d", "d");

    // -- Jump ------------------------------------------------------------
    ic.bind("keyboard/keys/space/down", () => { intent.jump = true; });
    ic.bind("keyboard/keys/space/up", () => { intent.jump = false; });

    // -- Sprint ----------------------------------------------------------
    ic.bind("keyboard/keys/shift/down", () => { intent.sprint = true; });
    ic.bind("keyboard/keys/shift/up", () => { intent.sprint = false; });

    // -- Crouch ----------------------------------------------------------
    ic.bind("keyboard/keys/c/down", () => { intent.crouch = true; });
    ic.bind("keyboard/keys/c/up", () => { intent.crouch = false; });

    // -- Mouse look ------------------------------------------------------
    ic.bind("pointer/on/move", (position, event, delta) => {
        if (document.pointerLockElement === null) return;
        intent.look._add(delta.x * SENSITIVITY, delta.y * SENSITIVITY);
    });

    ic.on.unlinked.add(() => {
        intent.move.set(0, 0);
        intent.look.set(0, 0);
        intent.jump = false;
        intent.crouch = false;
        intent.sprint = false;
    });

    new Entity()
        .add(ic)
        .add(Tag.fromJSON(["FirstPersonInputBindings"]))
        .add(SerializationMetadata.Transient)
        .build(ecd);
}

/**
 * Click to capture; ESC (or other browser-default) to release.
 * @param {Engine} engine
 */
function setupPointerLock(engine) {
    const el = engine.graphics.domElement;
    const captureEl = engine.viewStack.el;

    captureEl.addEventListener("click", () => {
        if (document.pointerLockElement !== el && el.requestPointerLock) {
            el.requestPointerLock();
        }
    });

    captureEl.addEventListener("contextmenu", (e) => e.preventDefault());
}

function logHelp() {
    /* eslint-disable no-console */
    console.log("%cFirstPersonPlayerController — Parkour Gym",
        "color:#88ddff;font-weight:bold;font-size:14px");
    console.log("Click the canvas to capture mouse. ESC to release.");
    console.log("WASD = move | Mouse = look | Space = jump | Shift = sprint | C = crouch");
    console.log("");
    console.log("%cStations:", "font-weight:bold");
    console.log("  N  Mantle row — four obstacles, last one is too tall");
    console.log("  NE Slide tunnel — sprint + C to slide under the low ceiling");
    console.log("  E  Wall-run — sprint along, jump near the wall");
    console.log("  SE Gap jump — mantle up the stair, sprint, jump the gap");
    console.log("  S  Ledge-grab — too tall to mantle; jump at it, catch the lip");
    console.log("  W  Wall-jump chimney — alternate walls to climb");
}

async function init() {
    await eh.initialize();
    await main(eh.engine);
}

init();
