// DreamAnimations — GPU skeletal animation and skinning pipeline.
//
// Provides:
// - Skeleton definition (bone hierarchy with bind poses)
// - GPU bone transform buffer (pre-allocated, write_buffer per frame)
// - Compute shader skinning (transforms vertices in-place)
// - Animation state machine (blend trees, transitions)
// - FBX animation import bridge
// - Locomotion input mapping (player input → animation blend)
//
// Clean Compute: bone buffers allocated at skeleton load, skinning dispatch
// is bounded O(vertices/64) workgroups. Zero per-frame allocation.

use crate::animation::AnimationClip;

// ── Constants ─────────────────────────────────────────────────────────

/// Maximum bones per skeleton. Matches typical glTF/FBX skeletal limits.
pub const MAX_BONES: usize = 256;

/// Workgroup size for the skinning compute shader (vertices per invocation).
const SKINNING_WORKGROUP_SIZE: u32 = 64;

/// Identity matrix (column-major 4x4).
#[rustfmt::skip]
const IDENTITY_4X4: [f32; 16] = [
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    0.0, 0.0, 0.0, 1.0,
];

// ── Skeleton ──────────────────────────────────────────────────────────

/// A single bone in a skeleton hierarchy.
#[derive(Debug, Clone)]
pub struct Bone {
    pub name: String,
    pub parent_index: Option<usize>,
    /// Inverse bind pose matrix (column-major 4x4).
    pub inverse_bind_pose: [f32; 16],
    /// Local-space rest pose translation.
    pub rest_translation: [f32; 3],
    /// Local-space rest pose rotation (quaternion xyzw).
    pub rest_rotation: [f32; 4],
    /// Local-space rest pose scale.
    pub rest_scale: [f32; 3],
}

/// A skeleton definition — bone hierarchy with bind poses.
/// Loaded once from FBX/glTF, shared across all instances of a character.
pub struct Skeleton {
    pub bones: Vec<Bone>,
    pub root_index: usize,
}

impl Skeleton {
    /// Create from FBX bone data.
    /// Computes inverse bind pose from the FBX bind pose matrices.
    pub fn from_fbx(bones: &[dreamwell_engine::fbx::FbxBone]) -> Self {
        let mut result_bones = Vec::with_capacity(bones.len());
        for fbx_bone in bones {
            // Compute inverse bind pose from FBX bind pose.
            let inv = mat4_inverse(&fbx_bone.bind_pose);
            result_bones.push(Bone {
                name: fbx_bone.name.clone(),
                parent_index: fbx_bone.parent_index,
                inverse_bind_pose: inv,
                rest_translation: [0.0, 0.0, 0.0],
                rest_rotation: [0.0, 0.0, 0.0, 1.0], // identity quaternion
                rest_scale: [1.0, 1.0, 1.0],
            });
        }
        Self {
            bones: result_bones,
            root_index: 0,
        }
    }

    /// Evaluate bone transforms at a given time in an animation clip.
    /// Returns world-space matrix palette (one mat4 per bone).
    ///
    /// Uses the clip's joint tracks for interpolation, then multiplies
    /// by the inverse bind pose to produce the final skinning matrices.
    pub fn evaluate(&self, clip: &AnimationClip, time_seconds: f32) -> Vec<[f32; 16]> {
        let time_ms = (time_seconds * 1000.0) as u32;
        let bone_count = self.bones.len();
        let mut local_transforms = vec![IDENTITY_4X4; bone_count];

        // Sample each track's keyframes at the requested time.
        for (track_idx, track) in clip.tracks.iter().enumerate() {
            if track_idx >= bone_count {
                break;
            }
            if track.keyframes.len() < 2 {
                if let Some(kf) = track.keyframes.first() {
                    // Single keyframe — use as constant.
                    local_transforms[track_idx] = trs_to_mat4(
                        &[kf.value[0], kf.value[1], kf.value[2]],
                        &[0.0, 0.0, 0.0, 1.0],
                        &[1.0, 1.0, 1.0],
                    );
                }
                continue;
            }

            // Find surrounding keyframes and interpolate.
            let local_time = if clip.looping && clip.duration_ms > 0 {
                time_ms % clip.duration_ms
            } else {
                time_ms.min(clip.duration_ms)
            };

            let mut k0 = &track.keyframes[0];
            let mut k1 = &track.keyframes[track.keyframes.len() - 1];
            for window in track.keyframes.windows(2) {
                if local_time >= window[0].time_ms && local_time <= window[1].time_ms {
                    k0 = &window[0];
                    k1 = &window[1];
                    break;
                }
            }

            let span = k1.time_ms.saturating_sub(k0.time_ms);
            let t = if span > 0 {
                (local_time.saturating_sub(k0.time_ms)) as f32 / span as f32
            } else {
                0.0
            };

            // Lerp the vec4 values (treated as translation + w).
            let mut val = [0.0f32; 4];
            for i in 0..4 {
                val[i] = k0.value[i] * (1.0 - t) + k1.value[i] * t;
            }
            local_transforms[track_idx] =
                trs_to_mat4(&[val[0], val[1], val[2]], &[0.0, 0.0, 0.0, 1.0], &[1.0, 1.0, 1.0]);
        }

        // Build world-space transforms by walking the hierarchy.
        let mut world_transforms = vec![IDENTITY_4X4; bone_count];
        for i in 0..bone_count {
            if let Some(parent) = self.bones[i].parent_index {
                if parent < i {
                    world_transforms[i] = mat4_mul(&world_transforms[parent], &local_transforms[i]);
                } else {
                    world_transforms[i] = local_transforms[i];
                }
            } else {
                world_transforms[i] = local_transforms[i];
            }
        }

        // Multiply by inverse bind pose to get final skinning matrices.
        let mut result = vec![IDENTITY_4X4; bone_count];
        for i in 0..bone_count {
            result[i] = mat4_mul(&world_transforms[i], &self.bones[i].inverse_bind_pose);
        }

        result
    }

    /// Blend between two poses by linearly interpolating each bone matrix.
    /// `factor`: 0.0 = pose_a, 1.0 = pose_b.
    pub fn blend(&self, pose_a: &[[f32; 16]], pose_b: &[[f32; 16]], factor: f32) -> Vec<[f32; 16]> {
        let len = pose_a.len().min(pose_b.len()).min(self.bones.len());
        let t = factor.clamp(0.0, 1.0);
        let one_minus_t = 1.0 - t;
        let mut result = Vec::with_capacity(len);
        for i in 0..len {
            let mut m = [0.0f32; 16];
            for j in 0..16 {
                m[j] = pose_a[i][j] * one_minus_t + pose_b[i][j] * t;
            }
            result.push(m);
        }
        result
    }

    /// Number of bones in this skeleton.
    pub fn bone_count(&self) -> usize {
        self.bones.len()
    }
}

// ── Skeleton Instance ─────────────────────────────────────────────────

/// A live skeleton instance — per-character state.
/// Holds current bone transforms (world-space) uploaded to GPU each frame.
pub struct SkeletonInstance {
    pub skeleton_index: usize,
    /// Current world-space bone transforms (matrix palette). One mat4x4 per bone.
    pub bone_transforms: Vec<[f32; 16]>,
    /// GPU buffer for bone transforms (pre-allocated at skeleton bind).
    bone_buffer: Option<wgpu::Buffer>,
    /// Current animation state.
    pub animation_state: AnimationState,
}

impl SkeletonInstance {
    /// Create a new skeleton instance bound to the given skeleton index.
    /// Allocates the GPU bone buffer immediately (Clean Compute).
    pub fn new(skeleton_index: usize, bone_count: usize, device: &wgpu::Device) -> Self {
        let bone_transforms = vec![IDENTITY_4X4; bone_count];

        let buffer_size = (bone_count * std::mem::size_of::<[f32; 16]>()) as u64;
        let bone_buffer = if buffer_size > 0 {
            Some(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("dream_animations_bones"),
                size: buffer_size,
                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }))
        } else {
            None
        };

        Self {
            skeleton_index,
            bone_transforms,
            bone_buffer,
            animation_state: AnimationState::default(),
        }
    }

    /// Upload current bone transforms to the GPU buffer.
    /// Single write_buffer call — zero allocation.
    pub fn upload(&self, queue: &wgpu::Queue) {
        if let Some(buf) = &self.bone_buffer {
            let bytes: &[u8] = bytemuck::cast_slice(&self.bone_transforms);
            queue.write_buffer(buf, 0, bytes);
        }
    }

    /// Access the GPU bone buffer for bind group creation.
    pub fn bone_buffer(&self) -> Option<&wgpu::Buffer> {
        self.bone_buffer.as_ref()
    }

    /// Explicitly destroy GPU resources.
    pub fn destroy(&mut self) {
        if let Some(buf) = self.bone_buffer.take() {
            buf.destroy();
        }
    }
}

// ── Animation State Machine ───────────────────────────────────────────

/// Animation state machine — manages blend between clips.
pub struct AnimationState {
    pub current_clip: Option<usize>,
    pub next_clip: Option<usize>,
    /// Blend factor: 0.0 = current, 1.0 = next.
    pub blend_factor: f32,
    /// Total blend duration in seconds.
    pub blend_duration: f32,
    /// Seconds elapsed into current blend transition.
    pub blend_elapsed: f32,
    /// Seconds into current clip playback.
    pub playback_time: f32,
    /// Playback speed multiplier (default: 1.0).
    pub playback_speed: f32,
    /// Whether the current clip loops.
    pub looping: bool,
}

impl Default for AnimationState {
    fn default() -> Self {
        Self {
            current_clip: None,
            next_clip: None,
            blend_factor: 0.0,
            blend_duration: 0.0,
            blend_elapsed: 0.0,
            playback_time: 0.0,
            playback_speed: 1.0,
            looping: true,
        }
    }
}

impl AnimationState {
    /// Begin playing a clip, optionally blending from the current one.
    /// If `blend_duration` > 0 and a clip is already playing, a crossfade begins.
    pub fn play(&mut self, clip_index: usize, blend_duration: f32) {
        if self.current_clip.is_some() && blend_duration > 0.0 {
            self.next_clip = Some(clip_index);
            self.blend_duration = blend_duration;
            self.blend_elapsed = 0.0;
            self.blend_factor = 0.0;
        } else {
            self.current_clip = Some(clip_index);
            self.next_clip = None;
            self.playback_time = 0.0;
            self.blend_factor = 0.0;
            self.blend_duration = 0.0;
            self.blend_elapsed = 0.0;
        }
    }

    /// Advance the animation state by `dt` seconds.
    /// `clip_durations` maps clip index → duration in seconds.
    pub fn update(&mut self, dt: f32, clip_durations: &[f32]) {
        // Advance playback time.
        self.playback_time += dt * self.playback_speed;

        // Wrap or clamp based on current clip duration.
        if let Some(idx) = self.current_clip {
            if let Some(&dur) = clip_durations.get(idx) {
                if dur > 0.0 && self.looping {
                    while self.playback_time >= dur {
                        self.playback_time -= dur;
                    }
                } else if dur > 0.0 {
                    self.playback_time = self.playback_time.min(dur);
                }
            }
        }

        // Advance blend transition.
        if self.next_clip.is_some() && self.blend_duration > 0.0 {
            self.blend_elapsed += dt;
            self.blend_factor = (self.blend_elapsed / self.blend_duration).clamp(0.0, 1.0);

            // Complete transition.
            if self.blend_elapsed >= self.blend_duration {
                self.current_clip = self.next_clip.take();
                self.playback_time = self.blend_elapsed - self.blend_duration;
                self.blend_factor = 0.0;
                self.blend_duration = 0.0;
                self.blend_elapsed = 0.0;
            }
        }
    }
}

// ── Locomotion Input ──────────────────────────────────────────────────

/// Player input mapping → animation blend weights.
/// Converts movement direction + speed into animation state transitions.
/// Supports 16 locomotion states: idle, walk, run, sprint, jump, fall,
/// crouch, crouch_walk, swim_idle, swim_forward, climb_idle, climb_up,
/// fly_idle, fly_forward, land, turn_in_place.
pub struct LocomotionInput {
    /// Normalized XZ movement direction.
    pub move_direction: [f32; 2],
    /// Movement speed: 0.0 = idle, 0.5 = walk, 1.0 = run.
    pub move_speed: f32,
    pub is_jumping: bool,
    pub is_crouching: bool,
    pub is_grounded: bool,
    pub is_swimming: bool,
    pub is_climbing: bool,
    pub is_flying: bool,
    pub is_sprinting: bool,
    /// Vertical velocity (positive = up, negative = falling).
    pub velocity_y: f32,
}

impl Default for LocomotionInput {
    fn default() -> Self {
        Self {
            move_direction: [0.0, 0.0],
            move_speed: 0.0,
            is_jumping: false,
            is_crouching: false,
            is_grounded: true,
            is_swimming: false,
            is_climbing: false,
            is_flying: false,
            is_sprinting: false,
            velocity_y: 0.0,
        }
    }
}

/// Locomotion controller — maps input to animation clips (16 states).
/// Priority: fly > climb > swim > airborne > grounded.
pub struct LocomotionController {
    // Grounded clips (original 5)
    pub idle_clip: usize,
    pub walk_clip: usize,
    pub run_clip: usize,
    pub jump_clip: usize,
    pub crouch_clip: usize,
    // Extended clips (11 new)
    pub sprint_clip: usize,
    pub fall_clip: usize,
    pub crouch_walk_clip: usize,
    pub swim_idle_clip: usize,
    pub swim_forward_clip: usize,
    pub climb_idle_clip: usize,
    pub climb_up_clip: usize,
    pub fly_idle_clip: usize,
    pub fly_forward_clip: usize,
    pub land_clip: usize,
    pub turn_in_place_clip: usize,
    /// Speed threshold below which the character is considered idle.
    pub idle_threshold: f32,
    /// Speed threshold above which the character transitions from walk to run.
    pub run_threshold: f32,
    /// Speed threshold for sprint (above run).
    pub sprint_threshold: f32,
    /// Default blend duration for locomotion transitions (seconds).
    pub blend_duration: f32,
}

impl LocomotionController {
    /// Create a locomotion controller with standard thresholds.
    /// Uses 16 clip slots; if fewer clips are available, map multiple states to the same clip index.
    pub fn new(idle_clip: usize, walk_clip: usize, run_clip: usize, jump_clip: usize, crouch_clip: usize) -> Self {
        Self {
            idle_clip,
            walk_clip,
            run_clip,
            jump_clip,
            crouch_clip,
            sprint_clip: run_clip,
            fall_clip: jump_clip,
            crouch_walk_clip: crouch_clip,
            swim_idle_clip: idle_clip,
            swim_forward_clip: walk_clip,
            climb_idle_clip: idle_clip,
            climb_up_clip: walk_clip,
            fly_idle_clip: idle_clip,
            fly_forward_clip: run_clip,
            land_clip: idle_clip,
            turn_in_place_clip: idle_clip,
            idle_threshold: 0.1,
            run_threshold: 0.7,
            sprint_threshold: 0.95,
            blend_duration: 0.2,
        }
    }

    /// Evaluate input and update animation state.
    /// Priority: fly > climb > swim > airborne > grounded.
    pub fn evaluate(&self, input: &LocomotionInput, state: &mut AnimationState) {
        let target_clip = if input.is_flying {
            // Flying: idle or forward
            if input.move_speed >= self.idle_threshold {
                self.fly_forward_clip
            } else {
                self.fly_idle_clip
            }
        } else if input.is_climbing {
            // Climbing: idle or up
            if input.move_speed >= self.idle_threshold {
                self.climb_up_clip
            } else {
                self.climb_idle_clip
            }
        } else if input.is_swimming {
            // Swimming: idle or forward
            if input.move_speed >= self.idle_threshold {
                self.swim_forward_clip
            } else {
                self.swim_idle_clip
            }
        } else if !input.is_grounded {
            // Airborne: jump or fall
            if input.velocity_y > 0.0 || input.is_jumping {
                self.jump_clip
            } else {
                self.fall_clip
            }
        } else if input.is_crouching {
            // Crouching: idle or walk
            if input.move_speed >= self.idle_threshold {
                self.crouch_walk_clip
            } else {
                self.crouch_clip
            }
        } else if input.is_sprinting && input.move_speed >= self.sprint_threshold {
            self.sprint_clip
        } else if input.move_speed >= self.run_threshold {
            self.run_clip
        } else if input.move_speed >= self.idle_threshold {
            self.walk_clip
        } else {
            self.idle_clip
        };

        // Only transition if the target clip differs from current.
        if state.current_clip != Some(target_clip) {
            state.play(target_clip, self.blend_duration);
        }
    }
}

/// FBX slot mapping for the 16 locomotion clips.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LocomotionSet {
    pub idle: FbxSlot,
    pub walk: FbxSlot,
    pub run: FbxSlot,
    pub sprint: FbxSlot,
    pub jump: FbxSlot,
    pub fall: FbxSlot,
    pub crouch: FbxSlot,
    pub crouch_walk: FbxSlot,
    pub swim_idle: FbxSlot,
    pub swim_forward: FbxSlot,
    pub climb_idle: FbxSlot,
    pub climb_up: FbxSlot,
    pub fly_idle: FbxSlot,
    pub fly_forward: FbxSlot,
    pub land: FbxSlot,
    pub turn_in_place: FbxSlot,
}

/// A reference to an FBX animation within a file.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FbxSlot {
    /// Path to the FBX file (relative to project root).
    pub path: String,
    /// Animation stack name within the FBX file.
    #[serde(default)]
    pub stack_name: Option<String>,
    /// Clip index if the file contains multiple animations.
    #[serde(default)]
    pub clip_index: usize,
}

// ── CPU Skinned Positions (for LOD fallback / physics queries) ─────────

/// Compute skinned vertex positions on the CPU using 4-bone LBS into a caller buffer.
/// Clean Compute: caller owns the output buffer, no per-frame allocation.
///
/// `bone_transforms`: world-space skinning matrices (from Skeleton::evaluate).
/// `positions`: unskinned vertex positions.
/// `weights`: per-vertex skin weights (must be same length as positions).
/// `out`: output buffer — cleared and filled with skinned positions.
pub fn compute_skinned_positions_into(
    bone_transforms: &[[f32; 16]],
    positions: &[[f32; 3]],
    weights: &[SkinWeight],
    out: &mut Vec<[f32; 3]>,
) {
    assert_eq!(positions.len(), weights.len());
    out.clear();
    out.reserve(positions.len().saturating_sub(out.capacity()));
    for (pos, w) in positions.iter().zip(weights.iter()) {
        let mut skinned = [0.0f32; 3];
        for i in 0..4 {
            let bone_idx = w.bone_indices[i] as usize;
            let weight = w.bone_weights[i];
            if weight <= 0.0 || bone_idx >= bone_transforms.len() {
                continue;
            }
            let m = &bone_transforms[bone_idx];
            skinned[0] += weight * (m[0] * pos[0] + m[4] * pos[1] + m[8] * pos[2] + m[12]);
            skinned[1] += weight * (m[1] * pos[0] + m[5] * pos[1] + m[9] * pos[2] + m[13]);
            skinned[2] += weight * (m[2] * pos[0] + m[6] * pos[1] + m[10] * pos[2] + m[14]);
        }
        out.push(skinned);
    }
}

/// Convenience allocating wrapper. Prefer `compute_skinned_positions_into` on hot paths.
pub fn compute_skinned_positions(
    bone_transforms: &[[f32; 16]],
    positions: &[[f32; 3]],
    weights: &[SkinWeight],
) -> Vec<[f32; 3]> {
    let mut out = Vec::new();
    compute_skinned_positions_into(bone_transforms, positions, weights, &mut out);
    out
}

/// Deterministic surface subsampling for physics/collision proxies.
/// Returns every `stride`-th skinned position. Stride must be >= 1.
pub fn sample_surface_positions(skinned: &[[f32; 3]], stride: usize) -> Vec<[f32; 3]> {
    let stride = stride.max(1);
    skinned.iter().step_by(stride).copied().collect()
}

// ── Skinning Pass (GPU Compute) ───────────────────────────────────────

/// Per-vertex skinning weights (4 bones max, standard format).
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinWeight {
    /// Indices into the bone transform palette.
    pub bone_indices: [u32; 4],
    /// Weights for each bone influence. Must sum to 1.0.
    pub bone_weights: [f32; 4],
}

/// GPU skinning uniforms.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinningUniforms {
    pub vertex_count: u32,
    pub bone_count: u32,
    pub _pad: [u32; 2],
}

/// GPU compute shader pass for vertex skinning.
/// Reads: bone transforms (storage), vertex positions + bone weights (storage)
/// Writes: skinned vertex positions (storage)
///
/// Clean Compute: pipeline created once at init. Per-frame cost is write_buffer
/// for bone transforms + dispatch_workgroups(ceil(vertices/64), 1, 1).
pub struct SkinningPass {
    pub enabled: bool,
    pipeline: Option<wgpu::ComputePipeline>,
    bgl: Option<wgpu::BindGroupLayout>,
    uniforms_buffer: Option<wgpu::Buffer>,
    initialized: bool,
}

impl Default for SkinningPass {
    fn default() -> Self {
        Self {
            enabled: true,
            pipeline: None,
            bgl: None,
            uniforms_buffer: None,
            initialized: false,
        }
    }
}

impl SkinningPass {
    /// Initialize the skinning compute pipeline.
    /// Call once at startup or on first use.
    pub fn init(&mut self, device: &wgpu::Device) {
        if self.initialized {
            return;
        }

        // Bind group layout:
        //   0: SkinningUniforms (uniform)
        //   1: bone transforms  (storage, read-only)
        //   2: source vertices  (storage, read-only)
        //   3: skin weights     (storage, read-only)
        //   4: output vertices  (storage, read-write)
        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("skinning_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 4,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: false },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
            ],
        });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("skinning_pipeline_layout"),
            bind_group_layouts: &[Some(&bgl)],
            immediate_size: 0,
        });

        let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("skinning_compute"),
            source: wgpu::ShaderSource::Wgsl(SKINNING_SHADER_SRC.into()),
        });

        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
            label: Some("skinning_compute"),
            layout: Some(&pipeline_layout),
            module: &shader_module,
            entry_point: Some("skinning_main"),
            compilation_options: Default::default(),
            cache: None,
        });

        let uniforms_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("skinning_uniforms"),
            size: std::mem::size_of::<SkinningUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        self.pipeline = Some(pipeline);
        self.bgl = Some(bgl);
        self.uniforms_buffer = Some(uniforms_buffer);
        self.initialized = true;
    }

    /// Dispatch the skinning compute pass.
    ///
    /// `bone_buffer`: storage buffer with bone transform matrices.
    /// `source_vertices`: storage buffer with unskinned vertex positions ([f32; 3] per vertex).
    /// `skin_weights`: storage buffer with SkinWeight per vertex.
    /// `output_vertices`: storage buffer for skinned positions (same layout as source).
    pub fn dispatch(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        uniforms: &SkinningUniforms,
        bone_buffer: &wgpu::Buffer,
        source_vertices: &wgpu::Buffer,
        skin_weights: &wgpu::Buffer,
        output_vertices: &wgpu::Buffer,
    ) {
        if !self.enabled || !self.initialized {
            return;
        }

        let Some(pipeline) = &self.pipeline else { return };
        let Some(bgl) = &self.bgl else { return };
        let Some(uniforms_buf) = &self.uniforms_buffer else {
            return;
        };

        queue.write_buffer(uniforms_buf, 0, bytemuck::bytes_of(uniforms));

        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("skinning_bg"),
            layout: bgl,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: uniforms_buf.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: bone_buffer.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: source_vertices.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: skin_weights.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 4,
                    resource: output_vertices.as_entire_binding(),
                },
            ],
        });

        let workgroups = uniforms.vertex_count.div_ceil(SKINNING_WORKGROUP_SIZE);
        let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
            label: Some("skinning_compute"),
            timestamp_writes: None,
        });
        pass.set_pipeline(pipeline);
        pass.set_bind_group(0, &bind_group, &[]);
        pass.dispatch_workgroups(workgroups, 1, 1);
    }

    /// Access the bind group layout for external bind group creation.
    pub fn bind_group_layout(&self) -> Option<&wgpu::BindGroupLayout> {
        self.bgl.as_ref()
    }

    /// Whether the pass is initialized and ready to dispatch.
    pub fn is_initialized(&self) -> bool {
        self.initialized
    }

    /// Explicitly destroy GPU resources.
    pub fn destroy(&mut self) {
        if let Some(buf) = self.uniforms_buffer.take() {
            buf.destroy();
        }
        self.pipeline = None;
        self.bgl = None;
        self.initialized = false;
    }
}

// ── FBX Bridge ────────────────────────────────────────────────────────

/// Convert an FBX animation to the engine's AnimationClip format.
///
/// FBX keyframes are per-bone with TRS components; the resulting clip
/// packs bone index as the track property and translation as the vec4 value.
pub fn fbx_animation_to_clip(anim: &dreamwell_engine::fbx::FbxAnimation, skeleton: &Skeleton) -> AnimationClip {
    use crate::animation::{AnimationTrack, Keyframe};

    let bone_count = skeleton.bones.len();
    let mut tracks = Vec::with_capacity(bone_count);

    // Group keyframes by bone index.
    let mut per_bone: Vec<Vec<&dreamwell_engine::fbx::FbxKeyframe>> = vec![Vec::new(); bone_count];
    for kf in &anim.keyframes {
        if kf.bone_index < bone_count {
            per_bone[kf.bone_index].push(kf);
        }
    }

    for (bone_idx, bone_kfs) in per_bone.iter().enumerate() {
        let mut keyframes = Vec::with_capacity(bone_kfs.len());
        for kf in bone_kfs {
            keyframes.push(Keyframe {
                time_ms: (kf.time_seconds * 1000.0) as u32,
                value: [kf.translation[0], kf.translation[1], kf.translation[2], 0.0],
            });
        }
        // Sort by time to ensure correct interpolation.
        keyframes.sort_by_key(|k| k.time_ms);

        tracks.push(AnimationTrack {
            property: format!("bone_{bone_idx}"),
            keyframes,
            tween_mode: 0, // linear
        });
    }

    AnimationClip {
        name: anim.name.clone(),
        duration_ms: (anim.duration_seconds * 1000.0) as u32,
        tracks,
        looping: true,
    }
}

// ── Matrix Math (column-major 4x4) ───────────────────────────────────

/// Multiply two column-major 4x4 matrices: result = a * b.
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
    let mut r = [0.0f32; 16];
    for col in 0..4 {
        for row in 0..4 {
            let mut sum = 0.0f32;
            for k in 0..4 {
                sum += a[k * 4 + row] * b[col * 4 + k];
            }
            r[col * 4 + row] = sum;
        }
    }
    r
}

/// Invert a column-major 4x4 matrix.
/// Falls back to identity if the matrix is singular.
fn mat4_inverse(m: &[f32; 16]) -> [f32; 16] {
    // Cofactor expansion method for 4x4 inverse.
    let a = |r: usize, c: usize| -> f32 { m[c * 4 + r] };

    let s0 = a(0, 0) * a(1, 1) - a(1, 0) * a(0, 1);
    let s1 = a(0, 0) * a(1, 2) - a(1, 0) * a(0, 2);
    let s2 = a(0, 0) * a(1, 3) - a(1, 0) * a(0, 3);
    let s3 = a(0, 1) * a(1, 2) - a(1, 1) * a(0, 2);
    let s4 = a(0, 1) * a(1, 3) - a(1, 1) * a(0, 3);
    let s5 = a(0, 2) * a(1, 3) - a(1, 2) * a(0, 3);

    let c5 = a(2, 2) * a(3, 3) - a(3, 2) * a(2, 3);
    let c4 = a(2, 1) * a(3, 3) - a(3, 1) * a(2, 3);
    let c3 = a(2, 1) * a(3, 2) - a(3, 1) * a(2, 2);
    let c2 = a(2, 0) * a(3, 3) - a(3, 0) * a(2, 3);
    let c1 = a(2, 0) * a(3, 2) - a(3, 0) * a(2, 2);
    let c0 = a(2, 0) * a(3, 1) - a(3, 0) * a(2, 1);

    let det = s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0;
    if det.abs() < 1e-12 {
        return IDENTITY_4X4;
    }
    let inv_det = 1.0 / det;

    let mut r = [0.0f32; 16];
    // Row 0
    r[0 * 4 + 0] = (a(1, 1) * c5 - a(1, 2) * c4 + a(1, 3) * c3) * inv_det;
    r[0 * 4 + 1] = (-a(0, 1) * c5 + a(0, 2) * c4 - a(0, 3) * c3) * inv_det;
    r[0 * 4 + 2] = (a(3, 1) * s5 - a(3, 2) * s4 + a(3, 3) * s3) * inv_det;
    r[0 * 4 + 3] = (-a(2, 1) * s5 + a(2, 2) * s4 - a(2, 3) * s3) * inv_det;
    // Row 1
    r[1 * 4 + 0] = (-a(1, 0) * c5 + a(1, 2) * c2 - a(1, 3) * c1) * inv_det;
    r[1 * 4 + 1] = (a(0, 0) * c5 - a(0, 2) * c2 + a(0, 3) * c1) * inv_det;
    r[1 * 4 + 2] = (-a(3, 0) * s5 + a(3, 2) * s2 - a(3, 3) * s1) * inv_det;
    r[1 * 4 + 3] = (a(2, 0) * s5 - a(2, 2) * s2 + a(2, 3) * s1) * inv_det;
    // Row 2
    r[2 * 4 + 0] = (a(1, 0) * c4 - a(1, 1) * c2 + a(1, 3) * c0) * inv_det;
    r[2 * 4 + 1] = (-a(0, 0) * c4 + a(0, 1) * c2 - a(0, 3) * c0) * inv_det;
    r[2 * 4 + 2] = (a(3, 0) * s4 - a(3, 1) * s2 + a(3, 3) * s0) * inv_det;
    r[2 * 4 + 3] = (-a(2, 0) * s4 + a(2, 1) * s2 - a(2, 3) * s0) * inv_det;
    // Row 3
    r[3 * 4 + 0] = (-a(1, 0) * c3 + a(1, 1) * c1 - a(1, 2) * c0) * inv_det;
    r[3 * 4 + 1] = (a(0, 0) * c3 - a(0, 1) * c1 + a(0, 2) * c0) * inv_det;
    r[3 * 4 + 2] = (-a(3, 0) * s3 + a(3, 1) * s1 - a(3, 2) * s0) * inv_det;
    r[3 * 4 + 3] = (a(2, 0) * s3 - a(2, 1) * s1 + a(2, 2) * s0) * inv_det;

    r
}

/// Build a column-major 4x4 matrix from translation, rotation (quaternion xyzw), and scale.
fn trs_to_mat4(t: &[f32; 3], q: &[f32; 4], s: &[f32; 3]) -> [f32; 16] {
    let (qx, qy, qz, qw) = (q[0], q[1], q[2], q[3]);

    let x2 = qx + qx;
    let y2 = qy + qy;
    let z2 = qz + qz;
    let xx = qx * x2;
    let xy = qx * y2;
    let xz = qx * z2;
    let yy = qy * y2;
    let yz = qy * z2;
    let zz = qz * z2;
    let wx = qw * x2;
    let wy = qw * y2;
    let wz = qw * z2;

    #[rustfmt::skip]
    let m = [
        (1.0 - (yy + zz)) * s[0],  (xy + wz) * s[0],          (xz - wy) * s[0],          0.0,
        (xy - wz) * s[1],          (1.0 - (xx + zz)) * s[1],  (yz + wx) * s[1],          0.0,
        (xz + wy) * s[2],          (yz - wx) * s[2],          (1.0 - (xx + yy)) * s[2],  0.0,
        t[0],                       t[1],                       t[2],                       1.0,
    ];
    m
}

// ── Embedded skinning shader ──────────────────────────────────────────

/// Embedded WGSL source for the skinning compute shader.
const SKINNING_SHADER_SRC: &str = r#"
struct SkinningUniforms {
    vertex_count: u32,
    bone_count: u32,
    _pad0: u32,
    _pad1: u32,
};

struct SkinWeight {
    bone_indices: vec4<u32>,
    bone_weights: vec4<f32>,
};

@group(0) @binding(0) var<uniform> uniforms: SkinningUniforms;
@group(0) @binding(1) var<storage, read> bones: array<mat4x4<f32>>;
@group(0) @binding(2) var<storage, read> src_positions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> weights: array<SkinWeight>;
@group(0) @binding(4) var<storage, read_write> dst_positions: array<vec4<f32>>;

@compute @workgroup_size(64)
fn skinning_main(@builtin(global_invocation_id) gid: vec3<u32>) {
    let idx = gid.x;
    if (idx >= uniforms.vertex_count) {
        return;
    }

    let pos = src_positions[idx];
    let w = weights[idx];

    // Accumulate weighted bone transforms.
    var skinned = vec4<f32>(0.0, 0.0, 0.0, 0.0);
    for (var i = 0u; i < 4u; i = i + 1u) {
        let bone_idx = w.bone_indices[i];
        let weight = w.bone_weights[i];
        if (weight > 0.0 && bone_idx < uniforms.bone_count) {
            skinned = skinned + weight * (bones[bone_idx] * pos);
        }
    }

    // Preserve w=1 for homogeneous coordinates.
    dst_positions[idx] = vec4<f32>(skinned.xyz, 1.0);
}
"#;

// ── Tests ─────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use dreamwell_engine::fbx::{FbxAnimation, FbxBone, FbxKeyframe};

    #[test]
    fn skeleton_from_fbx_three_bone_chain() {
        let bones = vec![
            FbxBone {
                name: "root".into(),
                parent_index: None,
                bind_pose: IDENTITY_4X4,
            },
            FbxBone {
                name: "spine".into(),
                parent_index: Some(0),
                bind_pose: IDENTITY_4X4,
            },
            FbxBone {
                name: "head".into(),
                parent_index: Some(1),
                bind_pose: IDENTITY_4X4,
            },
        ];
        let skel = Skeleton::from_fbx(&bones);
        assert_eq!(skel.bones.len(), 3);
        assert_eq!(skel.bones[0].name, "root");
        assert!(skel.bones[0].parent_index.is_none());
        assert_eq!(skel.bones[1].parent_index, Some(0));
        assert_eq!(skel.bones[2].parent_index, Some(1));
        assert_eq!(skel.root_index, 0);
    }

    #[test]
    fn animation_state_play_and_update() {
        let mut state = AnimationState::default();
        state.play(0, 0.0);
        assert_eq!(state.current_clip, Some(0));
        assert_eq!(state.playback_time, 0.0);

        let durations = [2.0]; // clip 0 is 2 seconds
        state.update(0.5, &durations);
        assert!((state.playback_time - 0.5).abs() < 0.001);

        state.update(0.5, &durations);
        assert!((state.playback_time - 1.0).abs() < 0.001);
    }

    #[test]
    fn animation_state_blend_transition() {
        let mut state = AnimationState::default();
        state.play(0, 0.0); // start clip 0 immediately
        state.play(1, 0.4); // blend to clip 1 over 0.4s

        assert_eq!(state.current_clip, Some(0));
        assert_eq!(state.next_clip, Some(1));
        assert!((state.blend_duration - 0.4).abs() < 0.001);

        let durations = [2.0, 2.0];
        state.update(0.2, &durations);
        assert!((state.blend_factor - 0.5).abs() < 0.001);

        state.update(0.2, &durations);
        // Blend complete — clip 1 is now current.
        assert_eq!(state.current_clip, Some(1));
        assert!(state.next_clip.is_none());
        assert!((state.blend_factor - 0.0).abs() < 0.001);
    }

    #[test]
    fn locomotion_controller_idle_walk_run() {
        let ctrl = LocomotionController::new(0, 1, 2, 3, 4);
        let mut state = AnimationState::default();

        // Idle
        let input = LocomotionInput {
            move_speed: 0.0,
            ..Default::default()
        };
        ctrl.evaluate(&input, &mut state);
        assert_eq!(state.current_clip, Some(0));

        // Walk
        let input = LocomotionInput {
            move_speed: 0.4,
            ..Default::default()
        };
        ctrl.evaluate(&input, &mut state);
        // Should start blending to walk clip (1).
        assert!(state.current_clip == Some(0) || state.current_clip == Some(1));
        // If blending, next_clip should be walk.
        if state.current_clip == Some(0) {
            assert_eq!(state.next_clip, Some(1));
        }

        // Run — force current to walk first so we can observe the transition.
        state.current_clip = Some(1);
        state.next_clip = None;
        let input = LocomotionInput {
            move_speed: 0.9,
            ..Default::default()
        };
        ctrl.evaluate(&input, &mut state);
        assert_eq!(state.next_clip, Some(2));
    }

    #[test]
    fn skin_weight_is_32_bytes() {
        assert_eq!(std::mem::size_of::<SkinWeight>(), 32);
    }

    #[test]
    fn skinning_uniforms_is_16_bytes() {
        assert_eq!(std::mem::size_of::<SkinningUniforms>(), 16);
    }

    #[test]
    fn bone_transform_evaluation_at_t0_returns_identity() {
        use crate::animation::{AnimationClip, AnimationTrack, Keyframe};

        let skel = Skeleton {
            bones: vec![Bone {
                name: "root".into(),
                parent_index: None,
                inverse_bind_pose: IDENTITY_4X4,
                rest_translation: [0.0, 0.0, 0.0],
                rest_rotation: [0.0, 0.0, 0.0, 1.0],
                rest_scale: [1.0, 1.0, 1.0],
            }],
            root_index: 0,
        };

        let clip = AnimationClip {
            name: "rest".into(),
            duration_ms: 1000,
            tracks: vec![AnimationTrack {
                property: "bone_0".into(),
                keyframes: vec![
                    Keyframe {
                        time_ms: 0,
                        value: [0.0, 0.0, 0.0, 0.0],
                    },
                    Keyframe {
                        time_ms: 1000,
                        value: [0.0, 0.0, 0.0, 0.0],
                    },
                ],
                tween_mode: 0,
            }],
            looping: false,
        };

        let result = skel.evaluate(&clip, 0.0);
        assert_eq!(result.len(), 1);
        // At t=0 with zero translation and identity inverse bind pose,
        // the result should be the identity matrix.
        for i in 0..16 {
            assert!(
                (result[0][i] - IDENTITY_4X4[i]).abs() < 0.001,
                "element {i}: got {}, expected {}",
                result[0][i],
                IDENTITY_4X4[i]
            );
        }
    }

    #[test]
    fn fbx_animation_to_clip_converts() {
        let skel = Skeleton::from_fbx(&[
            FbxBone {
                name: "root".into(),
                parent_index: None,
                bind_pose: IDENTITY_4X4,
            },
            FbxBone {
                name: "spine".into(),
                parent_index: Some(0),
                bind_pose: IDENTITY_4X4,
            },
        ]);

        let anim = FbxAnimation {
            name: "walk".into(),
            duration_seconds: 1.0,
            keyframes: vec![
                FbxKeyframe {
                    time_seconds: 0.0,
                    bone_index: 0,
                    translation: [0.0, 0.0, 0.0],
                    rotation: [0.0, 0.0, 0.0, 1.0],
                    scale: [1.0, 1.0, 1.0],
                },
                FbxKeyframe {
                    time_seconds: 1.0,
                    bone_index: 0,
                    translation: [1.0, 0.0, 0.0],
                    rotation: [0.0, 0.0, 0.0, 1.0],
                    scale: [1.0, 1.0, 1.0],
                },
                FbxKeyframe {
                    time_seconds: 0.0,
                    bone_index: 1,
                    translation: [0.0, 1.0, 0.0],
                    rotation: [0.0, 0.0, 0.0, 1.0],
                    scale: [1.0, 1.0, 1.0],
                },
            ],
        };

        let clip = fbx_animation_to_clip(&anim, &skel);
        assert_eq!(clip.name, "walk");
        assert_eq!(clip.duration_ms, 1000);
        assert_eq!(clip.tracks.len(), 2);
        assert_eq!(clip.tracks[0].keyframes.len(), 2);
        assert_eq!(clip.tracks[1].keyframes.len(), 1);
        assert!(clip.looping);
    }

    #[test]
    fn mat4_mul_identity() {
        let result = mat4_mul(&IDENTITY_4X4, &IDENTITY_4X4);
        for i in 0..16 {
            assert!((result[i] - IDENTITY_4X4[i]).abs() < 0.001);
        }
    }

    #[test]
    fn mat4_inverse_identity() {
        let inv = mat4_inverse(&IDENTITY_4X4);
        for i in 0..16 {
            assert!((inv[i] - IDENTITY_4X4[i]).abs() < 0.001);
        }
    }

    #[test]
    fn trs_identity() {
        let m = trs_to_mat4(&[0.0, 0.0, 0.0], &[0.0, 0.0, 0.0, 1.0], &[1.0, 1.0, 1.0]);
        for i in 0..16 {
            assert!(
                (m[i] - IDENTITY_4X4[i]).abs() < 0.001,
                "element {i}: got {}, expected {}",
                m[i],
                IDENTITY_4X4[i]
            );
        }
    }

    #[test]
    fn trs_translation() {
        let m = trs_to_mat4(&[3.0, 5.0, 7.0], &[0.0, 0.0, 0.0, 1.0], &[1.0, 1.0, 1.0]);
        // Translation is in column 3 (indices 12, 13, 14).
        assert!((m[12] - 3.0).abs() < 0.001);
        assert!((m[13] - 5.0).abs() < 0.001);
        assert!((m[14] - 7.0).abs() < 0.001);
    }

    #[test]
    fn blend_50_50() {
        let skel = Skeleton {
            bones: vec![Bone {
                name: "root".into(),
                parent_index: None,
                inverse_bind_pose: IDENTITY_4X4,
                rest_translation: [0.0; 3],
                rest_rotation: [0.0, 0.0, 0.0, 1.0],
                rest_scale: [1.0; 3],
            }],
            root_index: 0,
        };
        let pose_a = vec![IDENTITY_4X4];
        let mut pose_b_mat = IDENTITY_4X4;
        pose_b_mat[12] = 2.0; // translate X by 2
        let pose_b = vec![pose_b_mat];
        let blended = skel.blend(&pose_a, &pose_b, 0.5);
        assert_eq!(blended.len(), 1);
        assert!((blended[0][12] - 1.0).abs() < 0.001);
    }

    #[test]
    fn max_bones_constant() {
        assert_eq!(MAX_BONES, 256);
    }

    #[test]
    fn locomotion_16_states_reachable() {
        // Create a controller where each state maps to a unique clip index (0..15).
        let mut ctrl = LocomotionController::new(0, 1, 2, 3, 4);
        ctrl.sprint_clip = 5;
        ctrl.fall_clip = 6;
        ctrl.crouch_walk_clip = 7;
        ctrl.swim_idle_clip = 8;
        ctrl.swim_forward_clip = 9;
        ctrl.climb_idle_clip = 10;
        ctrl.climb_up_clip = 11;
        ctrl.fly_idle_clip = 12;
        ctrl.fly_forward_clip = 13;
        ctrl.land_clip = 14;
        ctrl.turn_in_place_clip = 15;

        let cases: Vec<(LocomotionInput, usize)> = vec![
            // Idle
            (
                LocomotionInput {
                    move_speed: 0.0,
                    is_grounded: true,
                    ..Default::default()
                },
                0,
            ),
            // Walk
            (
                LocomotionInput {
                    move_speed: 0.4,
                    is_grounded: true,
                    ..Default::default()
                },
                1,
            ),
            // Run
            (
                LocomotionInput {
                    move_speed: 0.8,
                    is_grounded: true,
                    ..Default::default()
                },
                2,
            ),
            // Jump
            (
                LocomotionInput {
                    is_jumping: true,
                    is_grounded: false,
                    velocity_y: 3.0,
                    ..Default::default()
                },
                3,
            ),
            // Crouch idle
            (
                LocomotionInput {
                    is_crouching: true,
                    is_grounded: true,
                    ..Default::default()
                },
                4,
            ),
            // Sprint
            (
                LocomotionInput {
                    move_speed: 1.0,
                    is_sprinting: true,
                    is_grounded: true,
                    ..Default::default()
                },
                5,
            ),
            // Fall
            (
                LocomotionInput {
                    is_grounded: false,
                    velocity_y: -2.0,
                    ..Default::default()
                },
                6,
            ),
            // Crouch walk
            (
                LocomotionInput {
                    is_crouching: true,
                    move_speed: 0.3,
                    is_grounded: true,
                    ..Default::default()
                },
                7,
            ),
            // Swim idle
            (
                LocomotionInput {
                    is_swimming: true,
                    is_grounded: true,
                    ..Default::default()
                },
                8,
            ),
            // Swim forward
            (
                LocomotionInput {
                    is_swimming: true,
                    move_speed: 0.5,
                    is_grounded: true,
                    ..Default::default()
                },
                9,
            ),
            // Climb idle
            (
                LocomotionInput {
                    is_climbing: true,
                    is_grounded: true,
                    ..Default::default()
                },
                10,
            ),
            // Climb up
            (
                LocomotionInput {
                    is_climbing: true,
                    move_speed: 0.5,
                    is_grounded: true,
                    ..Default::default()
                },
                11,
            ),
            // Fly idle
            (
                LocomotionInput {
                    is_flying: true,
                    is_grounded: true,
                    ..Default::default()
                },
                12,
            ),
            // Fly forward
            (
                LocomotionInput {
                    is_flying: true,
                    move_speed: 0.5,
                    is_grounded: true,
                    ..Default::default()
                },
                13,
            ),
        ];

        for (i, (input, expected_clip)) in cases.iter().enumerate() {
            let mut state = AnimationState::default();
            ctrl.evaluate(input, &mut state);
            assert_eq!(
                state.current_clip,
                Some(*expected_clip),
                "case {i}: expected clip {expected_clip}, got {:?}",
                state.current_clip
            );
        }
    }

    #[test]
    fn locomotion_set_serde_roundtrip() {
        let slot = FbxSlot {
            path: "anims/walk.fbx".into(),
            stack_name: Some("Take001".into()),
            clip_index: 0,
        };
        let set = LocomotionSet {
            idle: slot.clone(),
            walk: slot.clone(),
            run: slot.clone(),
            sprint: slot.clone(),
            jump: slot.clone(),
            fall: slot.clone(),
            crouch: slot.clone(),
            crouch_walk: slot.clone(),
            swim_idle: slot.clone(),
            swim_forward: slot.clone(),
            climb_idle: slot.clone(),
            climb_up: slot.clone(),
            fly_idle: slot.clone(),
            fly_forward: slot.clone(),
            land: slot.clone(),
            turn_in_place: slot,
        };
        let json = serde_json::to_string(&set).unwrap();
        let back: LocomotionSet = serde_json::from_str(&json).unwrap();
        assert_eq!(back.idle.path, "anims/walk.fbx");
        assert_eq!(back.turn_in_place.stack_name, Some("Take001".into()));
    }

    #[test]
    fn compute_skinned_positions_identity() {
        let transforms = vec![IDENTITY_4X4];
        let positions = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
        let weights = vec![
            SkinWeight {
                bone_indices: [0, 0, 0, 0],
                bone_weights: [1.0, 0.0, 0.0, 0.0],
            },
            SkinWeight {
                bone_indices: [0, 0, 0, 0],
                bone_weights: [1.0, 0.0, 0.0, 0.0],
            },
        ];
        let skinned = compute_skinned_positions(&transforms, &positions, &weights);
        assert_eq!(skinned.len(), 2);
        for i in 0..3 {
            assert!((skinned[0][i] - positions[0][i]).abs() < 0.001);
            assert!((skinned[1][i] - positions[1][i]).abs() < 0.001);
        }
    }

    #[test]
    fn compute_skinned_positions_translation() {
        let mut m = IDENTITY_4X4;
        m[12] = 10.0; // translate X by 10
        let transforms = vec![m];
        let positions = vec![[0.0, 0.0, 0.0]];
        let weights = vec![SkinWeight {
            bone_indices: [0, 0, 0, 0],
            bone_weights: [1.0, 0.0, 0.0, 0.0],
        }];
        let skinned = compute_skinned_positions(&transforms, &positions, &weights);
        assert!((skinned[0][0] - 10.0).abs() < 0.001);
    }

    #[test]
    fn compute_skinned_positions_weight_blend() {
        // Bone 0: identity, Bone 1: translate X by 10
        let mut m1 = IDENTITY_4X4;
        m1[12] = 10.0;
        let transforms = vec![IDENTITY_4X4, m1];
        let positions = vec![[0.0, 0.0, 0.0]];
        let weights = vec![SkinWeight {
            bone_indices: [0, 1, 0, 0],
            bone_weights: [0.5, 0.5, 0.0, 0.0],
        }];
        let skinned = compute_skinned_positions(&transforms, &positions, &weights);
        assert!((skinned[0][0] - 5.0).abs() < 0.001);
    }

    #[test]
    fn sample_surface_deterministic() {
        let skinned: Vec<[f32; 3]> = (0..100).map(|i| [i as f32, 0.0, 0.0]).collect();
        let sampled = sample_surface_positions(&skinned, 10);
        assert_eq!(sampled.len(), 10);
        assert!((sampled[0][0] - 0.0).abs() < 0.001);
        assert!((sampled[1][0] - 10.0).abs() < 0.001);
        assert!((sampled[9][0] - 90.0).abs() < 0.001);
    }
}
