//! The [`PhysicsWorld`] resource and the fixed-timestep simulation step.
//!
//! This is the bridge between the engine's ECS (the source of truth) and the
//! rapier simulation. The rapier state lives in a [`PhysicsWorld`] resource
//! rebuilt from the [`RigidBody`]/[`Collider`] components; each fixed step the
//! [`step_physics`] system:
//!
//! 1. **syncs** — inserts a rapier body+collider for every new physics entity,
//!    removes bodies for despawned ones, and pushes kinematic targets,
//! 2. **steps** the rapier pipeline by one [`fixed_delta`](oxide_engine::app::Time::fixed_delta),
//! 3. **writes back** each moved body's pose onto its entity's
//!    [`Transform`](oxide_engine::math::Transform).
//!
//! Because the ECS components are authoritative and rapier is transient, the
//! Stage-8.7 play-mode snapshot captures physics for free: Stop restores the
//! authored components and the next Play rebuilds the world.
//!
//! ## Conversions & limitations (this piece)
//!
//! rapier 0.33 uses its own (glam-backed) math types; this module converts at
//! the boundary by components so the engine stays on its own `glam`. Two
//! simplifications land with the first simulation piece and are lifted later:
//! **`Transform::scale` is ignored** (the collider uses its authored
//! dimensions), and **non-root bodies** write back through their parent's world
//! transform but author their shapes in world space — keep physics bodies at the
//! scene root or unscaled for now.

use std::collections::{HashMap, HashSet};

use oxide_engine::app::App;
use oxide_engine::math::{Quat, Transform, Vec3};
use oxide_engine::scene::{Entity, Scene};

use rapier3d::control::{CharacterAutostep, CharacterLength, KinematicCharacterController};
use rapier3d::math::{Pose, Rotation, Vector};
use rapier3d::parry::query::ShapeCastOptions;
use rapier3d::parry::shape::{Ball, Capsule};
use rapier3d::prelude::{
    ActiveEvents, ChannelEventCollector, ColliderBuilder, ColliderHandle,
    CollisionEvent as RapierCollisionEvent, FixedJointBuilder, Group, ImpulseJointHandle,
    InteractionGroups, InteractionTestMode, PhysicsWorld as RapierWorld, PrismaticJointBuilder,
    QueryFilter, Ray, RevoluteJointBuilder, RigidBodyBuilder, RigidBodyHandle,
    SphericalJointBuilder,
};

use oxide_engine::layer::LayerMask;

use crate::{
    CharacterController, Collider, ColliderShape, PhysicsSettings, RigidBody, RigidBodyKind,
    DEFAULT_GRAVITY,
};

/// The collision-resolved result of a [`PhysicsWorld::move_character`] call.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CharacterMovement {
    /// The actual translation to apply, after sliding along walls, stepping up
    /// ledges, and snapping to the ground (may be shorter than the request).
    pub translation: Vec3,
    /// Whether the character is standing on walkable ground after the move.
    pub grounded: bool,
}

/// Which kind of constraint a joint imposes between two bodies.
///
/// Each anchors a point on body A to a point on body B and removes some degrees
/// of freedom:
/// - [`Fixed`](Self::Fixed) — welds the bodies: no relative motion at all.
/// - [`Spherical`](Self::Spherical) — ball-and-socket: the anchor points stay
///   coincident, but the bodies may rotate freely about it (3 rotational DOF).
/// - [`Revolute`](Self::Revolute) — hinge: like spherical but rotation is locked
///   to a single `axis` (1 rotational DOF).
/// - [`Prismatic`](Self::Prismatic) — slider: the bodies may only translate
///   relative to each other along `axis` (1 translational DOF).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum JointKind {
    /// A rigid weld — zero relative DOF.
    Fixed,
    /// A ball-and-socket joint — 3 rotational DOF about the anchor.
    Spherical,
    /// A hinge about `axis` (in body A's local frame).
    Revolute { axis: Vec3 },
    /// A slider along `axis` (in body A's local frame).
    Prismatic { axis: Vec3 },
}

/// An opaque handle to a joint created with [`PhysicsWorld::add_joint`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JointId(ImpulseJointHandle);

/// The result of a successful scene query (raycast or shape-cast).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RayHit {
    /// The entity whose collider was hit.
    pub entity: Entity,
    /// Distance along the (normalized) ray/sweep direction to the hit.
    pub toi: f32,
    /// The world-space hit point.
    pub point: Vec3,
    /// The world-space surface normal at the hit.
    pub normal: Vec3,
}

/// One collision/overlap transition surfaced to game code for the current frame.
///
/// Reported when two colliders **start** or **stop** touching. A `sensor` event
/// is a trigger overlap (one collider is a sensor — no contact was resolved); a
/// non-`sensor` event is a solid contact. The pair is unordered. "Stay" (ongoing
/// overlap) is not an event — query it with
/// [`PhysicsWorld::is_intersecting`] / [`PhysicsWorld::intersecting_pairs`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollisionEvent {
    /// One of the two entities involved.
    pub a: Entity,
    /// The other entity.
    pub b: Entity,
    /// `true` = the pair began touching (enter); `false` = stopped (exit).
    pub started: bool,
    /// `true` = a sensor/trigger overlap; `false` = a solid contact.
    pub sensor: bool,
}

impl CollisionEvent {
    /// The other entity in the pair, given one of them (or `None` if `entity`
    /// is not part of this event).
    pub fn other(&self, entity: Entity) -> Option<Entity> {
        if self.a == entity {
            Some(self.b)
        } else if self.b == entity {
            Some(self.a)
        } else {
            None
        }
    }
}

// --- glam <-> rapier conversions (by component; the crates' glam versions
//     differ, so we never rely on type identity) ---------------------------

fn to_vec(v: Vec3) -> Vector {
    Vector::new(v.x, v.y, v.z)
}

fn from_vec(v: Vector) -> Vec3 {
    Vec3::new(v.x, v.y, v.z)
}

fn to_quat(q: Quat) -> Rotation {
    Rotation::from_xyzw(q.x, q.y, q.z, q.w)
}

fn from_quat(q: Rotation) -> Quat {
    Quat::from_xyzw(q.x, q.y, q.z, q.w)
}

fn to_pose(t: &Transform) -> Pose {
    Pose::from_parts(to_vec(t.translation), to_quat(t.rotation))
}

/// The rapier simulation, plus the entity ↔ handle mapping.
///
/// Installed as an [`App`] resource by
/// [`PhysicsModule`](crate::PhysicsModule); driven each fixed step by
/// [`step_physics`]. Public control methods (forces, velocities, sleep/wake)
/// operate by [`Entity`] so game code never handles rapier types.
pub struct PhysicsWorld {
    world: RapierWorld,
    entity_to_body: HashMap<Entity, RigidBodyHandle>,
    body_to_entity: HashMap<RigidBodyHandle, Entity>,
    entity_to_collider: HashMap<Entity, ColliderHandle>,
    collider_to_entity: HashMap<ColliderHandle, Entity>,
    /// Collision/trigger transitions for the current frame (accumulated across
    /// every fixed sub-step, cleared at the first step of each frame).
    events: Vec<CollisionEvent>,
    /// The frame the `events` buffer was last cleared for.
    events_frame: u64,
}

impl Default for PhysicsWorld {
    fn default() -> Self {
        Self::new()
    }
}

impl PhysicsWorld {
    /// A new, empty physics world.
    pub fn new() -> Self {
        Self {
            world: RapierWorld::new(),
            entity_to_body: HashMap::new(),
            body_to_entity: HashMap::new(),
            entity_to_collider: HashMap::new(),
            collider_to_entity: HashMap::new(),
            events: Vec::new(),
            events_frame: 0,
        }
    }

    /// The rapier body handle backing `entity`, if it is in the simulation.
    /// Exposed for the joint system (a later piece) and advanced callers.
    pub fn body_handle(&self, entity: Entity) -> Option<RigidBodyHandle> {
        self.entity_to_body.get(&entity).copied()
    }

    /// The number of bodies currently in the simulation.
    pub fn body_count(&self) -> usize {
        self.entity_to_body.len()
    }

    // --- Collision / trigger events ----------------------------------------

    /// Every collision/trigger transition (enter/exit) from the current frame.
    pub fn collision_events(&self) -> &[CollisionEvent] {
        &self.events
    }

    /// The trigger (sensor) overlap transitions from the current frame.
    pub fn trigger_events(&self) -> impl Iterator<Item = &CollisionEvent> {
        self.events.iter().filter(|e| e.sensor)
    }

    /// The solid-contact transitions from the current frame.
    pub fn contact_events(&self) -> impl Iterator<Item = &CollisionEvent> {
        self.events.iter().filter(|e| !e.sensor)
    }

    /// Whether two entities' colliders are currently intersecting (the "stay"
    /// state between an enter and an exit event). Works for both sensor overlaps
    /// and solid contacts.
    pub fn is_intersecting(&self, a: Entity, b: Entity) -> bool {
        let (Some(&ha), Some(&hb)) = (
            self.entity_to_collider.get(&a),
            self.entity_to_collider.get(&b),
        ) else {
            return false;
        };
        self.world.intersection_pair(ha, hb).unwrap_or(false)
            || self
                .world
                .contact_pair(ha, hb)
                .is_some_and(|p| p.has_any_active_contact())
    }

    /// All entity pairs whose sensor colliders are currently overlapping.
    pub fn intersecting_pairs(&self) -> Vec<(Entity, Entity)> {
        self.world
            .intersection_pairs()
            .filter(|(_, _, _, _, intersecting)| *intersecting)
            .filter_map(|(h1, _, h2, _, _)| {
                Some((
                    *self.collider_to_entity.get(&h1)?,
                    *self.collider_to_entity.get(&h2)?,
                ))
            })
            .collect()
    }

    // --- Scene queries -----------------------------------------------------

    /// The [`InteractionGroups`] for a query that should hit colliders on any
    /// layer in `mask`. The query "belongs to" every layer and filters by
    /// `mask`, so it selects colliders whose membership intersects `mask`.
    fn query_groups(mask: LayerMask) -> InteractionGroups {
        InteractionGroups::new(
            Group::all(),
            Group::from_bits_retain(mask.bits()),
            InteractionTestMode::And,
        )
    }

    /// Casts a ray and returns the first collider hit on a layer in `mask`.
    ///
    /// `dir` need not be normalized; `max_distance` is measured in world units.
    /// Pass [`LayerMask::ALL`] to hit anything.
    pub fn raycast(
        &self,
        origin: Vec3,
        dir: Vec3,
        max_distance: f32,
        mask: LayerMask,
    ) -> Option<RayHit> {
        let dir = dir.normalize_or_zero();
        if dir == Vec3::ZERO {
            return None;
        }
        let ray = Ray::new(to_vec(origin), to_vec(dir));
        let filter = QueryFilter::new().groups(Self::query_groups(mask));
        let (handle, intersection) =
            self.world
                .cast_ray_and_get_normal(&ray, max_distance, true, filter)?;
        let entity = *self.collider_to_entity.get(&handle)?;
        Some(RayHit {
            entity,
            toi: intersection.time_of_impact,
            point: origin + dir * intersection.time_of_impact,
            normal: from_vec(intersection.normal),
        })
    }

    /// Sweeps a sphere of `radius` from `origin` along `dir` and returns the
    /// first collider hit on a layer in `mask` (a "thick raycast").
    pub fn sphere_cast(
        &self,
        origin: Vec3,
        radius: f32,
        dir: Vec3,
        max_distance: f32,
        mask: LayerMask,
    ) -> Option<RayHit> {
        let dir = dir.normalize_or_zero();
        if dir == Vec3::ZERO {
            return None;
        }
        let shape = Ball::new(radius);
        let shape_pos = Pose::from_parts(to_vec(origin), to_quat(Quat::IDENTITY));
        let options = ShapeCastOptions::with_max_time_of_impact(max_distance);
        let filter = QueryFilter::new().groups(Self::query_groups(mask));
        let (handle, hit) =
            self.world
                .cast_shape(&shape_pos, to_vec(dir), &shape, options, filter)?;
        let entity = *self.collider_to_entity.get(&handle)?;
        Some(RayHit {
            entity,
            toi: hit.time_of_impact,
            point: from_vec(hit.witness1),
            normal: from_vec(hit.normal1),
        })
    }

    /// Every entity whose collider overlaps a sphere at `center` (on a layer in
    /// `mask`) — an overlap/proximity query.
    pub fn overlap_sphere(&self, center: Vec3, radius: f32, mask: LayerMask) -> Vec<Entity> {
        let shape = Ball::new(radius);
        let pose = Pose::from_parts(to_vec(center), to_quat(Quat::IDENTITY));
        let filter = QueryFilter::new().groups(Self::query_groups(mask));
        self.world
            .intersect_shape(pose, &shape, filter)
            .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied())
            .collect()
    }

    /// Every entity whose collider contains `point` (on a layer in `mask`) — a
    /// point/containment query.
    pub fn point_overlap(&self, point: Vec3, mask: LayerMask) -> Vec<Entity> {
        let filter = QueryFilter::new().groups(Self::query_groups(mask));
        self.world
            .intersect_point(to_vec(point), filter)
            .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied())
            .collect()
    }

    // --- Joints / constraints ----------------------------------------------

    /// Connects two entities' bodies with a joint and returns its handle.
    ///
    /// `anchor_a` / `anchor_b` are the attachment points in each body's local
    /// frame. Both entities must already be in the simulation (they are after
    /// the first step in which their components exist); returns `None` if either
    /// has no body yet. Joints created this way live in the [`PhysicsWorld`]
    /// (not the ECS), so they are recreated by game/setup code on each Play
    /// rather than restored from the scene snapshot.
    pub fn add_joint(
        &mut self,
        a: Entity,
        b: Entity,
        kind: JointKind,
        anchor_a: Vec3,
        anchor_b: Vec3,
    ) -> Option<JointId> {
        let ha = self.entity_to_body.get(&a).copied()?;
        let hb = self.entity_to_body.get(&b).copied()?;
        let (aa, ab) = (to_vec(anchor_a), to_vec(anchor_b));
        let handle = match kind {
            JointKind::Fixed => self.world.insert_impulse_joint(
                ha,
                hb,
                FixedJointBuilder::new().local_anchor1(aa).local_anchor2(ab),
            ),
            JointKind::Spherical => self.world.insert_impulse_joint(
                ha,
                hb,
                SphericalJointBuilder::new()
                    .local_anchor1(aa)
                    .local_anchor2(ab),
            ),
            JointKind::Revolute { axis } => self.world.insert_impulse_joint(
                ha,
                hb,
                RevoluteJointBuilder::new(to_vec(axis.normalize_or_zero()))
                    .local_anchor1(aa)
                    .local_anchor2(ab),
            ),
            JointKind::Prismatic { axis } => self.world.insert_impulse_joint(
                ha,
                hb,
                PrismaticJointBuilder::new(to_vec(axis.normalize_or_zero()))
                    .local_anchor1(aa)
                    .local_anchor2(ab),
            ),
        };
        Some(JointId(handle))
    }

    /// Removes a previously created joint. No-op if it was already removed.
    pub fn remove_joint(&mut self, joint: JointId) {
        self.world.remove_impulse_joint(joint.0);
    }

    /// The number of joints currently in the simulation.
    pub fn joint_count(&self) -> usize {
        self.world.impulse_joints().count()
    }

    // --- Character controller ----------------------------------------------

    /// Resolves a desired move for a kinematic capsule character against the
    /// world, returning the collision-corrected translation and grounded state.
    ///
    /// `entity` must carry a [`CharacterController`] component; its capsule is
    /// taken from there and its start pose from its world
    /// [`Transform`](oxide_engine::math::Transform). The character is **not** in
    /// the body set, so it never self-collides. The caller applies the returned
    /// [`translation`](CharacterMovement::translation) to the entity (typically
    /// `scene.set_local_transform`). Returns `None` if the entity has no
    /// controller.
    pub fn move_character(
        &self,
        scene: &Scene,
        entity: Entity,
        desired: Vec3,
        dt: f32,
    ) -> Option<CharacterMovement> {
        let cc = *scene.get::<CharacterController>(entity)?;
        let world_t = scene.world_transform(entity)?;

        let shape = Capsule::new_y(cc.half_height, cc.radius);
        let mut controller = KinematicCharacterController {
            offset: CharacterLength::Absolute(cc.skin_width.max(1.0e-3)),
            max_slope_climb_angle: cc.max_slope_degrees.to_radians(),
            ..KinematicCharacterController::default()
        };
        controller.autostep = (cc.step_offset > 0.0).then_some(CharacterAutostep {
            max_height: CharacterLength::Absolute(cc.step_offset),
            min_width: CharacterLength::Absolute(cc.radius * 0.5),
            include_dynamic_bodies: false,
        });
        controller.snap_to_ground =
            (cc.snap_to_ground > 0.0).then_some(CharacterLength::Absolute(cc.snap_to_ground));

        let pose = to_pose(&world_t);
        let queries = self.world.query_pipeline();
        let movement = controller.move_shape(dt, &queries, &shape, &pose, to_vec(desired), |_| {});

        Some(CharacterMovement {
            translation: from_vec(movement.translation),
            grounded: movement.grounded,
        })
    }

    // --- Forces & control (by entity) --------------------------------------

    /// Sets a body's linear velocity (m/s), waking it.
    pub fn set_linear_velocity(&mut self, entity: Entity, v: Vec3) {
        if let Some(b) = self.body_mut(entity) {
            b.set_linvel(to_vec(v), true);
        }
    }

    /// The body's current linear velocity, or zero if it has none.
    pub fn linear_velocity(&self, entity: Entity) -> Vec3 {
        self.body(entity)
            .map(|b| from_vec(b.linvel()))
            .unwrap_or(Vec3::ZERO)
    }

    /// Applies a one-shot impulse (mass·velocity) at the body's center, waking it.
    pub fn apply_impulse(&mut self, entity: Entity, impulse: Vec3) {
        if let Some(b) = self.body_mut(entity) {
            b.apply_impulse(to_vec(impulse), true);
        }
    }

    /// Applies a continuous force at the body's center (cleared each step), waking it.
    pub fn apply_force(&mut self, entity: Entity, force: Vec3) {
        if let Some(b) = self.body_mut(entity) {
            b.add_force(to_vec(force), true);
        }
    }

    /// Applies a one-shot angular impulse, waking the body.
    pub fn apply_torque_impulse(&mut self, entity: Entity, torque: Vec3) {
        if let Some(b) = self.body_mut(entity) {
            b.apply_torque_impulse(to_vec(torque), true);
        }
    }

    /// Wakes (or, with `false`, allows to sleep) a body.
    pub fn wake(&mut self, entity: Entity, strong: bool) {
        if let Some(h) = self.entity_to_body.get(&entity).copied() {
            self.world.wake_up(h, strong);
        }
    }

    fn body(&self, entity: Entity) -> Option<&rapier3d::dynamics::RigidBody> {
        self.entity_to_body
            .get(&entity)
            .and_then(|h| self.world.bodies.get(*h))
    }

    fn body_mut(&mut self, entity: Entity) -> Option<&mut rapier3d::dynamics::RigidBody> {
        match self.entity_to_body.get(&entity).copied() {
            Some(h) => self.world.bodies.get_mut(h),
            None => None,
        }
    }

    // --- The fixed step ----------------------------------------------------

    /// Syncs the rapier world to the scene, steps once, and writes poses back.
    fn sync_and_step(&mut self, app: &mut App) {
        let gravity = app
            .get_resource::<PhysicsSettings>()
            .map(|s| s.gravity)
            .unwrap_or(DEFAULT_GRAVITY);
        self.world.gravity = to_vec(gravity);
        self.world.integration_parameters.dt = app.time.fixed_delta;

        // Clear the event buffer once per frame, then accumulate across every
        // fixed sub-step so a consumer reading after Update sees them all.
        if self.events_frame != app.time.frame {
            self.events.clear();
            self.events_frame = app.time.frame;
        }

        self.sync_bodies(&app.scene);
        self.step_and_collect_events();
        self.write_back(&mut app.scene);
    }

    /// Steps the rapier pipeline, draining collision/trigger events into the
    /// frame buffer (translated from collider handles to entities).
    fn step_and_collect_events(&mut self) {
        let (collision_tx, collision_rx) = std::sync::mpsc::channel();
        let (force_tx, _force_rx) = std::sync::mpsc::channel();
        let collector = ChannelEventCollector::new(collision_tx, force_tx);
        self.world.step_with_events(&(), &collector);
        drop(collector); // close the senders so the iterator terminates

        for ev in collision_rx.try_iter() {
            let started = ev.started();
            let sensor = ev.sensor();
            let (h1, h2) = match ev {
                RapierCollisionEvent::Started(a, b, _) | RapierCollisionEvent::Stopped(a, b, _) => {
                    (a, b)
                }
            };
            if let (Some(&a), Some(&b)) = (
                self.collider_to_entity.get(&h1),
                self.collider_to_entity.get(&h2),
            ) {
                self.events.push(CollisionEvent {
                    a,
                    b,
                    started,
                    sensor,
                });
            }
        }
    }

    /// Rebuilds the rapier world from `scene`'s physics components and refreshes
    /// the query pipeline, leaving it ready for scene queries **without
    /// advancing the simulation**.
    ///
    /// The simulation step runs the same sync internally; expose it so tools
    /// (notably the editor's raycast probe) can query the *edited* scene outside
    /// Play. rapier's query pipeline reads from the broad phase, which only learns
    /// about colliders during a pipeline step — so after syncing we run one
    /// **`dt = 0`** step: it registers every collider's AABB for queries while
    /// integrating nothing, so authored positions are preserved. After this call
    /// [`raycast`](Self::raycast) / [`overlap_sphere`](Self::overlap_sphere) and
    /// the other queries reflect the current colliders.
    pub fn sync_to_scene(&mut self, scene: &Scene) {
        self.sync_bodies(scene);
        // Refresh the broad phase (and thus the query pipeline) without moving
        // anything: a zero-length step integrates no motion but rebuilds the
        // spatial acceleration structure the queries read from.
        self.world.integration_parameters.dt = 0.0;
        self.world.step();
    }

    /// Inserts/removes/updates rapier bodies to match the scene's physics entities.
    fn sync_bodies(&mut self, scene: &Scene) {
        // Snapshot the physics entities (collider required, body optional) so we
        // can drop the query borrow before resolving world transforms.
        let present: Vec<(Entity, Collider, Option<RigidBody>)> = scene
            .world()
            .query::<(&Collider, Option<&RigidBody>)>()
            .iter()
            .map(|(e, (c, rb))| (e, *c, rb.copied()))
            .collect();

        // Remove bodies whose entity lost its collider or was despawned.
        let live: HashSet<Entity> = present.iter().map(|(e, _, _)| *e).collect();
        let stale: Vec<Entity> = self
            .entity_to_body
            .keys()
            .copied()
            .filter(|e| !live.contains(e))
            .collect();
        for e in stale {
            if let Some(h) = self.entity_to_body.remove(&e) {
                self.body_to_entity.remove(&h);
                self.world.remove_body(h);
            }
            if let Some(ch) = self.entity_to_collider.remove(&e) {
                self.collider_to_entity.remove(&ch);
            }
        }

        for (e, col, rb) in &present {
            let world_t = scene.world_transform(*e).unwrap_or(Transform::IDENTITY);
            match self.entity_to_body.get(e).copied() {
                Some(h) => {
                    // A kinematic body is driven by its ECS transform: push the
                    // target so the step integrates toward it.
                    if matches!(rb.map(|r| r.kind), Some(RigidBodyKind::Kinematic)) {
                        if let Some(b) = self.world.bodies.get_mut(h) {
                            b.set_next_kinematic_position(to_pose(&world_t));
                        }
                    }
                }
                None => self.insert_body(*e, col, rb.as_ref(), &world_t),
            }
        }
    }

    /// Builds and inserts a rapier body + collider for a new physics entity.
    fn insert_body(&mut self, e: Entity, col: &Collider, rb: Option<&RigidBody>, t: &Transform) {
        let kind = rb.map(|r| r.kind).unwrap_or(RigidBodyKind::Static);
        let mut body = match kind {
            RigidBodyKind::Dynamic => RigidBodyBuilder::dynamic(),
            RigidBodyKind::Kinematic => RigidBodyBuilder::kinematic_position_based(),
            RigidBodyKind::Static => RigidBodyBuilder::fixed(),
        }
        .pose(to_pose(t))
        .user_data(e.to_bits().get() as u128);

        let explicit_mass = rb.map(|r| r.mass > 0.0).unwrap_or(false);
        if let Some(r) = rb {
            body = body
                .linear_damping(r.linear_damping)
                .angular_damping(r.angular_damping)
                .gravity_scale(r.gravity_scale)
                .ccd_enabled(r.ccd);
            if r.mass > 0.0 {
                body = body.additional_mass(r.mass);
            }
        }

        // When an explicit mass is set, zero the collider density so the body's
        // mass is exactly the authored value (density would otherwise add to it).
        let density = if explicit_mass { 0.0 } else { col.density };
        let collider = build_shape(col)
            .friction(col.friction)
            .restitution(col.restitution)
            .density(density)
            .sensor(col.sensor)
            // Emit started/stopped events for this collider (off by default).
            .active_events(ActiveEvents::COLLISION_EVENTS)
            .collision_groups(InteractionGroups::new(
                Group::from_bits_retain(col.membership.bits()),
                Group::from_bits_retain(col.filter.bits()),
                InteractionTestMode::And,
            ));

        let (handle, collider_handle) = self.world.insert(body.build(), collider.build());
        self.entity_to_body.insert(e, handle);
        self.body_to_entity.insert(handle, e);
        self.entity_to_collider.insert(e, collider_handle);
        self.collider_to_entity.insert(collider_handle, e);
    }

    /// Writes each moved body's pose back onto its entity's local transform.
    fn write_back(&self, scene: &mut Scene) {
        for (&e, &h) in &self.entity_to_body {
            let Some(body) = self.world.bodies.get(h) else {
                continue;
            };
            // Static bodies never move; skip the work (and the float churn).
            if body.is_fixed() {
                continue;
            }
            let pos = body.position();
            let world_t = Transform {
                translation: from_vec(pos.translation),
                rotation: from_quat(pos.rotation),
                scale: Vec3::ONE,
            };
            // Convert world → local through the parent (root: local == world).
            // Only translation/rotation are driven; the entity keeps its scale.
            let mut local = scene.local_transform(e).unwrap_or(Transform::IDENTITY);
            let new_local = match scene.parent(e).and_then(|p| scene.world_transform(p)) {
                Some(parent_world) => parent_world.inverse().mul_transform(&world_t),
                None => world_t,
            };
            local.translation = new_local.translation;
            local.rotation = new_local.rotation;
            scene.set_local_transform(e, local);
        }
    }
}

/// Builds the rapier collider shape from an Oxide [`Collider`].
fn build_shape(col: &Collider) -> ColliderBuilder {
    match col.shape {
        ColliderShape::Box => {
            ColliderBuilder::cuboid(col.half_extents.x, col.half_extents.y, col.half_extents.z)
        }
        ColliderShape::Sphere => ColliderBuilder::ball(col.radius),
        ColliderShape::Capsule => ColliderBuilder::capsule_y(col.half_height, col.radius),
        ColliderShape::Cylinder => ColliderBuilder::cylinder(col.half_height, col.radius),
    }
}

/// The [`Schedule::FixedUpdate`](oxide_engine::app::Schedule::FixedUpdate)
/// system that advances the simulation one fixed step. Takes the
/// [`PhysicsWorld`] out of the app so it can borrow the scene mutably, then
/// re-inserts it.
pub(crate) fn step_physics(app: &mut App) {
    let Some(mut world) = app.remove_resource::<PhysicsWorld>() else {
        return;
    };
    world.sync_and_step(app);
    app.insert_resource(world);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::PhysicsModule;
    use oxide_engine::app::{App, DefaultModules};
    use oxide_engine::math::Transform;

    /// Builds an app with physics and a fixed 60 Hz step, returning it ready to
    /// `step()`.
    fn physics_app() -> App {
        let mut app = App::new();
        app.add_modules(DefaultModules);
        app.add_module(PhysicsModule);
        app
    }

    fn spawn_dynamic_ball(app: &mut App, y: f32) -> Entity {
        let e = app
            .scene
            .spawn("ball", Transform::from_translation(Vec3::new(0.0, y, 0.0)));
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::default())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::ball(0.5))
            .unwrap();
        e
    }

    fn spawn_static_floor(app: &mut App) -> Entity {
        let e = app
            .scene
            .spawn("floor", Transform::from_translation(Vec3::ZERO));
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::static_body())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0)))
            .unwrap();
        e
    }

    fn y_of(app: &App, e: Entity) -> f32 {
        app.scene.local_transform(e).unwrap().translation.y
    }

    #[test]
    fn a_dropped_ball_falls() {
        let mut app = physics_app();
        let ball = spawn_dynamic_ball(&mut app, 5.0);
        let start = y_of(&app, ball);
        app.step();
        app.step();
        assert!(y_of(&app, ball) < start, "ball should fall under gravity");
    }

    #[test]
    fn a_dropped_ball_lands_on_the_floor_and_rests() {
        let mut app = physics_app();
        spawn_static_floor(&mut app);
        let ball = spawn_dynamic_ball(&mut app, 5.0);

        // ~3 s of simulation: plenty to fall 4.5 m and settle.
        for _ in 0..180 {
            app.step();
        }
        let y = y_of(&app, ball);
        // Floor top is at y=0.5, ball radius 0.5 → resting center ≈ 1.0.
        assert!(
            (y - 1.0).abs() < 0.1,
            "ball should rest on the floor at ~y=1.0, got {y}"
        );

        // And it should be (nearly) at rest.
        let world = app.get_resource::<PhysicsWorld>().unwrap();
        assert!(
            world.linear_velocity(ball).length() < 0.05,
            "ball should have come to rest"
        );
    }

    #[test]
    fn stacked_boxes_stay_stacked_without_jitter() {
        let mut app = physics_app();
        spawn_static_floor(&mut app);
        // Three unit boxes stacked: centers at 1.0, 2.0, 3.0 (floor top = 0.5,
        // half-extent 0.5 → resting centers ≈ 1.0, 2.0, 3.0).
        let mut boxes = Vec::new();
        for i in 0..3 {
            let y = 1.0 + i as f32;
            let e = app
                .scene
                .spawn("box", Transform::from_translation(Vec3::new(0.0, y, 0.0)));
            app.scene
                .world_mut()
                .insert_one(e, RigidBody::default())
                .unwrap();
            app.scene
                .world_mut()
                .insert_one(e, Collider::cuboid(Vec3::splat(0.5)))
                .unwrap();
            boxes.push((e, y));
        }
        for _ in 0..240 {
            app.step();
        }
        // Each box should remain near its starting height (stable contact, no
        // collapse, no explosion).
        for (e, y0) in boxes {
            let y = y_of(&app, e);
            assert!(
                (y - y0).abs() < 0.15,
                "box should stay stacked near y={y0}, got {y}"
            );
        }
    }

    #[test]
    fn a_static_floor_does_not_move() {
        let mut app = physics_app();
        let floor = spawn_static_floor(&mut app);
        for _ in 0..60 {
            app.step();
        }
        assert!(y_of(&app, floor).abs() < 1e-5, "static body must not move");
    }

    #[test]
    fn gravity_scale_zero_floats() {
        let mut app = physics_app();
        let e = app.scene.spawn(
            "floater",
            Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)),
        );
        app.scene
            .world_mut()
            .insert_one(
                e,
                RigidBody {
                    gravity_scale: 0.0,
                    ..RigidBody::default()
                },
            )
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::ball(0.5))
            .unwrap();
        for _ in 0..60 {
            app.step();
        }
        assert!(
            (y_of(&app, e) - 3.0).abs() < 1e-3,
            "a zero-gravity body should not fall"
        );
    }

    #[test]
    fn a_kinematic_body_ignores_gravity() {
        let mut app = physics_app();
        let e = app.scene.spawn(
            "platform",
            Transform::from_translation(Vec3::new(0.0, 2.0, 0.0)),
        );
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::kinematic())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::cuboid(Vec3::splat(0.5)))
            .unwrap();
        for _ in 0..60 {
            app.step();
        }
        assert!(
            (y_of(&app, e) - 2.0).abs() < 1e-4,
            "a kinematic body is not moved by forces"
        );
    }

    #[test]
    fn despawning_an_entity_removes_its_body() {
        let mut app = physics_app();
        let ball = spawn_dynamic_ball(&mut app, 5.0);
        app.step();
        assert_eq!(app.get_resource::<PhysicsWorld>().unwrap().body_count(), 1);

        app.scene
            .despawn(ball, oxide_engine::scene::DespawnPolicy::Recursive);
        app.step();
        assert_eq!(app.get_resource::<PhysicsWorld>().unwrap().body_count(), 0);
    }

    /// Spawns a dynamic ball on the given layer (membership = filter = layer).
    fn spawn_layered_ball(app: &mut App, pos: Vec3, layer: u32) -> Entity {
        let e = app.scene.spawn("ball", Transform::from_translation(pos));
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::default())
            .unwrap();
        let mask = oxide_engine::layer::LayerMask::layer(layer);
        app.scene
            .world_mut()
            .insert_one(e, Collider::ball(0.5).with_layers(mask, mask))
            .unwrap();
        e
    }

    #[test]
    fn matching_layers_collide_but_mismatched_layers_pass_through() {
        // Two balls dropped onto a static floor, side by side but overlapping in
        // x; if they collide they push apart, if they don't they stay overlapped.
        fn final_separation(layer_a: u32, layer_b: u32) -> f32 {
            let mut app = physics_app();
            spawn_static_floor(&mut app);
            let a = spawn_layered_ball(&mut app, Vec3::new(-0.2, 1.0, 0.0), layer_a);
            let b = spawn_layered_ball(&mut app, Vec3::new(0.2, 1.0, 0.0), layer_b);
            for _ in 0..120 {
                app.step();
            }
            let xa = app.scene.local_transform(a).unwrap().translation.x;
            let xb = app.scene.local_transform(b).unwrap().translation.x;
            (xa - xb).abs()
        }

        // Same layer → they collide and push apart (separation grows past ~1.0,
        // the sum of radii).
        assert!(
            final_separation(1, 1) > 0.9,
            "same-layer balls should collide and separate"
        );
        // Different, non-matching layers → they ignore each other and stay
        // roughly where they started (separation ≈ 0.4).
        assert!(
            final_separation(1, 2) < 0.6,
            "mismatched-layer balls should pass through each other"
        );
    }

    #[test]
    fn a_sensor_fires_a_trigger_event_and_does_not_block() {
        let mut app = physics_app();
        // A static sensor box straddling y=2.5..3.5 (center y=3, half 0.5).
        let sensor = app.scene.spawn(
            "trigger",
            Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)),
        );
        app.scene
            .world_mut()
            .insert_one(sensor, RigidBody::static_body())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(sensor, Collider::cuboid(Vec3::splat(0.5)).as_sensor())
            .unwrap();
        // A ball dropped from above the sensor.
        let ball = spawn_dynamic_ball(&mut app, 5.0);

        let mut saw_enter = false;
        let mut saw_exit = false;
        for _ in 0..180 {
            app.step();
            let world = app.get_resource::<PhysicsWorld>().unwrap();
            for ev in world.trigger_events() {
                assert!(ev.sensor);
                assert_eq!(ev.other(sensor), Some(ball));
                if ev.started {
                    saw_enter = true;
                } else {
                    saw_exit = true;
                }
            }
        }
        assert!(saw_enter, "ball should have entered the sensor");
        assert!(
            saw_exit,
            "ball should have passed through and exited the sensor"
        );
        // And it passed through — it is now well below the sensor.
        assert!(
            y_of(&app, ball) < 2.0,
            "a sensor must not block the ball, got y={}",
            y_of(&app, ball)
        );
    }

    #[test]
    fn contact_events_fire_when_a_ball_lands() {
        let mut app = physics_app();
        let floor = spawn_static_floor(&mut app);
        let ball = spawn_dynamic_ball(&mut app, 3.0);
        let mut saw_contact = false;
        for _ in 0..120 {
            app.step();
            let world = app.get_resource::<PhysicsWorld>().unwrap();
            if world
                .contact_events()
                .any(|e| e.started && e.other(ball) == Some(floor))
            {
                saw_contact = true;
            }
        }
        assert!(saw_contact, "the ball landing should fire a contact event");
        // While resting it should report as intersecting the floor ("stay").
        assert!(app
            .get_resource::<PhysicsWorld>()
            .unwrap()
            .is_intersecting(ball, floor));
    }

    #[test]
    fn a_raycast_hits_the_floor_and_reports_point_and_normal() {
        let mut app = physics_app();
        spawn_static_floor(&mut app); // top at y=0.5
        let floor = app.scene.roots()[0];
        app.step(); // register bodies

        let world = app.get_resource::<PhysicsWorld>().unwrap();
        // Cast straight down from above.
        let hit = world
            .raycast(
                Vec3::new(0.0, 5.0, 0.0),
                Vec3::new(0.0, -1.0, 0.0),
                10.0,
                LayerMask::ALL,
            )
            .expect("ray should hit the floor");
        assert_eq!(hit.entity, floor);
        // Floor top is y=0.5, so the ray travels ~4.5 m.
        assert!((hit.toi - 4.5).abs() < 0.05, "toi={}", hit.toi);
        assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y);
        // Up-facing surface.
        assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
    }

    #[test]
    fn sync_to_scene_makes_the_edited_scene_queryable_without_a_step() {
        // The editor's raycast probe queries the *edited* scene outside Play, so
        // it needs a query-able world built straight from components — no step.
        let mut app = physics_app();
        let floor = spawn_static_floor(&mut app); // top at y=0.5

        let mut world = PhysicsWorld::new();
        world.sync_to_scene(&app.scene);
        assert_eq!(world.body_count(), 1, "sync should register the floor body");

        let hit = world
            .raycast(
                Vec3::new(0.0, 5.0, 0.0),
                Vec3::new(0.0, -1.0, 0.0),
                10.0,
                LayerMask::ALL,
            )
            .expect("ray should hit the floor right after sync, with no step");
        assert_eq!(hit.entity, floor);
        assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y);
        assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
    }

    #[test]
    fn a_raycast_respects_the_layer_mask() {
        let mut app = physics_app();
        // Floor on layer 3 only.
        let e = app
            .scene
            .spawn("floor", Transform::from_translation(Vec3::ZERO));
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::static_body())
            .unwrap();
        let mask = LayerMask::layer(3);
        app.scene
            .world_mut()
            .insert_one(
                e,
                Collider::cuboid(Vec3::new(10.0, 0.5, 10.0)).with_layers(mask, mask),
            )
            .unwrap();
        app.step();

        let world = app.get_resource::<PhysicsWorld>().unwrap();
        let down = Vec3::new(0.0, -1.0, 0.0);
        // A query on layer 3 hits it; a query on layer 1 misses.
        assert!(world
            .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(3))
            .is_some());
        assert!(world
            .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(1))
            .is_none());
    }

    #[test]
    fn a_sphere_cast_hits_a_wall() {
        let mut app = physics_app();
        // A vertical wall at x=2.
        let e = app.scene.spawn(
            "wall",
            Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
        );
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::static_body())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::cuboid(Vec3::new(0.5, 5.0, 5.0)))
            .unwrap();
        app.step();

        let world = app.get_resource::<PhysicsWorld>().unwrap();
        let hit = world
            .sphere_cast(
                Vec3::ZERO,
                0.5,
                Vec3::new(1.0, 0.0, 0.0),
                10.0,
                LayerMask::ALL,
            )
            .expect("sphere should hit the wall");
        assert_eq!(hit.entity, e);
        // Wall face at x=1.5, sphere radius 0.5 → contact when center at x=1.0.
        assert!((hit.toi - 1.0).abs() < 0.05, "toi={}", hit.toi);
    }

    #[test]
    fn overlap_and_point_queries_find_colliders() {
        let mut app = physics_app();
        let e = app
            .scene
            .spawn("box", Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)));
        app.scene
            .world_mut()
            .insert_one(e, RigidBody::static_body())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::cuboid(Vec3::splat(1.0)))
            .unwrap();
        app.step();

        let world = app.get_resource::<PhysicsWorld>().unwrap();
        // A sphere overlapping the box.
        assert_eq!(
            world.overlap_sphere(Vec3::new(0.5, 0.0, 0.0), 0.5, LayerMask::ALL),
            vec![e]
        );
        // A sphere far away overlaps nothing.
        assert!(world
            .overlap_sphere(Vec3::new(10.0, 0.0, 0.0), 0.5, LayerMask::ALL)
            .is_empty());
        // The origin is inside the box.
        assert_eq!(world.point_overlap(Vec3::ZERO, LayerMask::ALL), vec![e]);
        // A point outside is not.
        assert!(world
            .point_overlap(Vec3::new(5.0, 0.0, 0.0), LayerMask::ALL)
            .is_empty());
    }

    /// Spawns a small free-floating dynamic body (tiny ball, no gravity unless
    /// asked) for joint tests, returning its entity.
    fn spawn_joint_body(app: &mut App, pos: Vec3, kind: RigidBodyKind) -> Entity {
        let e = app.scene.spawn("jb", Transform::from_translation(pos));
        let rb = RigidBody {
            kind,
            ..RigidBody::default()
        };
        app.scene.world_mut().insert_one(e, rb).unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::ball(0.1))
            .unwrap();
        e
    }

    fn pos_of(app: &App, e: Entity) -> Vec3 {
        app.scene.local_transform(e).unwrap().translation
    }

    #[test]
    fn a_fixed_joint_welds_a_body_to_a_static_anchor() {
        let mut app = physics_app();
        let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static);
        let body = spawn_joint_body(&mut app, Vec3::new(0.0, -2.0, 0.0), RigidBodyKind::Dynamic);
        app.step(); // register bodies
                    // Anchor A's local point (0,-2,0) welded to B's origin.
        app.get_resource_mut::<PhysicsWorld>()
            .unwrap()
            .add_joint(
                anchor,
                body,
                JointKind::Fixed,
                Vec3::new(0.0, -2.0, 0.0),
                Vec3::ZERO,
            )
            .expect("both bodies are registered");

        for _ in 0..120 {
            app.step();
        }
        // A fixed joint to a static anchor fully constrains B: it does not fall.
        let p = pos_of(&app, body);
        assert!(
            (p - Vec3::new(0.0, -2.0, 0.0)).length() < 0.05,
            "fixed-jointed body should stay put, got {p:?}"
        );
    }

    #[test]
    fn a_revolute_joint_keeps_a_pendulum_at_constant_radius() {
        let mut app = physics_app();
        let pivot = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static);
        let bob = spawn_joint_body(&mut app, Vec3::new(1.0, 0.0, 0.0), RigidBodyKind::Dynamic);
        app.step();
        // Hinge about Z at the world origin: A's anchor at its origin, B's anchor
        // one unit back so the two coincide at the start.
        app.get_resource_mut::<PhysicsWorld>()
            .unwrap()
            .add_joint(
                pivot,
                bob,
                JointKind::Revolute { axis: Vec3::Z },
                Vec3::ZERO,
                Vec3::new(-1.0, 0.0, 0.0),
            )
            .unwrap();

        let mut swung_down = false;
        for _ in 0..240 {
            app.step();
            let p = pos_of(&app, bob);
            // The revolute joint pins B to a circle of radius 1 about the origin.
            assert!(
                (p.length() - 1.0).abs() < 0.1,
                "pendulum radius should stay ~1, got {} at {p:?}",
                p.length()
            );
            if p.y < -0.5 {
                swung_down = true;
            }
        }
        assert!(
            swung_down,
            "the pendulum should swing downward under gravity"
        );
    }

    #[test]
    fn a_prismatic_joint_only_slides_along_its_axis() {
        let mut app = physics_app();
        let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static);
        let slider = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic);
        app.step();
        // Slide only along Y.
        app.get_resource_mut::<PhysicsWorld>()
            .unwrap()
            .add_joint(
                anchor,
                slider,
                JointKind::Prismatic { axis: Vec3::Y },
                Vec3::ZERO,
                Vec3::ZERO,
            )
            .unwrap();

        for _ in 0..120 {
            app.step();
        }
        let p = pos_of(&app, slider);
        // Gravity slides it down along Y; x/z stay pinned.
        assert!(p.y < -1.0, "slider should fall along Y, got {p:?}");
        assert!(
            p.x.abs() < 1e-3 && p.z.abs() < 1e-3,
            "off-axis drift: {p:?}"
        );
    }

    #[test]
    fn joints_can_be_removed() {
        let mut app = physics_app();
        let a = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static);
        let b = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic);
        app.step();
        let world = app.get_resource_mut::<PhysicsWorld>().unwrap();
        let j = world
            .add_joint(
                a,
                b,
                JointKind::Fixed,
                Vec3::new(0.0, -1.0, 0.0),
                Vec3::ZERO,
            )
            .unwrap();
        assert_eq!(world.joint_count(), 1);
        world.remove_joint(j);
        assert_eq!(world.joint_count(), 0);
        // With the joint gone, B falls freely.
        for _ in 0..60 {
            app.step();
        }
        assert!(pos_of(&app, b).y < -1.5, "freed body should fall");
    }

    /// Spawns a capsule character at `pos` and returns its entity.
    fn spawn_character(app: &mut App, pos: Vec3) -> Entity {
        let e = app.scene.spawn("player", Transform::from_translation(pos));
        app.scene
            .world_mut()
            .insert_one(e, CharacterController::default())
            .unwrap();
        e
    }

    /// Moves the character one step (gravity + `horizontal`) and applies the
    /// resolved translation, returning whether it ended grounded.
    fn move_character_step(app: &mut App, e: Entity, horizontal: Vec3, dt: f32) -> bool {
        let desired = horizontal + Vec3::new(0.0, -9.81 * dt, 0.0);
        let movement = {
            let world = app.get_resource::<PhysicsWorld>().unwrap();
            world.move_character(&app.scene, e, desired, dt).unwrap()
        };
        let mut t = app.scene.local_transform(e).unwrap();
        t.translation += movement.translation;
        app.scene.set_local_transform(e, t);
        movement.grounded
    }

    #[test]
    fn a_character_settles_on_the_floor_and_reports_grounded() {
        let mut app = physics_app();
        spawn_static_floor(&mut app); // top at y=0.5
                                      // Capsule total half-height 0.9 → rests with center at y≈1.4. Start above.
        let player = spawn_character(&mut app, Vec3::new(0.0, 2.0, 0.0));
        app.step(); // register the floor body

        let mut grounded = false;
        for _ in 0..120 {
            grounded = move_character_step(&mut app, player, Vec3::ZERO, 1.0 / 60.0);
        }
        let y = pos_of(&app, player).y;
        assert!(grounded, "character should be grounded on the floor");
        assert!(
            (y - 1.4).abs() < 0.1,
            "character should rest at ~y=1.4, got {y}"
        );
    }

    #[test]
    fn a_character_is_blocked_by_a_wall() {
        let mut app = physics_app();
        spawn_static_floor(&mut app);
        // A wall whose near face is at x=1.5.
        let wall = app.scene.spawn(
            "wall",
            Transform::from_translation(Vec3::new(2.0, 2.0, 0.0)),
        );
        app.scene
            .world_mut()
            .insert_one(wall, RigidBody::static_body())
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(wall, Collider::cuboid(Vec3::new(0.5, 2.0, 5.0)))
            .unwrap();
        let player = spawn_character(&mut app, Vec3::new(0.0, 1.4, 0.0));
        app.step();

        // Walk hard into the wall.
        for _ in 0..180 {
            move_character_step(&mut app, player, Vec3::new(0.1, 0.0, 0.0), 1.0 / 60.0);
        }
        let x = pos_of(&app, player).x;
        // Capsule radius 0.3 → stops with center near x=1.2; never past the face.
        assert!(
            x < 1.3,
            "character should be blocked before the wall, got x={x}"
        );
    }

    #[test]
    fn a_character_climbs_a_low_step_but_not_a_high_one() {
        // Returns the character's final height after walking +X into a step
        // whose top is at `step_top` (the floor surface is at y=0.5, so the
        // climb height is `step_top - 0.5`). 50 steps end the walk on top of a
        // climbable step (which spans x in [0.5, 3.5]) without running off the
        // floor's edge.
        fn final_height(step_top: f32) -> f32 {
            let mut app = physics_app();
            spawn_static_floor(&mut app); // top at 0.5
            let step = app.scene.spawn(
                "step",
                Transform::from_translation(Vec3::new(2.0, step_top / 2.0, 0.0)),
            );
            app.scene
                .world_mut()
                .insert_one(step, RigidBody::static_body())
                .unwrap();
            app.scene
                .world_mut()
                .insert_one(step, Collider::cuboid(Vec3::new(1.5, step_top / 2.0, 5.0)))
                .unwrap();
            let player = spawn_character(&mut app, Vec3::new(-0.5, 1.4, 0.0));
            app.step();
            for _ in 0..50 {
                move_character_step(&mut app, player, Vec3::new(0.08, 0.0, 0.0), 1.0 / 60.0);
            }
            pos_of(&app, player).y
        }

        // step_offset defaults to 0.3. A step rising 0.2 above the floor (top
        // 0.7) is climbable → the character ends up on it (center ≈ 0.7+0.9=1.6).
        let climbed = final_height(0.7);
        assert!(
            climbed > 1.5,
            "should climb the low step, ended at y={climbed}"
        );
        // A step rising 0.4 above the floor (top 0.9) exceeds the offset →
        // blocked; the character stays at floor height (center ≈ 1.4).
        let blocked = final_height(0.9);
        assert!(
            blocked < 1.5,
            "should not climb the high step, ended at y={blocked}"
        );
    }

    #[test]
    fn an_impulse_launches_a_floating_body() {
        let mut app = physics_app();
        let e = app
            .scene
            .spawn("proj", Transform::from_translation(Vec3::ZERO));
        app.scene
            .world_mut()
            .insert_one(
                e,
                RigidBody {
                    gravity_scale: 0.0,
                    ..RigidBody::default()
                },
            )
            .unwrap();
        app.scene
            .world_mut()
            .insert_one(e, Collider::ball(0.5))
            .unwrap();
        // Register the body first.
        app.step();
        app.get_resource_mut::<PhysicsWorld>()
            .unwrap()
            .apply_impulse(e, Vec3::new(0.0, 0.0, 5.0));
        for _ in 0..30 {
            app.step();
        }
        let z = app.scene.local_transform(e).unwrap().translation.z;
        assert!(z > 0.5, "impulse should move the body along +Z, got {z}");
    }
}
