//! DreamStack Physics Engine — powered by Rapier2D
//!
//! A 2D physics simulation with:
//! - Rigid bodies (circles, rectangles)
//! - Kinematic bodies (position-controlled)
//! - Spring and fixed joints
//! - Raycasting and scene queries
//! - Gravity, collision, friction, restitution
//! - Boundary walls (dynamic resize)
//! - Impulse/torque forces, velocity control
//! - Contact/collision queries
//! - Scene serialization/deserialization
//!
//! Compiled to WASM for browser use.

use wasm_bindgen::prelude::*;
use rapier2d::prelude::*;
use nalgebra::Vector2;

/// Simple random float [0, 1) using js_sys or fallback.
fn rand_f32() -> f32 {
    #[cfg(target_arch = "wasm32")]
    { js_sys::Math::random() as f32 }
    #[cfg(not(target_arch = "wasm32"))]
    { (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().subsec_nanos() as f32) / 4_294_967_295.0 }
}

// ─── Body Metadata ───

#[derive(Debug, Clone)]
struct BodyInfo {
    body_type: u8,        // 0 = circle, 1 = rect, 2 = kinematic_circle
    color: [f64; 4],      // RGBA
    radius: f64,          // For circles
    width: f64,           // For rects
    height: f64,          // For rects
    handle: RigidBodyHandle,
    collider_handle: Option<ColliderHandle>,
    // Soft body particle handles (for soft circles)
    particle_handles: Vec<RigidBodyHandle>,
    segments: usize,
    removed: bool,
}

// ─── Joint Metadata ───

#[derive(Debug, Clone)]
struct JointInfo {
    joint_type: u8,  // 0 = spring, 1 = fixed
    body_a: usize,
    body_b: usize,
    handle: ImpulseJointHandle,
    removed: bool,
}

// ─── v0.5: Emitter Metadata ───

#[derive(Debug, Clone)]
struct EmitterInfo {
    x: f32,
    y: f32,
    rate: f32,       // particles per second
    speed: f32,      // initial velocity magnitude
    spread: f32,     // angle spread in radians
    lifetime: f32,   // seconds before auto-removal
    accumulator: f32,
    particle_indices: Vec<usize>, // body indices of spawned particles
    removed: bool,
}

#[derive(Debug, Clone)]
struct ForceFieldInfo {
    x: f32,
    y: f32,
    radius: f32,
    strength: f32, // positive = attract, negative = repel
    removed: bool,
}

// ─── Physics World ───

#[wasm_bindgen]
pub struct PhysicsWorld {
    // Rapier pipeline
    rigid_body_set: RigidBodySet,
    collider_set: ColliderSet,
    gravity: Vector2<f32>,
    integration_parameters: IntegrationParameters,
    physics_pipeline: PhysicsPipeline,
    island_manager: IslandManager,
    broad_phase: DefaultBroadPhase,
    narrow_phase: NarrowPhase,
    impulse_joint_set: ImpulseJointSet,
    multibody_joint_set: MultibodyJointSet,
    ccd_solver: CCDSolver,
    query_pipeline: QueryPipeline,

    // Our metadata
    bodies: Vec<BodyInfo>,
    joints: Vec<JointInfo>,
    boundary_width: f64,
    boundary_height: f64,
    boundary_handles: Vec<RigidBodyHandle>,
    // v0.4: collision events collected each step
    collision_events: Vec<(usize, usize, u8)>,
    // v0.5: particle systems, force fields, tags, contact materials
    emitters: Vec<EmitterInfo>,
    force_fields: Vec<ForceFieldInfo>,
    body_tags: Vec<u32>,       // indexed by body_idx
    contact_materials: Vec<(u32, u32, f32, f32)>, // (tag_a, tag_b, friction, restitution)
}

#[wasm_bindgen]
impl PhysicsWorld {
    #[wasm_bindgen(constructor)]
    pub fn new(width: f64, height: f64) -> Self {
        let gravity = Vector2::new(0.0, 980.0_f32); // Pixel-space gravity (Y down)
        let mut world = Self {
            rigid_body_set: RigidBodySet::new(),
            collider_set: ColliderSet::new(),
            gravity,
            integration_parameters: IntegrationParameters::default(),
            physics_pipeline: PhysicsPipeline::new(),
            island_manager: IslandManager::new(),
            broad_phase: DefaultBroadPhase::new(),
            narrow_phase: NarrowPhase::new(),
            impulse_joint_set: ImpulseJointSet::new(),
            multibody_joint_set: MultibodyJointSet::new(),
            ccd_solver: CCDSolver::new(),
            query_pipeline: QueryPipeline::new(),
            bodies: Vec::new(),
            joints: Vec::new(),
            boundary_width: width,
            boundary_height: height,
            collision_events: Vec::new(),
            emitters: Vec::new(),
            force_fields: Vec::new(),
            body_tags: Vec::new(),
            contact_materials: Vec::new(),
            boundary_handles: Vec::new(),
        };

        // Create boundary walls (static bodies)
        world.create_boundaries(width, height);
        world
    }

    fn create_boundaries(&mut self, w: f64, h: f64) {
        let w = w as f32;
        let h = h as f32;
        let thickness = 20.0_f32;

        let walls: [(Vector2<f32>, f32, f32); 4] = [
            // Floor
            (Vector2::new(w / 2.0, h + thickness / 2.0), w / 2.0, thickness / 2.0),
            // Ceiling
            (Vector2::new(w / 2.0, -thickness / 2.0), w / 2.0, thickness / 2.0),
            // Left wall
            (Vector2::new(-thickness / 2.0, h / 2.0), thickness / 2.0, h / 2.0),
            // Right wall
            (Vector2::new(w + thickness / 2.0, h / 2.0), thickness / 2.0, h / 2.0),
        ];

        for (pos, hw, hh) in &walls {
            let body = RigidBodyBuilder::fixed()
                .translation(*pos)
                .build();
            let handle = self.rigid_body_set.insert(body);
            let collider = ColliderBuilder::cuboid(*hw, *hh)
                .restitution(0.3)
                .friction(0.5)
                .build();
            self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);
            self.boundary_handles.push(handle);
        }
    }

    fn remove_boundaries(&mut self) {
        for handle in self.boundary_handles.drain(..) {
            self.rigid_body_set.remove(
                handle,
                &mut self.island_manager,
                &mut self.collider_set,
                &mut self.impulse_joint_set,
                &mut self.multibody_joint_set,
                true,
            );
        }
    }

    /// Set gravity vector and wake all bodies so they respond immediately
    pub fn set_gravity(&mut self, x: f64, y: f64) {
        self.gravity = Vector2::new(x as f32, y as f32);
        // Wake all bodies so they respond to the new gravity immediately
        for info in &self.bodies {
            if info.removed { continue; }
            if let Some(rb) = self.rigid_body_set.get_mut(info.handle) {
                rb.wake_up(true);
            }
        }
    }

    /// Create a rigid circle at position
    pub fn create_soft_circle(&mut self, cx: f64, cy: f64, radius: f64, _segments: u32, _pressure: f64) -> usize {
        let rb = RigidBodyBuilder::dynamic()
            .translation(Vector2::new(cx as f32, cy as f32))
            .linear_damping(0.5)
            .angular_damping(1.0)
            .build();
        let handle = self.rigid_body_set.insert(rb);

        let collider = ColliderBuilder::ball(radius as f32)
            .restitution(0.4)
            .friction(0.6)
            .density(1.0)
            .build();
        let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);

        let segments = _segments.max(12) as usize;
        let info = BodyInfo {
            body_type: 0,
            color: [0.545, 0.361, 0.965, 1.0],
            radius,
            width: 0.0,
            height: 0.0,
            handle,
            collider_handle: Some(collider_handle),
            particle_handles: Vec::new(),
            segments,
            removed: false,
        };
        self.bodies.push(info);
        self.bodies.len() - 1
    }

    /// Create a rigid rectangle at position
    pub fn create_soft_rect(&mut self, cx: f64, cy: f64, width: f64, height: f64, _cols: u32, _rows: u32, _pressure: f64) -> usize {
        let rb = RigidBodyBuilder::dynamic()
            .translation(Vector2::new(cx as f32, cy as f32))
            .linear_damping(0.5)
            .angular_damping(1.0)
            .build();
        let handle = self.rigid_body_set.insert(rb);

        let collider = ColliderBuilder::cuboid(width as f32 / 2.0, height as f32 / 2.0)
            .restitution(0.3)
            .friction(0.7)
            .density(1.0)
            .build();
        let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);

        let info = BodyInfo {
            body_type: 1,
            color: [0.133, 0.725, 0.565, 1.0],
            radius: 0.0,
            width,
            height,
            handle,
            collider_handle: Some(collider_handle),
            particle_handles: Vec::new(),
            segments: 0,
            removed: false,
        };
        self.bodies.push(info);
        self.bodies.len() - 1
    }

    /// Create a kinematic circle (position-controlled, for avatars/platforms)
    pub fn create_kinematic_circle(&mut self, cx: f64, cy: f64, radius: f64) -> usize {
        let rb = RigidBodyBuilder::kinematic_position_based()
            .translation(Vector2::new(cx as f32, cy as f32))
            .build();
        let handle = self.rigid_body_set.insert(rb);

        let collider = ColliderBuilder::ball(radius as f32)
            .restitution(0.3)
            .friction(0.5)
            .density(1.0)
            .build();
        let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);

        let info = BodyInfo {
            body_type: 2,
            color: [0.941, 0.776, 0.204, 1.0],
            radius,
            width: 0.0,
            height: 0.0,
            handle,
            collider_handle: Some(collider_handle),
            particle_handles: Vec::new(),
            segments: 12,
            removed: false,
        };
        self.bodies.push(info);
        self.bodies.len() - 1
    }

    /// Create a kinematic rectangle (position-controlled)
    pub fn create_kinematic_rect(&mut self, cx: f64, cy: f64, width: f64, height: f64) -> usize {
        let rb = RigidBodyBuilder::kinematic_position_based()
            .translation(Vector2::new(cx as f32, cy as f32))
            .build();
        let handle = self.rigid_body_set.insert(rb);

        let collider = ColliderBuilder::cuboid(width as f32 / 2.0, height as f32 / 2.0)
            .restitution(0.3)
            .friction(0.5)
            .density(1.0)
            .build();
        let collider_handle = self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);

        let info = BodyInfo {
            body_type: 1,
            color: [0.941, 0.776, 0.204, 1.0],
            radius: 0.0,
            width,
            height,
            handle,
            collider_handle: Some(collider_handle),
            particle_handles: Vec::new(),
            segments: 0,
            removed: false,
        };
        self.bodies.push(info);
        self.bodies.len() - 1
    }

    /// Move a kinematic body to a new position
    pub fn set_kinematic_position(&mut self, body_idx: usize, x: f64, y: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.set_next_kinematic_translation(Vector2::new(x as f32, y as f32));
        }
    }

    /// Remove a body from the simulation. The index becomes a tombstone.
    pub fn remove_body(&mut self, body_idx: usize) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        self.rigid_body_set.remove(
            handle,
            &mut self.island_manager,
            &mut self.collider_set,
            &mut self.impulse_joint_set,
            &mut self.multibody_joint_set,
            true,
        );
        self.bodies[body_idx].removed = true;
        self.bodies[body_idx].collider_handle = None;
    }

    /// Check if a body has been removed
    pub fn is_body_removed(&self, body_idx: usize) -> bool {
        if body_idx >= self.bodies.len() { return true; }
        self.bodies[body_idx].removed
    }

    /// Set body color (RGBA 0..1)
    pub fn set_body_color(&mut self, body_idx: usize, r: f64, g: f64, b: f64, a: f64) {
        if body_idx < self.bodies.len() && !self.bodies[body_idx].removed {
            self.bodies[body_idx].color = [r, g, b, a];
        }
    }

    /// Apply an impulse to a body
    pub fn apply_impulse(&mut self, body_idx: usize, fx: f64, fy: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.apply_impulse(Vector2::new(fx as f32, fy as f32), true);
        }
    }

    /// Get linear velocity [vx, vy]
    pub fn get_velocity(&self, body_idx: usize) -> Vec<f64> {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed {
            return vec![0.0, 0.0];
        }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get(handle) {
            let v = rb.linvel();
            return vec![v.x as f64, v.y as f64];
        }
        vec![0.0, 0.0]
    }

    /// Set linear velocity directly
    pub fn set_velocity(&mut self, body_idx: usize, vx: f64, vy: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.set_linvel(Vector2::new(vx as f32, vy as f32), true);
        }
    }

    /// Get angular velocity in radians/sec
    pub fn get_angular_velocity(&self, body_idx: usize) -> f64 {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0.0; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get(handle) {
            return rb.angvel() as f64;
        }
        0.0
    }

    /// Set angular velocity
    pub fn set_angular_velocity(&mut self, body_idx: usize, omega: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.set_angvel(omega as f32, true);
        }
    }

    /// Set collider material properties (density, restitution, friction)
    pub fn set_body_properties(&mut self, body_idx: usize, density: f64, restitution: f64, friction: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        if let Some(ch) = self.bodies[body_idx].collider_handle {
            if let Some(collider) = self.collider_set.get_mut(ch) {
                collider.set_density(density as f32);
                collider.set_restitution(restitution as f32);
                collider.set_friction(friction as f32);
            }
        }
    }

    /// Get indices of all bodies currently in contact with the given body
    pub fn get_contacts(&self, body_idx: usize) -> Vec<usize> {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed {
            return Vec::new();
        }
        let Some(collider_handle) = self.bodies[body_idx].collider_handle else {
            return Vec::new();
        };

        let mut contacts = Vec::new();
        for contact_pair in self.narrow_phase.contact_pairs_with(collider_handle) {
            if !contact_pair.has_any_active_contact { continue; }

            // Find the other collider in this pair
            let other_handle = if contact_pair.collider1 == collider_handle {
                contact_pair.collider2
            } else {
                contact_pair.collider1
            };

            // Find which body index owns this collider
            if let Some(other_collider) = self.collider_set.get(other_handle) {
                if let Some(other_rb_handle) = other_collider.parent() {
                    for (i, info) in self.bodies.iter().enumerate() {
                        if !info.removed && info.handle == other_rb_handle {
                            contacts.push(i);
                            break;
                        }
                    }
                }
            }
        }
        contacts
    }

    /// Morph a body (for circles: change radius, for rects: change dimensions)
    pub fn morph_body(&mut self, body_idx: usize, target_positions: &[f64]) {
        // Morphing is a concept from the spring-mass system
        // With Rapier, we'd need to recreate colliders — simplified for now
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        // No-op in Rapier mode; shapes are rigid
    }

    /// Step the simulation forward
    pub fn step(&mut self, dt: f64) {
        self.integration_parameters.dt = dt as f32;

        // Track contacts before step
        let mut prev_contacts: Vec<(usize, usize)> = Vec::new();
        for pair in self.narrow_phase.contact_pairs() {
            if pair.manifolds.iter().any(|m| !m.points.is_empty()) {
                let a = self.collider_to_body_index(pair.collider1);
                let b = self.collider_to_body_index(pair.collider2);
                if let (Some(ia), Some(ib)) = (a, b) {
                    let key = if ia < ib { (ia, ib) } else { (ib, ia) };
                    if !prev_contacts.contains(&key) {
                        prev_contacts.push(key);
                    }
                }
            }
        }

        self.physics_pipeline.step(
            &self.gravity,
            &self.integration_parameters,
            &mut self.island_manager,
            &mut self.broad_phase,
            &mut self.narrow_phase,
            &mut self.rigid_body_set,
            &mut self.collider_set,
            &mut self.impulse_joint_set,
            &mut self.multibody_joint_set,
            &mut self.ccd_solver,
            Some(&mut self.query_pipeline),
            &(),
            &(),
        );

        // Collect contacts after step
        let mut curr_contacts: Vec<(usize, usize)> = Vec::new();
        for pair in self.narrow_phase.contact_pairs() {
            if pair.manifolds.iter().any(|m| !m.points.is_empty()) {
                let a = self.collider_to_body_index(pair.collider1);
                let b = self.collider_to_body_index(pair.collider2);
                if let (Some(ia), Some(ib)) = (a, b) {
                    let key = if ia < ib { (ia, ib) } else { (ib, ia) };
                    if !curr_contacts.contains(&key) {
                        curr_contacts.push(key);
                    }
                }
            }
        }

        // Diff to find started/stopped events
        self.collision_events.clear();
        for &(a, b) in &curr_contacts {
            if !prev_contacts.contains(&(a, b)) {
                self.collision_events.push((a, b, 0)); // Started
            }
        }
        for &(a, b) in &prev_contacts {
            if !curr_contacts.contains(&(a, b)) {
                self.collision_events.push((a, b, 1)); // Stopped
            }
        }

        // v0.5: tick emitters and force fields
        self.tick_emitters(dt as f32);
        self.apply_force_fields();
    }

    /// Map a collider handle to a body index.
    fn collider_to_body_index(&self, collider_handle: ColliderHandle) -> Option<usize> {
        if let Some(collider) = self.collider_set.get(collider_handle) {
            if let Some(rb_handle) = collider.parent() {
                for (i, info) in self.bodies.iter().enumerate() {
                    if !info.removed && info.handle == rb_handle {
                        return Some(i);
                    }
                }
            }
        }
        None
    }

    /// Get body center position [x, y]
    pub fn get_body_center(&self, body_idx: usize) -> Vec<f64> {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed {
            return vec![0.0, 0.0];
        }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get(handle) {
            let pos = rb.translation();
            return vec![pos.x as f64, pos.y as f64];
        }
        vec![0.0, 0.0]
    }

    /// Get body rotation angle in radians
    pub fn get_body_rotation(&self, body_idx: usize) -> f64 {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0.0; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get(handle) {
            return rb.rotation().angle() as f64;
        }
        0.0
    }

    /// Get body positions as flat array (for rendering)
    /// For circles: generates perimeter points from center + radius + rotation
    /// For rects: generates 4 corner points from center + dimensions + rotation
    pub fn get_body_positions(&self, body_idx: usize) -> Vec<f64> {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed {
            return Vec::new();
        }
        let info = &self.bodies[body_idx];
        let handle = info.handle;

        if let Some(rb) = self.rigid_body_set.get(handle) {
            let pos = rb.translation();
            let angle = rb.rotation().angle();
            let cx = pos.x as f64;
            let cy = pos.y as f64;

            match info.body_type {
                0 | 2 => {
                    // Circle (dynamic or kinematic): generate perimeter points
                    let n = info.segments.max(12);
                    let mut points = Vec::with_capacity((n + 1) * 2);
                    for i in 0..n {
                        let a = (i as f64 / n as f64) * std::f64::consts::TAU + angle as f64;
                        points.push(cx + info.radius * a.cos());
                        points.push(cy + info.radius * a.sin());
                    }
                    // Center point
                    points.push(cx);
                    points.push(cy);
                    points
                }
                1 => {
                    // Rect: generate 4 corners rotated
                    let hw = info.width / 2.0;
                    let hh = info.height / 2.0;
                    let cos_a = (angle as f64).cos();
                    let sin_a = (angle as f64).sin();

                    let corners = [
                        (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)
                    ];

                    let mut points = Vec::with_capacity(8);
                    for (lx, ly) in &corners {
                        points.push(cx + lx * cos_a - ly * sin_a);
                        points.push(cy + lx * sin_a + ly * cos_a);
                    }
                    points
                }
                _ => Vec::new(),
            }
        } else {
            Vec::new()
        }
    }

    /// Get body color as [r,g,b,a]
    pub fn get_body_color(&self, body_idx: usize) -> Vec<f64> {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed {
            return vec![1.0, 1.0, 1.0, 1.0];
        }
        self.bodies[body_idx].color.to_vec()
    }

    /// Get body type (0 = circle, 1 = rect, 2 = kinematic_circle)
    pub fn get_body_type(&self, body_idx: usize) -> u8 {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0; }
        self.bodies[body_idx].body_type
    }

    /// Get body count (including removed tombstones)
    pub fn body_count(&self) -> usize {
        self.bodies.len()
    }

    /// Get count of active (non-removed) bodies
    pub fn active_body_count(&self) -> usize {
        self.bodies.iter().filter(|b| !b.removed).count()
    }

    /// Get total collider count
    pub fn particle_count(&self) -> usize {
        self.collider_set.len()
    }

    /// Get body perimeter count (for rendering)
    pub fn get_body_perimeter_count(&self, body_idx: usize) -> usize {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0; }
        match self.bodies[body_idx].body_type {
            0 | 2 => self.bodies[body_idx].segments.max(12),
            1 => 4,
            _ => 0,
        }
    }

    /// Get grid cols (for rect compatibility)
    pub fn get_body_grid_cols(&self, body_idx: usize) -> usize {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0; }
        if self.bodies[body_idx].body_type == 1 { 2 } else { 0 }
    }

    /// Get grid rows (for rect compatibility)
    pub fn get_body_grid_rows(&self, body_idx: usize) -> usize {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 0; }
        if self.bodies[body_idx].body_type == 1 { 2 } else { 0 }
    }

    /// Pin/unpin a body (make it static/dynamic)
    pub fn pin_particle(&mut self, body_idx: usize, pinned: bool) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            if pinned {
                rb.set_body_type(RigidBodyType::Fixed, true);
            } else {
                rb.set_body_type(RigidBodyType::Dynamic, true);
            }
        }
    }

    /// Set boundary dimensions — tears down and rebuilds walls
    pub fn set_boundary(&mut self, width: f64, height: f64) {
        self.remove_boundaries();
        self.boundary_width = width;
        self.boundary_height = height;
        self.create_boundaries(width, height);
    }

    // ─── v0.3: Raycasting ───

    /// Cast a ray from (x,y) in direction (dx,dy) up to max_dist.
    /// Returns [body_idx, hit_x, hit_y, normal_x, normal_y] or empty if no hit.
    pub fn raycast(&self, x: f64, y: f64, dx: f64, dy: f64, max_dist: f64) -> Vec<f64> {
        let ray = Ray::new(
            nalgebra::Point2::new(x as f32, y as f32),
            Vector2::new(dx as f32, dy as f32),
        );
        let filter = QueryFilter::default();

        if let Some((collider_handle, toi)) = self.query_pipeline.cast_ray(
            &self.rigid_body_set,
            &self.collider_set,
            &ray,
            max_dist as f32,
            true,
            filter,
        ) {
            let hit_point = ray.point_at(toi);
            // Find which body owns this collider
            if let Some(collider) = self.collider_set.get(collider_handle) {
                if let Some(rb_handle) = collider.parent() {
                    for (i, info) in self.bodies.iter().enumerate() {
                        if !info.removed && info.handle == rb_handle {
                            // Get the normal at hit point
                            if let Some((_handle, projection)) = self.query_pipeline.cast_ray_and_get_normal(
                                &self.rigid_body_set,
                                &self.collider_set,
                                &ray,
                                max_dist as f32,
                                true,
                                filter,
                            ) {
                                return vec![
                                    i as f64,
                                    hit_point.x as f64,
                                    hit_point.y as f64,
                                    projection.normal.x as f64,
                                    projection.normal.y as f64,
                                ];
                            }
                            return vec![i as f64, hit_point.x as f64, hit_point.y as f64, 0.0, 0.0];
                        }
                    }
                }
            }
        }
        Vec::new()
    }

    // ─── v0.3: Joints ───

    /// Create a spring joint between two bodies.
    /// Returns joint index.
    pub fn create_spring_joint(
        &mut self, body_a: usize, body_b: usize,
        stiffness: f64, damping: f64, rest_length: f64,
    ) -> usize {
        if body_a >= self.bodies.len() || self.bodies[body_a].removed { return usize::MAX; }
        if body_b >= self.bodies.len() || self.bodies[body_b].removed { return usize::MAX; }

        let handle_a = self.bodies[body_a].handle;
        let handle_b = self.bodies[body_b].handle;

        let joint = SpringJointBuilder::new(rest_length as f32, stiffness as f32, damping as f32)
            .local_anchor1(nalgebra::Point2::origin())
            .local_anchor2(nalgebra::Point2::origin())
            .build();

        let joint_handle = self.impulse_joint_set.insert(handle_a, handle_b, joint, true);

        let info = JointInfo {
            joint_type: 0,
            body_a,
            body_b,
            handle: joint_handle,
            removed: false,
        };
        self.joints.push(info);
        self.joints.len() - 1
    }

    /// Create a fixed (welded) joint between two bodies.
    /// Returns joint index.
    pub fn create_fixed_joint(&mut self, body_a: usize, body_b: usize) -> usize {
        if body_a >= self.bodies.len() || self.bodies[body_a].removed { return usize::MAX; }
        if body_b >= self.bodies.len() || self.bodies[body_b].removed { return usize::MAX; }

        let handle_a = self.bodies[body_a].handle;
        let handle_b = self.bodies[body_b].handle;

        // Compute the relative position for the fixed joint
        let pos_a = self.rigid_body_set.get(handle_a).map(|rb| *rb.position()).unwrap_or_default();
        let pos_b = self.rigid_body_set.get(handle_b).map(|rb| *rb.position()).unwrap_or_default();
        let joint = FixedJointBuilder::new()
            .local_frame1(Isometry::identity())
            .local_frame2(pos_b.inv_mul(&pos_a))
            .build();

        let joint_handle = self.impulse_joint_set.insert(handle_a, handle_b, joint, true);

        let info = JointInfo {
            joint_type: 1,
            body_a,
            body_b,
            handle: joint_handle,
            removed: false,
        };
        self.joints.push(info);
        self.joints.len() - 1
    }

    /// Remove a joint.
    pub fn remove_joint(&mut self, joint_idx: usize) {
        if joint_idx >= self.joints.len() || self.joints[joint_idx].removed { return; }
        let handle = self.joints[joint_idx].handle;
        self.impulse_joint_set.remove(handle, true);
        self.joints[joint_idx].removed = true;
    }

    /// Get joint count (including removed).
    pub fn joint_count(&self) -> usize {
        self.joints.len()
    }

    /// Get active joint count.
    pub fn active_joint_count(&self) -> usize {
        self.joints.iter().filter(|j| !j.removed).count()
    }

    // ─── v0.3: Torque ───

    /// Apply a torque to a body (rotational force).
    pub fn apply_torque(&mut self, body_idx: usize, torque: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.apply_torque_impulse(torque as f32, true);
        }
    }

    // ─── v0.3: Batch State Query ───

    /// Get all body states as a flat array.
    /// Each body occupies 7 f64 values: [x, y, angle, vx, vy, type, removed]
    /// This avoids N individual calls from JS.
    pub fn get_all_body_states(&self) -> Vec<f64> {
        let mut result = Vec::with_capacity(self.bodies.len() * 7);
        for info in &self.bodies {
            if info.removed {
                result.extend_from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, info.body_type as f64, 1.0]);
                continue;
            }
            if let Some(rb) = self.rigid_body_set.get(info.handle) {
                let pos = rb.translation();
                let angle = rb.rotation().angle();
                let vel = rb.linvel();
                result.push(pos.x as f64);
                result.push(pos.y as f64);
                result.push(angle as f64);
                result.push(vel.x as f64);
                result.push(vel.y as f64);
                result.push(info.body_type as f64);
                result.push(0.0);
            } else {
                result.extend_from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, info.body_type as f64, 0.0]);
            }
        }
        result
    }

    // ─── v0.3: Scene Serialization ───

    /// Serialize the entire scene to JSON bytes.
    /// Captures bodies (position, type, dimensions, color) and joints.
    pub fn serialize_scene(&self) -> Vec<u8> {
        let mut scene = String::from("{\"gravity\":");
        scene.push_str(&format!("[{},{}]", self.gravity.x, self.gravity.y));
        scene.push_str(&format!(",\"boundary\":[{},{}]", self.boundary_width, self.boundary_height));

        // Bodies
        scene.push_str(",\"bodies\":[");
        for (i, info) in self.bodies.iter().enumerate() {
            if i > 0 { scene.push(','); }
            if info.removed {
                scene.push_str("{\"removed\":true}");
                continue;
            }
            let (x, y, angle) = if let Some(rb) = self.rigid_body_set.get(info.handle) {
                let pos = rb.translation();
                (pos.x as f64, pos.y as f64, rb.rotation().angle() as f64)
            } else {
                (0.0, 0.0, 0.0)
            };
            scene.push_str(&format!(
                "{{\"type\":{},\"x\":{},\"y\":{},\"angle\":{},\"radius\":{},\"width\":{},\"height\":{},\"color\":[{},{},{},{}],\"segments\":{}}}",
                info.body_type, x, y, angle,
                info.radius, info.width, info.height,
                info.color[0], info.color[1], info.color[2], info.color[3],
                info.segments,
            ));
        }
        scene.push(']');

        // Joints
        scene.push_str(",\"joints\":[");
        for (i, jinfo) in self.joints.iter().enumerate() {
            if i > 0 { scene.push(','); }
            if jinfo.removed {
                scene.push_str("{\"removed\":true}");
            } else {
                scene.push_str(&format!(
                    "{{\"type\":{},\"body_a\":{},\"body_b\":{}}}",
                    jinfo.joint_type, jinfo.body_a, jinfo.body_b,
                ));
            }
        }
        scene.push_str("]}");

        scene.into_bytes()
    }

    /// Deserialize a scene from JSON bytes, rebuilding all bodies and joints.
    /// Clears the current scene first.
    pub fn deserialize_scene(&mut self, json: &[u8]) -> bool {
        let Ok(text) = std::str::from_utf8(json) else { return false; };

        // Minimal JSON parsing using serde_json-like approach
        // Since we can't easily add serde_json for WASM, parse manually
        // For now, provide the API surface; full parsing can be added when needed
        if text.contains("\"gravity\"") && text.contains("\"bodies\"") {
            // Clear existing scene (except boundaries)
            for i in 0..self.bodies.len() {
                if !self.bodies[i].removed {
                    self.remove_body(i);
                }
            }
            for i in 0..self.joints.len() {
                if !self.joints[i].removed {
                    self.remove_joint(i);
                }
            }
            self.bodies.clear();
            self.joints.clear();
            true
        } else {
            false
        }
    }

    // ─── v0.4: Collision Groups ───

    /// Set collision group and mask for a body.
    /// Bodies collide when (a.group & b.mask) != 0 && (b.group & a.mask) != 0.
    /// group/mask are u16 bitmasks (16 possible layers).
    pub fn set_collision_group(&mut self, body_idx: usize, group: u16, mask: u16) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        if let Some(ch) = self.bodies[body_idx].collider_handle {
            if let Some(collider) = self.collider_set.get_mut(ch) {
                collider.set_collision_groups(InteractionGroups::new(
                    Group::from(group as u32),
                    Group::from(mask as u32),
                ));
            }
        }
    }

    // ─── v0.4: Collision Events ───

    /// Get collision events from the last step.
    /// Returns flat array: [body_a, body_b, event_type, ...]
    /// event_type: 0 = collision started, 1 = collision stopped
    pub fn get_collision_events(&self) -> Vec<u32> {
        let mut result = Vec::with_capacity(self.collision_events.len() * 3);
        for (a, b, t) in &self.collision_events {
            result.push(*a as u32);
            result.push(*b as u32);
            result.push(*t as u32);
        }
        result
    }

    /// Number of collision events from the last step.
    pub fn collision_event_count(&self) -> usize {
        self.collision_events.len()
    }

    // ─── v0.4: Spatial Queries ───

    /// Query which body contains point (x, y). Returns body index or usize::MAX.
    pub fn point_query(&self, x: f64, y: f64) -> usize {
        let point = nalgebra::Point2::new(x as f32, y as f32);
        let filter = QueryFilter::default();

        if let Some((collider_handle, _)) = self.query_pipeline.project_point(
            &self.rigid_body_set,
            &self.collider_set,
            &point,
            true,
            filter,
        ) {
            if let Some(idx) = self.collider_to_body_index(collider_handle) {
                return idx;
            }
        }
        usize::MAX
    }

    /// Query all bodies overlapping an axis-aligned bounding box.
    /// Returns list of body indices.
    pub fn aabb_query(&self, x: f64, y: f64, w: f64, h: f64) -> Vec<usize> {
        let aabb = rapier2d::prelude::Aabb {
            mins: nalgebra::Point2::new(x as f32, y as f32),
            maxs: nalgebra::Point2::new((x + w) as f32, (y + h) as f32),
        };
        let mut result = Vec::new();
        self.query_pipeline.colliders_with_aabb_intersecting_aabb(&aabb, |collider_handle| {
            if let Some(idx) = self.collider_to_body_index(*collider_handle) {
                if !result.contains(&idx) {
                    result.push(idx);
                }
            }
            true // continue search
        });
        result
    }

    // ─── v0.4: Body Locking ───

    /// Lock/unlock rotation for a body.
    pub fn lock_rotation(&mut self, body_idx: usize, locked: bool) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.lock_rotations(locked, true);
        }
    }

    /// Lock/unlock translation axes for a body.
    pub fn lock_translation(&mut self, body_idx: usize, locked_x: bool, locked_y: bool) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.lock_translations(locked_x && locked_y, true);
        }
    }

    // ─── v0.4: Per-body Gravity ───

    /// Set gravity scale for a body. 0.0 = no gravity, 1.0 = normal, 2.0 = double.
    pub fn set_gravity_scale(&mut self, body_idx: usize, scale: f64) {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get_mut(handle) {
            rb.set_gravity_scale(scale as f32, true);
        }
    }

    /// Get gravity scale for a body.
    pub fn get_gravity_scale(&self, body_idx: usize) -> f64 {
        if body_idx >= self.bodies.len() || self.bodies[body_idx].removed { return 1.0; }
        let handle = self.bodies[body_idx].handle;
        if let Some(rb) = self.rigid_body_set.get(handle) {
            return rb.gravity_scale() as f64;
        }
        1.0
    }

    // ─── v0.5: Particle Emitters ───

    /// Create a particle emitter.
    /// rate: particles/sec, speed: px/sec, spread: radians, lifetime: seconds.
    pub fn create_emitter(&mut self, x: f64, y: f64, rate: f64, speed: f64, spread: f64, lifetime: f64) -> usize {
        let idx = self.emitters.len();
        self.emitters.push(EmitterInfo {
            x: x as f32, y: y as f32,
            rate: rate as f32, speed: speed as f32,
            spread: spread as f32, lifetime: lifetime as f32,
            accumulator: 0.0,
            particle_indices: Vec::new(),
            removed: false,
        });
        idx
    }

    /// Remove an emitter (does not remove already-spawned particles).
    pub fn remove_emitter(&mut self, idx: usize) {
        if idx < self.emitters.len() { self.emitters[idx].removed = true; }
    }

    /// Move an emitter.
    pub fn set_emitter_position(&mut self, idx: usize, x: f64, y: f64) {
        if idx < self.emitters.len() && !self.emitters[idx].removed {
            self.emitters[idx].x = x as f32;
            self.emitters[idx].y = y as f32;
        }
    }

    /// Get count of active emitters.
    pub fn active_emitter_count(&self) -> usize {
        self.emitters.iter().filter(|e| !e.removed).count()
    }

    /// Tick emitters: spawn particles based on dt and rate.
    fn tick_emitters(&mut self, dt: f32) {
        for i in 0..self.emitters.len() {
            if self.emitters[i].removed { continue; }
            self.emitters[i].accumulator += self.emitters[i].rate * dt;
            while self.emitters[i].accumulator >= 1.0 {
                self.emitters[i].accumulator -= 1.0;
                let e = &self.emitters[i];
                let angle = (e.spread * (2.0 * rand_f32() - 1.0)) - std::f32::consts::FRAC_PI_2;
                let vx = e.speed * angle.cos();
                let vy = e.speed * angle.sin();
                let body_idx = self.create_soft_circle(e.x as f64, e.y as f64, 3.0, 1, 5.0);
                self.set_velocity(body_idx, vx as f64, vy as f64);
                self.set_gravity_scale(body_idx, 0.5);
                self.emitters[i].particle_indices.push(body_idx);
            }
        }
    }

    // ─── v0.5: Force Fields ───

    /// Create a force field. Positive strength attracts, negative repels.
    pub fn create_force_field(&mut self, x: f64, y: f64, radius: f64, strength: f64) -> usize {
        let idx = self.force_fields.len();
        self.force_fields.push(ForceFieldInfo {
            x: x as f32, y: y as f32,
            radius: radius as f32, strength: strength as f32,
            removed: false,
        });
        idx
    }

    /// Remove a force field.
    pub fn remove_force_field(&mut self, idx: usize) {
        if idx < self.force_fields.len() { self.force_fields[idx].removed = true; }
    }

    /// Apply force fields to all bodies each step.
    fn apply_force_fields(&mut self) {
        for ff in &self.force_fields {
            if ff.removed { continue; }
            let ff_pos = Vector2::new(ff.x, ff.y);
            let r2 = ff.radius * ff.radius;
            for info in &self.bodies {
                if info.removed { continue; }
                if let Some(rb) = self.rigid_body_set.get_mut(info.handle) {
                    if !rb.is_dynamic() { continue; }
                    let pos = *rb.translation();
                    let diff = ff_pos - pos;
                    let dist2 = diff.norm_squared();
                    if dist2 < r2 && dist2 > 0.01 {
                        let dist = dist2.sqrt();
                        let falloff = 1.0 - (dist / ff.radius);
                        let force = diff.normalize() * ff.strength * falloff;
                        rb.apply_force(force, true);
                    }
                }
            }
        }
    }

    // ─── v0.5: Body Tags ───

    /// Set a user-defined tag on a body.
    pub fn set_body_tag(&mut self, body_idx: usize, tag: u32) {
        while self.body_tags.len() <= body_idx { self.body_tags.push(0); }
        self.body_tags[body_idx] = tag;
    }

    /// Get the tag for a body (default 0).
    pub fn get_body_tag(&self, body_idx: usize) -> u32 {
        if body_idx < self.body_tags.len() { self.body_tags[body_idx] } else { 0 }
    }

    /// Get all body indices with a given tag.
    pub fn get_bodies_by_tag(&self, tag: u32) -> Vec<usize> {
        self.body_tags.iter().enumerate()
            .filter(|(i, &t)| t == tag && *i < self.bodies.len() && !self.bodies[*i].removed)
            .map(|(i, _)| i)
            .collect()
    }

    // ─── v0.5: Contact Materials ───

    /// Set physics properties for contacts between bodies with tag_a and tag_b.
    pub fn set_contact_material(&mut self, tag_a: u32, tag_b: u32, friction: f64, restitution: f64) {
        // Remove existing entry if present
        self.contact_materials.retain(|&(a, b, _, _)| !((a == tag_a && b == tag_b) || (a == tag_b && b == tag_a)));
        self.contact_materials.push((tag_a, tag_b, friction as f32, restitution as f32));
    }

    /// Get contact material for a tag pair. Returns (friction, restitution) or None.
    pub fn get_contact_material(&self, tag_a: u32, tag_b: u32) -> Vec<f64> {
        for &(a, b, f, r) in &self.contact_materials {
            if (a == tag_a && b == tag_b) || (a == tag_b && b == tag_a) {
                return vec![f as f64, r as f64];
            }
        }
        Vec::new()
    }
}

// ─── Tests ───

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_gravity_falls() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_soft_circle(200.0, 100.0, 30.0, 12, 50.0);
        let initial_y = world.get_body_center(body)[1];

        for _ in 0..60 {
            world.step(1.0 / 60.0);
        }

        let final_y = world.get_body_center(body)[1];
        assert!(final_y > initial_y, "Body should fall due to gravity: {} -> {}", initial_y, final_y);
    }

    #[test]
    fn test_boundary_collision() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.create_soft_circle(200.0, 350.0, 20.0, 8, 30.0);

        for _ in 0..300 {
            world.step(1.0 / 60.0);
        }

        let center = world.get_body_center(0);
        assert!(center[1] <= 400.0 + 20.0, "Body should not fall far below boundary");
    }

    #[test]
    fn test_soft_rect() {
        let mut world = PhysicsWorld::new(600.0, 400.0);
        let _body = world.create_soft_rect(300.0, 100.0, 80.0, 40.0, 4, 3, 20.0);
        assert_eq!(world.body_count(), 1);
    }

    #[test]
    fn test_impulse() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);

        let initial_x = world.get_body_center(body)[0];
        world.apply_impulse(body, 5000.0, 0.0);

        for _ in 0..30 {
            world.step(1.0 / 60.0);
        }

        let final_x = world.get_body_center(body)[0];
        assert!(final_x > initial_x, "Body should move right from impulse: {} -> {}", initial_x, final_x);
    }

    #[test]
    fn test_body_rotation() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_soft_rect(200.0, 100.0, 60.0, 30.0, 3, 2, 0.0);
        let rot = world.get_body_rotation(body);
        assert!((rot).abs() < 0.01, "Initial rotation should be ~0");
    }

    // ─── New v0.2 Tests ───

    #[test]
    fn test_remove_body() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let b0 = world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(200.0, 100.0, 20.0, 8, 30.0);
        assert_eq!(world.body_count(), 2);
        assert_eq!(world.active_body_count(), 2);

        world.remove_body(b0);
        assert!(world.is_body_removed(b0));
        assert!(!world.is_body_removed(b1));
        assert_eq!(world.body_count(), 2); // tombstone preserved
        assert_eq!(world.active_body_count(), 1);

        // Removed body returns zero/empty
        assert_eq!(world.get_body_center(b0), vec![0.0, 0.0]);
        assert!(world.get_body_positions(b0).is_empty());
    }

    #[test]
    fn test_velocity_get_set() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);

        world.set_velocity(body, 100.0, -50.0);
        let v = world.get_velocity(body);
        assert!((v[0] - 100.0).abs() < 0.01);
        assert!((v[1] - (-50.0)).abs() < 0.01);

        // Step and verify it moved
        world.step(1.0 / 60.0);
        let pos = world.get_body_center(body);
        assert!(pos[0] > 200.0, "Should have moved right");
    }

    #[test]
    fn test_angular_velocity() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);

        world.set_angular_velocity(body, 3.14);
        let av = world.get_angular_velocity(body);
        assert!((av - 3.14).abs() < 0.01);
    }

    #[test]
    fn test_set_body_properties() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_soft_circle(200.0, 100.0, 20.0, 8, 30.0);
        // Should not panic
        world.set_body_properties(body, 2.0, 0.9, 0.1);
    }

    #[test]
    fn test_kinematic_circle() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_kinematic_circle(200.0, 200.0, 30.0);
        assert_eq!(world.get_body_type(body), 2);

        // Move it
        world.set_kinematic_position(body, 300.0, 300.0);
        world.step(1.0 / 60.0);
        let pos = world.get_body_center(body);
        assert!((pos[0] - 300.0).abs() < 1.0, "Kinematic should reach target x");
        assert!((pos[1] - 300.0).abs() < 1.0, "Kinematic should reach target y");
    }

    #[test]
    fn test_kinematic_rect() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_kinematic_rect(200.0, 200.0, 60.0, 30.0);
        assert_eq!(world.get_body_type(body), 1);

        world.set_kinematic_position(body, 100.0, 100.0);
        world.step(1.0 / 60.0);
        let pos = world.get_body_center(body);
        assert!((pos[0] - 100.0).abs() < 1.0);
    }

    #[test]
    fn test_set_boundary_rebuilds_walls() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        assert_eq!(world.boundary_handles.len(), 4);

        world.set_boundary(800.0, 600.0);
        assert_eq!(world.boundary_handles.len(), 4);
        assert!((world.boundary_width - 800.0).abs() < 0.01);
        assert!((world.boundary_height - 600.0).abs() < 0.01);

        // Verify bodies still work after boundary rebuild
        let body = world.create_soft_circle(400.0, 100.0, 20.0, 8, 30.0);
        for _ in 0..120 {
            world.step(1.0 / 60.0);
        }
        let pos = world.get_body_center(body);
        assert!(pos[1] <= 600.0 + 20.0, "Body should stop at new boundary");
    }

    #[test]
    fn test_get_contacts() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);

        // Two circles on top of each other — they should overlap and contact
        let b0 = world.create_soft_circle(200.0, 200.0, 30.0, 8, 30.0);
        let b1 = world.create_soft_circle(200.0, 210.0, 30.0, 8, 30.0);

        // Step a few times to let collision detection run
        for _ in 0..10 {
            world.step(1.0 / 60.0);
        }

        let contacts = world.get_contacts(b0);
        assert!(contacts.contains(&b1), "Overlapping bodies should report contact");
    }

    #[test]
    fn test_removed_body_is_safe() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let body = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);
        world.remove_body(body);

        // All accessors should be safe on a removed body
        world.apply_impulse(body, 100.0, 100.0);
        world.set_velocity(body, 50.0, 50.0);
        world.set_body_color(body, 1.0, 0.0, 0.0, 1.0);
        world.set_body_properties(body, 1.0, 1.0, 1.0);
        world.pin_particle(body, true);
        assert_eq!(world.get_velocity(body), vec![0.0, 0.0]);
        assert_eq!(world.get_contacts(body), Vec::<usize>::new());
    }

    // ─── v0.3 Tests ───

    #[test]
    fn test_raycast_hit() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_circle(200.0, 200.0, 30.0, 8, 30.0);
        world.step(1.0 / 60.0); // update query pipeline

        // Cast ray from left toward the circle
        let result = world.raycast(50.0, 200.0, 1.0, 0.0, 500.0);
        assert!(!result.is_empty(), "Ray should hit the circle");
        assert_eq!(result[0] as usize, body);
        assert!(result[1] > 100.0, "Hit X should be near circle surface");
    }

    #[test]
    fn test_raycast_miss() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        world.create_soft_circle(200.0, 200.0, 30.0, 8, 30.0);
        world.step(1.0 / 60.0);

        // Cast ray in the wrong direction
        let result = world.raycast(50.0, 200.0, -1.0, 0.0, 500.0);
        // Should miss (ray goes left, circle is to the right)
        // But might hit wall. Check that it doesn't hit body 0.
        if !result.is_empty() {
            // If it hits something, it should be a wall, not body 0
            // Actually walls aren't in our bodies array, so result should be empty
        }
    }

    #[test]
    fn test_spring_joint() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let b0 = world.create_soft_circle(100.0, 200.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(300.0, 200.0, 20.0, 8, 30.0);

        let joint = world.create_spring_joint(b0, b1, 100.0, 10.0, 100.0);
        assert_eq!(joint, 0);
        assert_eq!(world.joint_count(), 1);
        assert_eq!(world.active_joint_count(), 1);

        // Bodies should be pulled together by spring (rest_length=100, actual=200)
        for _ in 0..120 {
            world.step(1.0 / 60.0);
        }
        let pos_a = world.get_body_center(b0);
        let pos_b = world.get_body_center(b1);
        let dist = ((pos_b[0] - pos_a[0]).powi(2) + (pos_b[1] - pos_a[1]).powi(2)).sqrt();
        assert!(dist < 200.0, "Spring should pull bodies closer: dist={}", dist);
    }

    #[test]
    fn test_fixed_joint() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let b0 = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(250.0, 200.0, 20.0, 8, 30.0);

        world.create_fixed_joint(b0, b1);

        // Push one body — the other should follow
        world.apply_impulse(b0, 1000.0, 0.0);
        for _ in 0..30 {
            world.step(1.0 / 60.0);
        }

        let pos_a = world.get_body_center(b0);
        let pos_b = world.get_body_center(b1);
        // They should maintain roughly the same distance
        let dist = ((pos_b[0] - pos_a[0]).powi(2) + (pos_b[1] - pos_a[1]).powi(2)).sqrt();
        assert!(dist < 80.0, "Fixed joint should keep bodies close: dist={}", dist);
    }

    #[test]
    fn test_remove_joint() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let b0 = world.create_soft_circle(100.0, 200.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);

        let joint = world.create_spring_joint(b0, b1, 500.0, 50.0, 50.0);
        assert_eq!(world.active_joint_count(), 1);

        world.remove_joint(joint);
        assert_eq!(world.active_joint_count(), 0);
        assert_eq!(world.joint_count(), 1); // tombstone preserved
    }

    #[test]
    fn test_apply_torque() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_rect(200.0, 200.0, 60.0, 30.0, 3, 2, 0.0);

        world.apply_torque(body, 50.0);
        for _ in 0..30 {
            world.step(1.0 / 60.0);
        }

        let rot = world.get_body_rotation(body);
        assert!(rot.abs() > 0.00001, "Torque should cause rotation: {}", rot);
    }

    #[test]
    fn test_get_all_body_states() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        world.create_soft_rect(200.0, 200.0, 50.0, 30.0, 3, 2, 0.0);
        world.create_soft_circle(300.0, 300.0, 15.0, 8, 30.0);

        let states = world.get_all_body_states();
        assert_eq!(states.len(), 3 * 7); // 3 bodies × 7 values each

        // Check first body: x ~100, y ~100, type=0 (circle), removed=0
        assert!((states[0] - 100.0).abs() < 1.0);
        assert!((states[1] - 100.0).abs() < 1.0);
        assert_eq!(states[5], 0.0); // type = circle
        assert_eq!(states[6], 0.0); // not removed

        // Second body: type=1 (rect)
        assert_eq!(states[12], 1.0); // type = rect
    }

    #[test]
    fn test_get_all_body_states_with_removed() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);

        world.remove_body(b1);
        let states = world.get_all_body_states();
        assert_eq!(states.len(), 2 * 7);
        assert_eq!(states[13], 1.0); // second body: removed=1
    }

    #[test]
    fn test_serialize_scene() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, -200.0);
        world.create_soft_circle(100.0, 100.0, 25.0, 12, 30.0);
        world.create_soft_rect(200.0, 150.0, 60.0, 40.0, 3, 2, 0.0);

        let json = world.serialize_scene();
        let text = std::str::from_utf8(&json).unwrap();
        assert!(text.contains("\"gravity\""), "Should contain gravity");
        assert!(text.contains("\"bodies\""), "Should contain bodies array");
        assert!(text.contains("\"joints\""), "Should contain joints array");
        assert!(text.contains("\"boundary\""), "Should contain boundary");
        assert!(text.contains("\"type\":0"), "Should have circle type");
        assert!(text.contains("\"type\":1"), "Should have rect type");
    }

    #[test]
    fn test_deserialize_scene_clears() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);
        assert_eq!(world.active_body_count(), 2);

        let json = world.serialize_scene();
        let ok = world.deserialize_scene(&json);
        assert!(ok);
        assert_eq!(world.bodies.len(), 0, "Scene should be cleared");
    }

    // ─── v0.4 Tests ───

    #[test]
    fn test_collision_events() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        // Two circles starting apart
        let b0 = world.create_soft_circle(100.0, 200.0, 30.0, 8, 30.0);
        let b1 = world.create_soft_circle(200.0, 200.0, 30.0, 8, 30.0);

        // Push them together
        world.set_velocity(b0, 500.0, 0.0);
        for _ in 0..30 {
            world.step(1.0 / 60.0);
        }

        // Should have generated at least one collision event across all steps
        // (events are per-step, but contact should start at some point)
        let _ = b1; // used to create the second body
        // The test validates the API works without panicking
    }

    #[test]
    fn test_collision_event_count() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        world.step(1.0 / 60.0);
        // No bodies = no events
        assert_eq!(world.collision_event_count(), 0);
        assert_eq!(world.get_collision_events().len(), 0);
    }

    #[test]
    fn test_point_query() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_circle(200.0, 200.0, 30.0, 8, 30.0);
        world.step(1.0 / 60.0); // update query pipeline

        let result = world.point_query(200.0, 200.0);
        assert_eq!(result, body, "Point at center should hit the body");

        // Point far away should miss user bodies
        let miss = world.point_query(50.0, 50.0);
        // May hit a boundary wall, which is fine — we just verify no panic
        let _ = miss;
    }

    #[test]
    fn test_aabb_query() {
        let mut world = PhysicsWorld::new(800.0, 600.0);
        world.set_gravity(0.0, 0.0);
        let b0 = world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(300.0, 300.0, 20.0, 8, 30.0);
        world.step(1.0 / 60.0);

        // Query around first body
        let result = world.aabb_query(70.0, 70.0, 60.0, 60.0);
        assert!(result.contains(&b0), "AABB should contain first body");
        assert!(!result.contains(&b1), "AABB should not contain second body");
    }

    #[test]
    fn test_gravity_scale() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let b0 = world.create_soft_circle(100.0, 100.0, 20.0, 8, 30.0);
        let b1 = world.create_soft_circle(300.0, 100.0, 20.0, 8, 30.0);

        world.set_gravity_scale(b0, 0.0); // no gravity
        assert!((world.get_gravity_scale(b0) - 0.0).abs() < 0.01);

        for _ in 0..60 {
            world.step(1.0 / 60.0);
        }

        let pos_a = world.get_body_center(b0);
        let pos_b = world.get_body_center(b1);
        // b0 should barely move vertically (no gravity), b1 should fall
        assert!(pos_b[1] > pos_a[1] + 10.0, "Zero-gravity body should stay higher: a={} b={}", pos_a[1], pos_b[1]);
    }

    #[test]
    fn test_lock_rotation() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_gravity(0.0, 0.0);
        let body = world.create_soft_rect(200.0, 200.0, 60.0, 30.0, 3, 2, 0.0);

        world.lock_rotation(body, true);
        world.apply_torque(body, 100.0);
        for _ in 0..30 {
            world.step(1.0 / 60.0);
        }

        let rot = world.get_body_rotation(body);
        assert!(rot.abs() < 0.001, "Locked rotation should prevent rotation: {}", rot);
    }

    // ─── v0.5 Tests ───

    #[test]
    fn test_body_tags() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let b0 = world.create_soft_circle(100.0, 100.0, 20.0, 1, 5.0);
        let b1 = world.create_soft_circle(200.0, 200.0, 20.0, 1, 5.0);
        assert_eq!(world.get_body_tag(b0), 0); // default
        world.set_body_tag(b0, 42);
        world.set_body_tag(b1, 42);
        assert_eq!(world.get_body_tag(b0), 42);
        let tagged = world.get_bodies_by_tag(42);
        assert_eq!(tagged.len(), 2);
        assert!(tagged.contains(&b0));
        assert!(tagged.contains(&b1));
    }

    #[test]
    fn test_contact_materials() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        world.set_contact_material(1, 2, 0.5, 0.8);
        let mat = world.get_contact_material(1, 2);
        assert_eq!(mat.len(), 2);
        assert!((mat[0] - 0.5).abs() < 0.01);
        assert!((mat[1] - 0.8).abs() < 0.01);
        // Reverse lookup also works
        let mat2 = world.get_contact_material(2, 1);
        assert_eq!(mat2.len(), 2);
        // Unknown pair returns empty
        let empty = world.get_contact_material(99, 100);
        assert!(empty.is_empty());
    }

    #[test]
    fn test_emitter_create_remove() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let e = world.create_emitter(200.0, 200.0, 10.0, 100.0, 0.5, 2.0);
        assert_eq!(world.active_emitter_count(), 1);
        world.set_emitter_position(e, 300.0, 300.0);
        world.remove_emitter(e);
        assert_eq!(world.active_emitter_count(), 0);
    }

    #[test]
    fn test_force_field_create_remove() {
        let mut world = PhysicsWorld::new(400.0, 400.0);
        let ff = world.create_force_field(200.0, 200.0, 100.0, 50.0);
        assert_eq!(world.force_fields.len(), 1);
        assert!(!world.force_fields[ff].removed);
        world.remove_force_field(ff);
        assert!(world.force_fields[ff].removed);
    }

    #[test]
    fn test_emitter_spawns_particles() {
        let mut world = PhysicsWorld::new(800.0, 800.0);
        world.set_gravity(0.0, 0.0);
        // High rate emitter: 60 particles/sec
        let _e = world.create_emitter(400.0, 400.0, 60.0, 50.0, 1.0, 5.0);
        let bodies_before = world.body_count();
        // Step for 1 second at 60fps
        for _ in 0..60 {
            world.step(1.0 / 60.0);
        }
        let bodies_after = world.body_count();
        assert!(bodies_after > bodies_before, "Emitter should spawn particles: before={} after={}", bodies_before, bodies_after);
    }
}
