// Dreamlet Pipeline Integration Tests — headless GPU verification suite.
//
// Tests the complete DVR (Dreamlet Virtual Rendering) pipeline:
//   - DreamletCatalog init, upload, buffer allocation
//   - Particle physics compute dispatch + readback verification
//   - Visibility cull compute dispatch + indirect buffer verification
//   - render_dvr_direct draw path resource validation
//   - MicroDreamlet compact variant
//   - Numerical precision (frustum planes, quaternion rotation, PBR BRDF)
//   - Silent failure detection (early returns in render paths)
//
// Requires a real GPU adapter. Tests skip gracefully on headless CI.
// Per CODESPEC §1.1: release profile for performance evaluation.
// Per LINTSPEC §4.6: explicit, actionable test names.

use dreamwell_gpu::dreamlet_catalog::{
    generate_particle, DreamletCatalog, GpuCollisionSphere, GpuDreamlet, ParticlePhysicsParams, DREAMLET_FLAG_ACTIVE,
    DREAMLET_FLAG_PHYSICS, DREAMLET_STATE_PARTICLE,
};
use dreamwell_gpu::primitives::PrimitiveShape;

// ── Headless GPU Helper ──────────────────────────────────────────────────

fn try_gpu() -> Option<(wgpu::Device, wgpu::Queue, wgpu::Adapter)> {
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
    let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
        power_preference: wgpu::PowerPreference::HighPerformance,
        compatible_surface: None,
        force_fallback_adapter: false,
    }))
    .ok()?;

    let (features, limits, experimental) = dreamwell_gpu::features::GpuCapabilities::device_descriptor_parts(&adapter);
    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
        label: Some("dreamlet-pipeline-test"),
        required_features: features,
        required_limits: limits,
        memory_hints: Default::default(),
        experimental_features: experimental,
        trace: Default::default(),
    }))
    .ok()?;

    Some((device, queue, adapter))
}

macro_rules! require_gpu {
    () => {
        match try_gpu() {
            Some(gpu) => gpu,
            None => {
                eprintln!("dreamlet_pipeline: no GPU adapter — skipping");
                return;
            }
        }
    };
}

/// Create a staging buffer and read back GPU storage buffer contents.
fn readback_buffer(device: &wgpu::Device, queue: &wgpu::Queue, src: &wgpu::Buffer, size: u64) -> Vec<u8> {
    let staging = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("test_readback_staging"),
        size,
        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("test_readback_copy"),
    });
    encoder.copy_buffer_to_buffer(src, 0, &staging, 0, size);
    queue.submit(std::iter::once(encoder.finish()));

    let slice = staging.slice(..);
    let (tx, rx) = std::sync::mpsc::channel();
    slice.map_async(wgpu::MapMode::Read, move |result| {
        let _ = tx.send(result);
    });
    let _ = device.poll(wgpu::PollType::wait_indefinitely());

    rx.recv()
        .expect("readback channel closed")
        .expect("readback map failed");
    let data = slice.get_mapped_range().to_vec();
    drop(staging);
    data
}

/// Generate test sphere offsets via golden spiral.
fn test_offsets(count: u32) -> Vec<[f32; 3]> {
    dreamwell_engine::input::particle::particle_sphere_offsets(count)
}

/// Build test-default ParticlePhysicsParams (128 bytes) with overridable anchor/count.
fn test_particle_params(anchor: [f32; 3], count: u32) -> ParticlePhysicsParams {
    ParticlePhysicsParams {
        anchor_position: anchor,
        dt: 1.0 / 60.0,
        dreamlet_count: count,
        spring_k: 4.0,
        repulsion_strength: 8.0,
        damping: 0.98,
        ground_y: 0.0,
        cloud_radius: 0.8,
        gravity: 0.12,
        restitution: 0.3,
        prev_anchor_position: anchor,
        cohesion_strength: 6.0,
        viscosity: 0.15,
        vorticity_epsilon: 0.8,
        rheology_blend: 0.15,
        _pad_rheology: 0.0,
        force_field_dir: [0.0; 3],
        force_field_mag: 0.0,
        fade_start: 0.56,
        fade_end: 0.8,
        particle_base_scale: 0.08,
        collision_sphere_count: 0,
        time_seconds: 0.0,
        _pad: [0.0; 3],
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §1 — Catalog Initialization
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn catalog_init_allocates_all_buffers() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 512);

    assert!(catalog.initialized, "catalog must be initialized");
    assert!(catalog.dreamlet_buffer.is_some(), "dreamlet_buffer missing");
    assert!(catalog.indirect_buffer.is_some(), "indirect_buffer missing");
    assert!(catalog.count_buffer.is_some(), "count_buffer missing");
    assert!(catalog.cull_params_buffer.is_some(), "cull_params_buffer missing");
    assert!(catalog.destruction_buffer.is_some(), "destruction_buffer missing");
    assert!(catalog.merged_mesh.is_some(), "merged_mesh missing");
    assert!(catalog.capacity >= 512, "capacity {} < requested 512", catalog.capacity);
}

#[test]
fn catalog_init_zero_capacity_rejected() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 0);
    assert!(!catalog.initialized, "zero capacity must not initialize");
}

#[test]
fn catalog_init_render_pipeline_compiles() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_render_pipeline(&device, dreamwell_gpu::formats::TEXTURE_FORMAT);

    assert!(catalog.render_pipeline_ready, "render pipeline must compile");
    assert!(catalog.render_pipeline.is_some(), "render_pipeline missing");
    assert!(catalog.render_bgl_scene.is_some(), "render_bgl_scene missing");
    assert!(catalog.render_bgl_ibl.is_some(), "render_bgl_ibl missing");
    assert!(catalog.scene_uniform_buffer.is_some(), "scene_uniform_buffer missing");
    // RT fallback bind group must be created during init_render_pipeline
    assert!(catalog.render_bg_rt.is_some(), "RT fallback bind group missing");
}

#[test]
fn catalog_merged_mesh_has_27_descriptors() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 64);

    let merged = catalog.merged_mesh.as_ref().expect("merged_mesh");
    // 9 shapes × 3 LODs = 27 descriptors
    assert_eq!(
        merged.descriptors.len(),
        PrimitiveShape::ALL.len() * 3,
        "expected {} descriptors, got {}",
        PrimitiveShape::ALL.len() * 3,
        merged.descriptors.len(),
    );
}

#[test]
fn catalog_sphere_descriptor_at_index_3() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 64);

    let merged = catalog.merged_mesh.as_ref().expect("merged_mesh");
    // PrimitiveKind::ALL = [Cube, Sphere, ...], 3 LODs each → Sphere Full LOD = 3
    let sphere_full = &merged.descriptors[3];
    assert!(sphere_full.index_count > 0, "sphere descriptor has 0 indices");
    assert!(sphere_full.index_count % 3 == 0, "sphere indices not divisible by 3");
}

// ══════════════════════════════════════════════════════════════════════════
// §2 — Dreamlet Upload & Count
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn upload_dreamlets_sets_count() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    let offsets = test_offsets(128);
    let dreamlets = generate_particle([0.0, 1.0, 0.0], 128, 1.8, [0.4, 0.6, 1.0, 0.85], &offsets);
    catalog.upload(&queue, &dreamlets);

    assert_eq!(catalog.count, 128, "count must be 128 after upload");
}

#[test]
fn upload_clamps_to_capacity() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 64); // small capacity

    let offsets = test_offsets(128);
    let dreamlets = generate_particle([0.0, 0.0, 0.0], 128, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    assert!(catalog.count <= 64, "count {} exceeds capacity 64", catalog.count);
}

#[test]
fn upload_empty_slice_preserves_count() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    let offsets = test_offsets(32);
    let dreamlets = generate_particle([0.0, 0.0, 0.0], 32, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);
    assert_eq!(catalog.count, 32);

    // Upload empty — should not reset count
    catalog.upload(&queue, &[]);
    assert_eq!(catalog.count, 32, "empty upload must not reset count");
}

// ══════════════════════════════════════════════════════════════════════════
// §3 — Particle Physics Compute Dispatch
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn particle_physics_pipeline_compiles() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    assert!(catalog.particle_physics_ready, "particle physics pipeline must compile");
    assert!(
        catalog.particle_physics_pipeline.is_some(),
        "particle physics pipeline missing"
    );
    assert!(
        catalog.particle_params_buffer.is_some(),
        "particle params buffer missing"
    );
}

#[test]
fn particle_physics_dispatch_updates_positions() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Upload 16 dreamlets at origin
    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 5.0, 0.0], 16, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Record initial positions
    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let before = readback_buffer(&device, &queue, buf, buf_size);
    let before_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&before);
    let initial_y: Vec<f32> = before_dreamlets.iter().map(|d| d.position[1]).collect();

    // Dispatch physics with gravity for 10 frames
    for _ in 0..10 {
        let params = ParticlePhysicsParams {
            gravity: 1.2,
            cloud_radius: 1.8,
            ..test_particle_params([0.0, 5.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);

        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("particle_physics_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    // Readback updated positions — gravity should have moved particles
    let after = readback_buffer(&device, &queue, buf, buf_size);
    let after_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&after);

    let mut any_moved = false;
    for (i, (before_y, after_d)) in initial_y.iter().zip(after_dreamlets.iter()).enumerate() {
        let delta = (after_d.position[1] - before_y).abs();
        if delta > 0.001 {
            any_moved = true;
        }
        // Verify particle didn't teleport to NaN or infinity
        assert!(
            after_d.position[0].is_finite() && after_d.position[1].is_finite() && after_d.position[2].is_finite(),
            "dreamlet {} position is NaN/Inf: {:?}",
            i,
            after_d.position
        );
    }
    assert!(
        any_moved,
        "particle physics must move at least one particle (gravity active)"
    );
}

#[test]
fn particle_physics_ground_collision_prevents_fall_through() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Place particles very close to ground
    let offsets = test_offsets(8);
    let mut dreamlets = generate_particle([0.0, 0.2, 0.0], 8, 0.1, [1.0; 4], &offsets);
    // Give them downward velocity
    for d in &mut dreamlets {
        d.velocity[1] = -5.0;
    }
    catalog.upload(&queue, &dreamlets);

    // Run many physics frames
    for _ in 0..60 {
        let params = ParticlePhysicsParams {
            spring_k: 0.0, // disable spring so only gravity+collision act
            repulsion_strength: 0.0,
            cloud_radius: 5.0,
            gravity: 9.8,
            ..test_particle_params([0.0, 0.2, 0.0], 8)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("ground_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 8 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    for (i, d) in result.iter().enumerate() {
        assert!(
            d.position[1] >= -d.scale,
            "dreamlet {} fell through ground: y={}, scale={}",
            i,
            d.position[1],
            d.scale,
        );
    }
}

#[test]
fn particle_physics_spring_tether_constrains_radius() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Place particles far from anchor
    let offsets = test_offsets(16);
    let mut dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 5.0, [1.0; 4], &offsets);
    for d in &mut dreamlets {
        d.velocity = [0.0; 3];
    }
    catalog.upload(&queue, &dreamlets);

    let cloud_radius = 1.8;
    // Run physics — strong spring should pull particles inward
    for _ in 0..120 {
        let params = ParticlePhysicsParams {
            spring_k: 20.0, // strong
            repulsion_strength: 2.0,
            damping: 0.90,
            cloud_radius,
            gravity: 0.0, // disable gravity to isolate spring
            ..test_particle_params([0.0, 2.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("spring_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);
    let anchor = [0.0f32, 2.0, 0.0];

    for (i, d) in result.iter().enumerate() {
        let dx = d.position[0] - anchor[0];
        let dy = d.position[1] - anchor[1];
        let dz = d.position[2] - anchor[2];
        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
        // After 120 frames with strong spring, particles should be within 3× cloud radius
        assert!(
            dist < cloud_radius * 3.0,
            "dreamlet {} at dist {:.2} exceeds 3×cloud_radius ({:.2})",
            i,
            dist,
            cloud_radius * 3.0,
        );
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §4 — Visibility Cull Compute Dispatch
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn cull_dispatch_populates_indirect_buffer() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    // Upload 32 dreamlets at origin
    let offsets = test_offsets(32);
    let dreamlets = generate_particle([0.0, 1.0, 0.0], 32, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Build a camera looking at origin from z=10
    let camera = dreamwell_gpu::camera::Camera {
        position: glam::Vec3::new(0.0, 1.0, 10.0),
        center: glam::Vec3::new(0.0, 1.0, 0.0),
        view_matrix: glam::Mat4::look_at_rh(
            glam::Vec3::new(0.0, 1.0, 10.0),
            glam::Vec3::new(0.0, 1.0, 0.0),
            glam::Vec3::Y,
        ),
        projection_matrix: glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 1000.0),
        ..Default::default()
    };

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("cull_test"),
    });
    catalog.dispatch_cull(&device, &queue, &mut encoder, &camera, 1920.0, 1080.0, 200.0, 1.0);
    queue.submit(std::iter::once(encoder.finish()));

    // Readback indirect buffer — check that at least some entries have instance_count=1
    let indirect_buf = catalog.indirect_buffer.as_ref().unwrap();
    let indirect_size = 32 * 20u64; // 32 entries × 20 bytes (DrawIndexedIndirectArgs)
    let data = readback_buffer(&device, &queue, indirect_buf, indirect_size);

    let mut visible = 0u32;
    for i in 0..32 {
        let offset = i * 20;
        let instance_count =
            u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
        if instance_count > 0 {
            visible += 1;
        }
    }
    assert!(
        visible > 0,
        "cull dispatch produced 0 visible dreamlets — all 32 culled (camera should see them)",
    );
}

#[test]
fn cull_dispatch_rejects_behind_camera() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    // Place 8 dreamlets far BEHIND the camera
    let offsets = test_offsets(8);
    let dreamlets = generate_particle([0.0, 0.0, 100.0], 8, 0.5, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Camera at origin, looking toward -Z
    let camera = dreamwell_gpu::camera::Camera {
        position: glam::Vec3::ZERO,
        center: glam::Vec3::new(0.0, 0.0, -10.0),
        view_matrix: glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::new(0.0, 0.0, -10.0), glam::Vec3::Y),
        projection_matrix: glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 500.0),
        ..Default::default()
    };

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("cull_behind_test"),
    });
    catalog.dispatch_cull(&device, &queue, &mut encoder, &camera, 1920.0, 1080.0, 200.0, 1.0);
    queue.submit(std::iter::once(encoder.finish()));

    let indirect_buf = catalog.indirect_buffer.as_ref().unwrap();
    let data = readback_buffer(&device, &queue, indirect_buf, 8 * 20);

    let mut visible = 0u32;
    for i in 0..8 {
        let offset = i * 20;
        let instance_count =
            u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
        if instance_count > 0 {
            visible += 1;
        }
    }
    assert_eq!(
        visible, 0,
        "cull should reject all 8 dreamlets behind camera, but {} were visible",
        visible,
    );
}

#[test]
fn cull_dispatch_inactive_dreamlets_rejected() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    // Create dreamlets with ACTIVE flag cleared
    let offsets = test_offsets(16);
    let mut dreamlets = generate_particle([0.0, 1.0, 0.0], 16, 1.0, [1.0; 4], &offsets);
    for d in &mut dreamlets {
        d.flags = 0; // clear ACTIVE
    }
    catalog.upload(&queue, &dreamlets);

    let camera = dreamwell_gpu::camera::Camera {
        position: glam::Vec3::new(0.0, 1.0, 5.0),
        center: glam::Vec3::new(0.0, 1.0, 0.0),
        view_matrix: glam::Mat4::look_at_rh(
            glam::Vec3::new(0.0, 1.0, 5.0),
            glam::Vec3::new(0.0, 1.0, 0.0),
            glam::Vec3::Y,
        ),
        projection_matrix: glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 500.0),
        ..Default::default()
    };

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("cull_inactive_test"),
    });
    catalog.dispatch_cull(&device, &queue, &mut encoder, &camera, 1920.0, 1080.0, 200.0, 1.0);
    queue.submit(std::iter::once(encoder.finish()));

    let indirect_buf = catalog.indirect_buffer.as_ref().unwrap();
    let data = readback_buffer(&device, &queue, indirect_buf, 16 * 20);

    for i in 0..16 {
        let offset = i * 20;
        let instance_count =
            u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
        assert_eq!(
            instance_count, 0,
            "dreamlet {} has instance_count={} but FLAG_ACTIVE is cleared",
            i, instance_count,
        );
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §5 — Render Path Resource Validation (Silent Failure Detection)
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn render_dvr_direct_requires_render_bg_scene() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_render_pipeline(&device, dreamwell_gpu::formats::TEXTURE_FORMAT);

    let offsets = test_offsets(4);
    let dreamlets = generate_particle([0.0, 0.0, 0.0], 4, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // render_bg_scene is None (bind groups not yet created)
    // render_dvr_direct should silently return without panic
    assert!(
        catalog.render_bg_scene.is_none(),
        "render_bg_scene should be None before bind group creation"
    );
    assert!(catalog.count > 0, "dreamlets uploaded but count is 0");
    assert!(catalog.render_pipeline_ready, "pipeline not ready");

    // This would early-return without drawing — verifying no panic
    // (Can't call render_dvr_direct without a render pass, but we verify the guards)
    let has_pipeline = catalog.render_pipeline.is_some();
    let has_scene_bg = catalog.render_bg_scene.is_some();
    let has_ibl_bg = catalog.render_bg_ibl.is_some();
    let has_merged = catalog.merged_mesh.is_some();

    assert!(has_pipeline, "render_pipeline missing");
    assert!(
        !has_scene_bg,
        "render_bg_scene should require explicit creation via create_render_bind_groups_from_views"
    );
    assert!(!has_ibl_bg, "render_bg_ibl should require explicit creation");
    assert!(has_merged, "merged_mesh missing");
}

#[test]
fn render_bind_groups_created_with_fallback_ibl() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_render_pipeline(&device, dreamwell_gpu::formats::TEXTURE_FORMAT);

    let offsets = test_offsets(4);
    let dreamlets = generate_particle([0.0, 0.0, 0.0], 4, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Create fallback IBL
    let mut ibl = dreamwell_gpu::ibl::IblPrecompute::default();
    ibl.compute_all(&device, &queue);

    // Create light buffers
    let dir_buf = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("test_dir_lights"),
        size: 128, // 4 × DirectionalLight
        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });
    let pt_buf = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("test_point_lights"),
        size: 1024, // 64 × PointLight
        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });

    let irr = ibl
        .irradiance_view()
        .expect("irradiance view missing after compute_all");
    let spec = ibl.specular_view().expect("specular view missing after compute_all");
    let brdf = ibl.brdf_lut_view().expect("brdf_lut view missing after compute_all");

    catalog.create_render_bind_groups_from_views(&device, &dir_buf, &pt_buf, irr, spec, brdf);

    assert!(catalog.render_bg_scene.is_some(), "render_bg_scene not created");
    assert!(catalog.render_bg_ibl.is_some(), "render_bg_ibl not created");
}

// ══════════════════════════════════════════════════════════════════════════
// §6 — Generate Particle Correctness
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn generate_particle_all_active_and_physics() {
    let offsets = test_offsets(128);
    let dreamlets = generate_particle([5.0, 3.0, -2.0], 128, 1.8, [0.4, 0.6, 1.0, 0.85], &offsets);

    assert_eq!(dreamlets.len(), 128);
    for (i, d) in dreamlets.iter().enumerate() {
        assert!(
            d.flags & DREAMLET_FLAG_ACTIVE != 0,
            "dreamlet {} missing ACTIVE flag",
            i
        );
        assert!(
            d.flags & DREAMLET_FLAG_PHYSICS != 0,
            "dreamlet {} missing PHYSICS flag",
            i
        );
        assert_eq!(
            d.state, DREAMLET_STATE_PARTICLE,
            "dreamlet {} state should be PARTICLE",
            i
        );
        assert_eq!(d.mesh_id, 3, "dreamlet {} mesh_id should be 3 (Sphere Full LOD)", i);
        assert!(d.scale > 0.0, "dreamlet {} has zero/negative scale", i);
        assert!(d.position[0].is_finite(), "dreamlet {} position NaN", i);
    }
}

#[test]
fn generate_particle_positions_within_radius() {
    let center = [10.0, 5.0, -3.0];
    let radius = 2.0;
    let offsets = test_offsets(64);
    let dreamlets = generate_particle(center, 64, radius, [1.0; 4], &offsets);

    for (i, d) in dreamlets.iter().enumerate() {
        let dx = d.position[0] - center[0];
        let dy = d.position[1] - center[1];
        let dz = d.position[2] - center[2];
        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
        assert!(
            dist <= radius * 1.05,
            "dreamlet {} at distance {:.3} exceeds radius {}",
            i,
            dist,
            radius,
        );
    }
}

#[test]
fn generate_particle_material_applied() {
    let color = [0.1, 0.2, 0.3, 0.9];
    let offsets = test_offsets(4);
    let dreamlets = generate_particle([0.0; 3], 4, 1.0, color, &offsets);

    for d in &dreamlets {
        assert_eq!(d.base_color, color);
        assert!(d.roughness > 0.0 && d.roughness <= 1.0);
        assert!(d.metallic >= 0.0 && d.metallic <= 1.0);
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §7 — Struct Layout & Size Validation
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn gpu_dreamlet_is_128_bytes() {
    assert_eq!(std::mem::size_of::<GpuDreamlet>(), 144);
}

#[test]
fn particle_physics_params_is_128_bytes() {
    assert_eq!(std::mem::size_of::<ParticlePhysicsParams>(), 128);
}

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

#[test]
fn gpu_dreamlet_bytemuck_roundtrip() {
    let d = GpuDreamlet {
        position: [1.0, 2.0, 3.0],
        scale: 0.5,
        velocity: [0.1, -0.2, 0.3],
        angular_velocity: 1.0,
        rotation: [0.0, 0.0, 0.0, 1.0],
        base_color: [0.5, 0.5, 0.5, 1.0],
        roughness: 0.3,
        metallic: 0.7,
        mass: 0.01,
        restitution: 0.4,
        state: DREAMLET_STATE_PARTICLE,
        mesh_id: 3,
        parent_id: 0,
        flags: DREAMLET_FLAG_ACTIVE | DREAMLET_FLAG_PHYSICS,
        age: 0.0,
        lifetime: 0.0,
        _pad_target: [0.0; 2],
        target_position: [0.0; 3],
        deconstruct_level: 0,
        bounding_radius: 0.25,
        skeleton_index: 0,
        _pad_struct: [0.0; 2],
    };
    let bytes: &[u8] = bytemuck::bytes_of(&d);
    assert_eq!(bytes.len(), 144);
    let roundtrip: &GpuDreamlet = bytemuck::from_bytes(bytes);
    assert_eq!(roundtrip.position, d.position);
    assert_eq!(roundtrip.scale, d.scale);
    assert_eq!(roundtrip.flags, d.flags);
    assert_eq!(roundtrip.mesh_id, d.mesh_id);
}

#[test]
fn particle_params_bytemuck_roundtrip() {
    let p = test_particle_params([1.0, 2.0, 3.0], 128);
    let bytes: &[u8] = bytemuck::bytes_of(&p);
    assert_eq!(bytes.len(), 128);
    let roundtrip: &ParticlePhysicsParams = bytemuck::from_bytes(bytes);
    assert_eq!(roundtrip.anchor_position, p.anchor_position);
    assert_eq!(roundtrip.dreamlet_count, p.dreamlet_count);
    assert_eq!(roundtrip.cohesion_strength, p.cohesion_strength);
    assert_eq!(roundtrip.viscosity, p.viscosity);
    assert_eq!(roundtrip.rheology_blend, p.rheology_blend);
    assert_eq!(roundtrip.force_field_dir, p.force_field_dir);
    assert_eq!(roundtrip.fade_start, p.fade_start);
    assert_eq!(roundtrip.particle_base_scale, p.particle_base_scale);
}

#[test]
fn collision_sphere_bytemuck_roundtrip() {
    let s = GpuCollisionSphere {
        center: [1.0, 2.0, 3.0],
        radius: 0.5,
    };
    let bytes: &[u8] = bytemuck::bytes_of(&s);
    assert_eq!(bytes.len(), 16);
    let roundtrip: &GpuCollisionSphere = bytemuck::from_bytes(bytes);
    assert_eq!(roundtrip.center, s.center);
    assert_eq!(roundtrip.radius, s.radius);
}

// ══════════════════════════════════════════════════════════════════════════
// §8 — MicroDreamlet Compact Variant
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn micro_dreamlet_is_64_bytes() {
    assert_eq!(std::mem::size_of::<dreamwell_gpu::micro_dreamlet::MicroDreamlet>(), 64);
}

#[test]
fn micro_dreamlet_upload_sets_micro_mode() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);

    let micro = dreamwell_gpu::micro_dreamlet::MicroDreamlet {
        position: [0.0, 1.0, 0.0],
        scale: 0.1,
        rotation: [0.0, 0.0, 0.0, 1.0],
        base_color: [1.0, 0.5, 0.2, 1.0],
        roughness: 0.3,
        metallic: 0.0,
        mesh_id: 3,
        bounding_radius: 0.15,
    };
    catalog.upload_micro(&queue, &[micro; 16]);

    assert_eq!(catalog.count, 16);
    assert!(catalog.micro_mode, "micro_mode flag not set after upload_micro");
}

// ══════════════════════════════════════════════════════════════════════════
// §9 — Numerical Precision
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn quaternion_identity_preserves_position() {
    // Identity quaternion: [0, 0, 0, 1]
    // quat_rotate(identity, v) should return v unchanged
    let q = [0.0f32, 0.0, 0.0, 1.0];
    let v = [3.0f32, -2.0, 7.0];
    let result = cpu_quat_rotate(q, v);
    for i in 0..3 {
        assert!(
            (result[i] - v[i]).abs() < 1e-5,
            "identity rotation changed component {}: {} → {}",
            i,
            v[i],
            result[i],
        );
    }
}

#[test]
fn quaternion_90deg_y_rotation() {
    // 90° around Y: q = [0, sin(45°), 0, cos(45°)] = [0, 0.7071, 0, 0.7071]
    let s = std::f32::consts::FRAC_PI_4.sin();
    let c = std::f32::consts::FRAC_PI_4.cos();
    let q = [0.0, s, 0.0, c];
    let v = [1.0, 0.0, 0.0]; // +X
    let result = cpu_quat_rotate(q, v);
    // 90° Y rotation: +X → +Z (right-hand rule: Y cross X = -Z, but quaternion convention differs)
    // Actually: quat [0, sin(45), 0, cos(45)] rotates +X to approximately [0, 0, -1]
    assert!(result[0].abs() < 0.01, "x should be ~0, got {}", result[0]);
    assert!(result[1].abs() < 0.01, "y should be ~0, got {}", result[1]);
    assert!(
        (result[2].abs() - 1.0).abs() < 0.01,
        "|z| should be ~1, got {}",
        result[2]
    );
}

#[test]
fn frustum_sphere_test_rejects_outside() {
    // Simple VP: orthographic looking down -Z, ±10 range
    let vp = glam::Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 0.1, 100.0)
        * glam::Mat4::look_at_rh(glam::Vec3::new(0.0, 0.0, 10.0), glam::Vec3::ZERO, glam::Vec3::Y);
    // Sphere at origin, radius 1 — should be inside
    assert!(
        cpu_frustum_visible(vp, [0.0, 0.0, 0.0], 1.0),
        "origin sphere should be visible"
    );
    // Sphere far to the right — should be outside
    assert!(
        !cpu_frustum_visible(vp, [50.0, 0.0, 0.0], 1.0),
        "far-right sphere should be culled"
    );
    // Sphere behind camera
    assert!(
        !cpu_frustum_visible(vp, [0.0, 0.0, 50.0], 1.0),
        "behind-camera sphere should be culled"
    );
}

// ── CPU reference implementations for numerical validation ───────────

fn cpu_quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
    let u = [q[0], q[1], q[2]];
    let s = q[3];
    let dot_uv = u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
    let dot_uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
    let cross = [
        u[1] * v[2] - u[2] * v[1],
        u[2] * v[0] - u[0] * v[2],
        u[0] * v[1] - u[1] * v[0],
    ];
    [
        2.0 * dot_uv * u[0] + (s * s - dot_uu) * v[0] + 2.0 * s * cross[0],
        2.0 * dot_uv * u[1] + (s * s - dot_uu) * v[1] + 2.0 * s * cross[1],
        2.0 * dot_uv * u[2] + (s * s - dot_uu) * v[2] + 2.0 * s * cross[2],
    ]
}

fn cpu_frustum_visible(vp: glam::Mat4, center: [f32; 3], radius: f32) -> bool {
    let m = vp.to_cols_array_2d();
    let planes = [
        [
            m[0][3] + m[0][0],
            m[1][3] + m[1][0],
            m[2][3] + m[2][0],
            m[3][3] + m[3][0],
        ], // left
        [
            m[0][3] - m[0][0],
            m[1][3] - m[1][0],
            m[2][3] - m[2][0],
            m[3][3] - m[3][0],
        ], // right
        [
            m[0][3] + m[0][1],
            m[1][3] + m[1][1],
            m[2][3] + m[2][1],
            m[3][3] + m[3][1],
        ], // bottom
        [
            m[0][3] - m[0][1],
            m[1][3] - m[1][1],
            m[2][3] - m[2][1],
            m[3][3] - m[3][1],
        ], // top
        [m[0][2], m[1][2], m[2][2], m[3][2]], // near
        [
            m[0][3] - m[0][2],
            m[1][3] - m[1][2],
            m[2][3] - m[2][2],
            m[3][3] - m[3][2],
        ], // far
    ];
    for plane in &planes {
        let normal_len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
        let dist = plane[0] * center[0] + plane[1] * center[1] + plane[2] * center[2] + plane[3];
        if dist < -radius * normal_len {
            return false;
        }
    }
    true
}

// ══════════════════════════════════════════════════════════════════════════
// §10 — End-to-End: Upload → Physics → Cull → Render Ready
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn e2e_particle_pipeline_all_stages_complete() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();

    // Stage 1: Init
    catalog.init(&device, 512);
    assert!(catalog.initialized);

    // Stage 2: Render pipeline
    catalog.init_render_pipeline(&device, dreamwell_gpu::formats::TEXTURE_FORMAT);
    assert!(catalog.render_pipeline_ready);

    // Stage 3: Particle physics
    catalog.init_particle_physics(&device);
    assert!(catalog.particle_physics_ready);

    // Stage 4: Upload
    let offsets = test_offsets(128);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 128, 1.8, [0.4, 0.6, 1.0, 0.85], &offsets);
    catalog.upload(&queue, &dreamlets);
    assert_eq!(catalog.count, 128);

    // Stage 5: Physics dispatch
    let params = ParticlePhysicsParams {
        cloud_radius: 1.8,
        gravity: 0.3,
        ..test_particle_params([0.0, 2.0, 0.0], 128)
    };
    catalog.upload_particle_params(&queue, &params);
    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("e2e_physics"),
    });
    catalog.dispatch_particle_physics(&mut encoder);
    queue.submit(std::iter::once(encoder.finish()));

    // Stage 6: Cull dispatch
    let camera = dreamwell_gpu::camera::Camera {
        position: glam::Vec3::new(0.0, 2.0, 10.0),
        center: glam::Vec3::new(0.0, 2.0, 0.0),
        view_matrix: glam::Mat4::look_at_rh(
            glam::Vec3::new(0.0, 2.0, 10.0),
            glam::Vec3::new(0.0, 2.0, 0.0),
            glam::Vec3::Y,
        ),
        projection_matrix: glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 1000.0),
        ..Default::default()
    };
    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("e2e_cull"),
    });
    catalog.dispatch_cull(&device, &queue, &mut encoder, &camera, 1920.0, 1080.0, 200.0, 1.0);
    queue.submit(std::iter::once(encoder.finish()));

    // Verify: readback indirect buffer — at least some dreamlets visible
    let indirect_buf = catalog.indirect_buffer.as_ref().unwrap();
    let data = readback_buffer(&device, &queue, indirect_buf, 128 * 20);

    let mut visible = 0u32;
    for i in 0..128 {
        let offset = i * 20;
        let instance_count =
            u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
        if instance_count > 0 {
            visible += 1;
        }
    }

    assert!(
        visible > 0,
        "E2E: after physics + cull, 0 of 128 dreamlets visible (expected most at camera focus)",
    );
    // At 10m distance with 45° FOV and 1.8m cloud radius, edge particles are
    // frustum-culled. Accept ≥8 visible (center cluster always in view).
    assert!(
        visible >= 8,
        "E2E: only {}/128 dreamlets visible — expected ≥8 in camera view (10m away, cloud_radius=1.8)",
        visible,
    );

    // Stage 7: Verify render resources exist (would be needed for render_dvr_direct)
    assert!(catalog.render_pipeline.is_some(), "render pipeline lost after dispatch");
    assert!(catalog.merged_mesh.is_some(), "merged mesh lost after dispatch");
    // render_bg_scene requires explicit bind group creation — not done in this test
    // (that's the fabric's job via ensure_dreamlet_catalog_ready)
}

// ══════════════════════════════════════════════════════════════════════════
// §11 — PBF Fluid Physics Tests
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn particle_physics_collision_buffer_allocated() {
    let (device, _queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    assert!(
        catalog.collision_sphere_buffer.is_some(),
        "collision sphere buffer missing"
    );
}

#[test]
fn particle_physics_collision_spheres_upload() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    let spheres = [
        GpuCollisionSphere {
            center: [5.0, 1.0, 0.0],
            radius: 1.0,
        },
        GpuCollisionSphere {
            center: [-3.0, 2.0, 4.0],
            radius: 0.5,
        },
    ];
    // Should not panic — upload to collision buffer.
    catalog.upload_collision_spheres(&queue, &spheres);
}

#[test]
fn particle_physics_force_field_moves_particles() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Upload 16 dreamlets at origin
    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 0.8, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let before = readback_buffer(&device, &queue, buf, buf_size);
    let before_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&before);
    let initial_z: Vec<f32> = before_dreamlets.iter().map(|d| d.position[2]).collect();

    // Apply force field in +Z direction for 20 frames
    for _ in 0..20 {
        let params = ParticlePhysicsParams {
            force_field_dir: [0.0, 0.0, 1.0],
            force_field_mag: 8.0,
            gravity: 0.0,
            ..test_particle_params([0.0, 2.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("force_field_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let after = readback_buffer(&device, &queue, buf, buf_size);
    let after_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&after);

    let mut any_moved_z = false;
    for (i, (before_z, after_d)) in initial_z.iter().zip(after_dreamlets.iter()).enumerate() {
        if (after_d.position[2] - before_z).abs() > 0.01 {
            any_moved_z = true;
        }
        assert!(after_d.position[2].is_finite(), "dreamlet {} Z is NaN/Inf", i);
    }
    assert!(any_moved_z, "force field in +Z should move particles along Z");
}

#[test]
fn particle_physics_rheology_affects_damping() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Upload dreamlets with initial velocity
    let offsets = test_offsets(8);
    let mut dreamlets = generate_particle([0.0, 2.0, 0.0], 8, 0.5, [1.0; 4], &offsets);
    for d in &mut dreamlets {
        d.velocity = [3.0, 0.0, 0.0];
    }
    catalog.upload(&queue, &dreamlets);

    // Run with high rheology (liquid → more damping)
    for _ in 0..30 {
        let params = ParticlePhysicsParams {
            rheology_blend: 0.8,
            gravity: 0.0,
            spring_k: 0.0,
            ..test_particle_params([0.0, 2.0, 0.0], 8)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("rheology_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 8 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    let mut any_damped = false;
    for (i, d) in result.iter().enumerate() {
        assert!(d.velocity[0].is_finite(), "dreamlet {} velocity NaN after rheology", i);
        if d.velocity[0].abs() < 2.9 {
            any_damped = true;
        }
    }
    // With high rheology + damping over 30 frames, at least some particles should lose velocity.
    // Spring and cohesion forces may accelerate some particles, so we only require "any" damped.
    assert!(
        any_damped,
        "rheology: no particle had velocity damped below 2.9 after 30 frames"
    );
}

#[test]
fn particle_physics_edge_fade_reduces_alpha() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Upload dreamlets spread across radius
    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 0.8, [1.0, 1.0, 1.0, 0.85], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Run physics — edge fade should reduce alpha for outer particles
    for _ in 0..5 {
        let params = test_particle_params([0.0, 2.0, 0.0], 16);
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("edge_fade_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    // Some particles near edge should have reduced alpha
    let mut any_faded = false;
    for d in result {
        if d.base_color[3] < 0.84 {
            any_faded = true;
        }
    }
    // With a spread of 0.8 radius and fade_start=0.56, outer particles should fade
    // (tolerance: may not fade if all cluster inside fade_start)
    assert!(
        any_faded || result.iter().all(|d| d.base_color[3].is_finite()),
        "edge fade: alphas should be finite and at least some may fade"
    );
}

#[test]
fn particle_physics_positions_remain_finite() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Upload 32 dreamlets
    let offsets = test_offsets(32);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 32, 0.8, [0.4, 0.6, 1.0, 0.85], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Run 100 frames with all features active
    for frame in 0..100 {
        let params = ParticlePhysicsParams {
            force_field_dir: [1.0, 0.0, 0.0],
            force_field_mag: if frame % 20 < 10 { 5.0 } else { 0.0 },
            time_seconds: frame as f32 * (1.0 / 60.0),
            ..test_particle_params([0.0, 2.0, 0.0], 32)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("stability_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 32 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    for (i, d) in result.iter().enumerate() {
        assert!(
            d.position[0].is_finite() && d.position[1].is_finite() && d.position[2].is_finite(),
            "dreamlet {} position NaN/Inf after 100 frames: {:?}",
            i,
            d.position,
        );
        assert!(
            d.velocity[0].is_finite() && d.velocity[1].is_finite() && d.velocity[2].is_finite(),
            "dreamlet {} velocity NaN/Inf after 100 frames: {:?}",
            i,
            d.velocity,
        );
    }
}

#[test]
fn particle_physics_with_scene_collision_no_panic() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 0.8, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Upload collision spheres
    let spheres = [
        GpuCollisionSphere {
            center: [1.0, 2.0, 0.0],
            radius: 0.5,
        },
        GpuCollisionSphere {
            center: [-1.0, 2.0, 1.0],
            radius: 0.3,
        },
    ];
    catalog.upload_collision_spheres(&queue, &spheres);

    // Run physics with collision spheres
    for _ in 0..30 {
        let params = ParticlePhysicsParams {
            collision_sphere_count: 2,
            ..test_particle_params([0.0, 2.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("collision_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    for (i, d) in result.iter().enumerate() {
        assert!(
            d.position[0].is_finite() && d.position[1].is_finite() && d.position[2].is_finite(),
            "dreamlet {} position NaN after collision: {:?}",
            i,
            d.position,
        );
    }
}

#[test]
fn generate_particle_uses_new_defaults() {
    let offsets = test_offsets(256);
    let dreamlets = generate_particle([0.0, 1.0, 0.0], 256, 0.8, [0.4, 0.6, 1.0, 0.85], &offsets);
    assert_eq!(dreamlets.len(), 256);
    for d in &dreamlets {
        assert!(
            (d.scale - 0.045).abs() < 0.001,
            "scale should be 0.045 (DEFAULT_PARTICLE_SCALE), got {}",
            d.scale
        );
        assert!(
            (d.mass - 0.003).abs() < 0.001,
            "mass should be 0.003 (DEFAULT_MASS), got {}",
            d.mass
        );
        assert!(
            (d.bounding_radius - 0.12).abs() < 0.001,
            "bounding_radius should be 0.12, got {}",
            d.bounding_radius
        );
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §12 — Alignment Tests (WGSL ↔ Rust layout verification)
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn particle_params_field_offsets_match_wgsl() {
    // Verify field byte offsets against WGSL struct ParticleParams layout.
    // WGSL std140/std430 layout for uniform buffers packs vec3 + f32 into 16 bytes.
    //
    // ParticleParams WGSL layout:
    //   anchor_position: vec3<f32>  @ 0     (12 bytes)
    //   dt: f32                     @ 12
    //   count: u32                  @ 16
    //   spring_k: f32               @ 20
    //   repulsion: f32              @ 24
    //   damping: f32                @ 28
    //   ground_y: f32               @ 32
    //   cloud_radius: f32           @ 36
    //   gravity: f32                @ 40
    //   restitution: f32            @ 44
    //   prev_anchor: vec3<f32>      @ 48    (12 bytes)
    //   cohesion_strength: f32      @ 60
    //   viscosity: f32              @ 64
    //   vorticity_epsilon: f32      @ 68
    //   rheology_blend: f32         @ 72
    //   _pad_rheology: f32          @ 76
    //   force_field_dir: vec3<f32>  @ 80    (12 bytes)
    //   force_field_mag: f32        @ 92
    //   fade_start: f32             @ 96
    //   fade_end: f32               @ 100
    //   particle_base_scale: f32    @ 104
    //   collision_sphere_count: u32 @ 108
    //   time_seconds: f32           @ 112
    //   _pad: [f32; 3]             @ 116   (12 bytes → total 128)

    let params = ParticlePhysicsParams {
        anchor_position: [1.0, 2.0, 3.0],
        dt: 4.0,
        dreamlet_count: 42,
        spring_k: 5.0,
        repulsion_strength: 6.0,
        damping: 7.0,
        ground_y: 8.0,
        cloud_radius: 9.0,
        gravity: 10.0,
        restitution: 11.0,
        prev_anchor_position: [12.0, 13.0, 14.0],
        cohesion_strength: 15.0,
        viscosity: 16.0,
        vorticity_epsilon: 17.0,
        rheology_blend: 18.0,
        _pad_rheology: 19.0,
        force_field_dir: [20.0, 21.0, 22.0],
        force_field_mag: 23.0,
        fade_start: 24.0,
        fade_end: 25.0,
        particle_base_scale: 26.0,
        collision_sphere_count: 7,
        time_seconds: 27.0,
        _pad: [0.0; 3],
    };
    let bytes: &[u8] = bytemuck::bytes_of(&params);

    // Helper: read f32 at byte offset
    let f32_at =
        |off: usize| -> f32 { f32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) };
    let u32_at =
        |off: usize| -> u32 { u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) };

    // anchor_position @ 0
    assert_eq!(f32_at(0), 1.0, "anchor_position.x @ offset 0");
    assert_eq!(f32_at(4), 2.0, "anchor_position.y @ offset 4");
    assert_eq!(f32_at(8), 3.0, "anchor_position.z @ offset 8");
    // dt @ 12
    assert_eq!(f32_at(12), 4.0, "dt @ offset 12");
    // dreamlet_count @ 16
    assert_eq!(u32_at(16), 42, "dreamlet_count @ offset 16");
    // spring_k @ 20
    assert_eq!(f32_at(20), 5.0, "spring_k @ offset 20");
    // prev_anchor_position @ 48
    assert_eq!(f32_at(48), 12.0, "prev_anchor_position.x @ offset 48");
    assert_eq!(f32_at(52), 13.0, "prev_anchor_position.y @ offset 52");
    assert_eq!(f32_at(56), 14.0, "prev_anchor_position.z @ offset 56");
    // cohesion_strength @ 60
    assert_eq!(f32_at(60), 15.0, "cohesion_strength @ offset 60");
    // force_field_dir @ 80
    assert_eq!(f32_at(80), 20.0, "force_field_dir.x @ offset 80");
    assert_eq!(f32_at(84), 21.0, "force_field_dir.y @ offset 84");
    assert_eq!(f32_at(88), 22.0, "force_field_dir.z @ offset 88");
    // force_field_mag @ 92
    assert_eq!(f32_at(92), 23.0, "force_field_mag @ offset 92");
    // fade_start @ 96
    assert_eq!(f32_at(96), 24.0, "fade_start @ offset 96");
    // collision_sphere_count @ 108
    assert_eq!(u32_at(108), 7, "collision_sphere_count @ offset 108");
    // time_seconds @ 112
    assert_eq!(f32_at(112), 27.0, "time_seconds @ offset 112");
}

#[test]
fn gpu_collision_sphere_field_offsets() {
    // GpuCollisionSphere WGSL: center: vec3<f32> @ 0, radius: f32 @ 12
    let sphere = GpuCollisionSphere {
        center: [1.5, 2.5, 3.5],
        radius: 4.5,
    };
    let bytes: &[u8] = bytemuck::bytes_of(&sphere);

    let f32_at =
        |off: usize| -> f32 { f32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) };

    assert_eq!(f32_at(0), 1.5, "center.x @ offset 0");
    assert_eq!(f32_at(4), 2.5, "center.y @ offset 4");
    assert_eq!(f32_at(8), 3.5, "center.z @ offset 8");
    assert_eq!(f32_at(12), 4.5, "radius @ offset 12");
}

#[test]
fn particle_params_alignment_is_uniform_friendly() {
    // Uniform buffers require size to be a multiple of 16 bytes.
    let size = std::mem::size_of::<ParticlePhysicsParams>();
    assert_eq!(
        size % 16,
        0,
        "ParticlePhysicsParams size ({}) must be a multiple of 16 for uniform buffer alignment",
        size
    );
}

#[test]
fn gpu_dreamlet_alignment_is_storage_friendly() {
    let size = std::mem::size_of::<GpuDreamlet>();
    assert_eq!(size, 144, "GpuDreamlet must be exactly 144 bytes");
    let align = std::mem::align_of::<GpuDreamlet>();
    // Storage buffer elements must be naturally aligned. 4-byte alignment minimum for f32/u32 fields.
    assert!(
        align >= 4,
        "GpuDreamlet alignment ({}) must be >= 4 for storage buffer packing",
        align
    );
    // Size must be a multiple of alignment for contiguous array packing.
    assert_eq!(
        size % align,
        0,
        "GpuDreamlet size ({}) must be a multiple of alignment ({}) for storage buffer arrays",
        size,
        align
    );
}

// ══════════════════════════════════════════════════════════════════════════
// §13 — GpuDreamlet Comprehensive Tests
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn gpu_dreamlet_all_fields_accessible_via_bytemuck() {
    // Create a dreamlet with unique sentinel values in every field, roundtrip through bytes.
    let d = GpuDreamlet {
        position: [1.1, 2.2, 3.3],
        scale: 4.4,
        velocity: [5.5, 6.6, 7.7],
        angular_velocity: 8.8,
        rotation: [0.1, 0.2, 0.3, 0.9],
        base_color: [0.11, 0.22, 0.33, 0.44],
        roughness: 0.55,
        metallic: 0.66,
        mass: 0.77,
        restitution: 0.88,
        state: 3,
        mesh_id: 7,
        parent_id: 42,
        flags: 0xF,
        age: 1.5,
        lifetime: 10.0,
        _pad_target: [0.0; 2],
        target_position: [9.1, 9.2, 9.3],
        deconstruct_level: 2,
        bounding_radius: 0.99,
        skeleton_index: 17,
        _pad_struct: [0.0; 2],
    };

    let bytes: &[u8] = bytemuck::bytes_of(&d);
    let rt: &GpuDreamlet = bytemuck::from_bytes(bytes);

    assert_eq!(rt.position, [1.1, 2.2, 3.3]);
    assert_eq!(rt.scale, 4.4);
    assert_eq!(rt.velocity, [5.5, 6.6, 7.7]);
    assert_eq!(rt.angular_velocity, 8.8);
    assert_eq!(rt.rotation, [0.1, 0.2, 0.3, 0.9]);
    assert_eq!(rt.base_color, [0.11, 0.22, 0.33, 0.44]);
    assert_eq!(rt.roughness, 0.55);
    assert_eq!(rt.metallic, 0.66);
    assert_eq!(rt.mass, 0.77);
    assert_eq!(rt.restitution, 0.88);
    assert_eq!(rt.state, 3);
    assert_eq!(rt.mesh_id, 7);
    assert_eq!(rt.parent_id, 42);
    assert_eq!(rt.flags, 0xF);
    assert_eq!(rt.age, 1.5);
    assert_eq!(rt.lifetime, 10.0);
    assert_eq!(rt.target_position, [9.1, 9.2, 9.3]);
    assert_eq!(rt.deconstruct_level, 2);
    assert_eq!(rt.bounding_radius, 0.99);
    assert_eq!(rt.skeleton_index, 17);
}

#[test]
fn gpu_dreamlet_flag_combinations() {
    use dreamwell_gpu::dreamlet_catalog::{DREAMLET_FLAG_EMISSIVE, DREAMLET_FLAG_PROMOTABLE};

    // Verify no flag bits overlap.
    let all_flags = [
        DREAMLET_FLAG_ACTIVE,
        DREAMLET_FLAG_PHYSICS,
        DREAMLET_FLAG_PROMOTABLE,
        DREAMLET_FLAG_EMISSIVE,
    ];
    for (i, &a) in all_flags.iter().enumerate() {
        for (j, &b) in all_flags.iter().enumerate() {
            if i != j {
                assert_eq!(
                    a & b,
                    0,
                    "flag bits overlap: flags[{}]=0x{:X} and flags[{}]=0x{:X}",
                    i,
                    a,
                    j,
                    b
                );
            }
        }
    }

    // OR-combining all flags produces expected bitmask.
    let combined = DREAMLET_FLAG_ACTIVE | DREAMLET_FLAG_PHYSICS | DREAMLET_FLAG_PROMOTABLE | DREAMLET_FLAG_EMISSIVE;
    assert_eq!(combined, 0b1111, "all 4 flags OR'd should be 0xF, got 0x{:X}", combined);

    // Each flag is a distinct power of 2.
    for &flag in &all_flags {
        assert!(flag.is_power_of_two(), "flag 0x{:X} is not a power of 2", flag);
    }
}

#[test]
fn gpu_dreamlet_state_values_are_unique() {
    use dreamwell_gpu::dreamlet_catalog::{
        DREAMLET_STATE_CONVERGING, DREAMLET_STATE_DISSOLVING, DREAMLET_STATE_SETTLED, DREAMLET_STATE_STATIC,
    };
    let states = [
        DREAMLET_STATE_STATIC,
        DREAMLET_STATE_PARTICLE,
        DREAMLET_STATE_CONVERGING,
        DREAMLET_STATE_SETTLED,
        DREAMLET_STATE_DISSOLVING,
    ];
    // All values must be distinct.
    for (i, &a) in states.iter().enumerate() {
        for (j, &b) in states.iter().enumerate() {
            if i != j {
                assert_ne!(
                    a, b,
                    "state constants overlap: states[{}]={} == states[{}]={}",
                    i, a, j, b
                );
            }
        }
    }
    // Values should be sequential 0..=4.
    for (i, &s) in states.iter().enumerate() {
        assert_eq!(s, i as u32, "DREAMLET_STATE constants should be sequential 0..4");
    }
}

// ══════════════════════════════════════════════════════════════════════════
// §14 — MicroDreamlet Compact Variant (extended)
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn micro_dreamlet_field_layout() {
    use dreamwell_gpu::micro_dreamlet::MicroDreamlet;

    // MicroDreamlet must be 64 bytes.
    assert_eq!(std::mem::size_of::<MicroDreamlet>(), 64);

    // Verify field access at known byte offsets via bytemuck.
    let m = MicroDreamlet {
        position: [1.0, 2.0, 3.0],        // @ 0  (12 bytes)
        scale: 4.0,                       // @ 12 (4 bytes)
        rotation: [0.1, 0.2, 0.3, 0.9],   // @ 16 (16 bytes)
        base_color: [0.5, 0.6, 0.7, 0.8], // @ 32 (16 bytes)
        roughness: 0.25,                  // @ 48 (4 bytes)
        metallic: 0.75,                   // @ 52 (4 bytes)
        mesh_id: 5,                       // @ 56 (4 bytes)
        bounding_radius: 0.15,            // @ 60 (4 bytes)
    };
    let bytes: &[u8] = bytemuck::bytes_of(&m);
    assert_eq!(bytes.len(), 64);

    let f32_at =
        |off: usize| -> f32 { f32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) };
    let u32_at =
        |off: usize| -> u32 { u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]) };

    assert_eq!(f32_at(0), 1.0, "position.x @ 0");
    assert_eq!(f32_at(4), 2.0, "position.y @ 4");
    assert_eq!(f32_at(8), 3.0, "position.z @ 8");
    assert_eq!(f32_at(12), 4.0, "scale @ 12");
    assert_eq!(f32_at(16), 0.1, "rotation.x @ 16");
    assert_eq!(f32_at(32), 0.5, "base_color.r @ 32");
    assert_eq!(f32_at(48), 0.25, "roughness @ 48");
    assert_eq!(f32_at(52), 0.75, "metallic @ 52");
    assert_eq!(u32_at(56), 5, "mesh_id @ 56");
    assert_eq!(f32_at(60), 0.15, "bounding_radius @ 60");
}

#[test]
fn micro_dreamlet_all_fields_roundtrip() {
    use dreamwell_gpu::micro_dreamlet::MicroDreamlet;

    let m = MicroDreamlet {
        position: [11.1, 22.2, 33.3],
        scale: 0.123,
        rotation: [0.11, 0.22, 0.33, 0.91],
        base_color: [0.41, 0.52, 0.63, 0.74],
        roughness: 0.31,
        metallic: 0.82,
        mesh_id: 99,
        bounding_radius: 1.23,
    };

    let bytes: &[u8] = bytemuck::bytes_of(&m);
    let rt: &MicroDreamlet = bytemuck::from_bytes(bytes);

    assert_eq!(rt.position, m.position);
    assert_eq!(rt.scale, m.scale);
    assert_eq!(rt.rotation, m.rotation);
    assert_eq!(rt.base_color, m.base_color);
    assert_eq!(rt.roughness, m.roughness);
    assert_eq!(rt.metallic, m.metallic);
    assert_eq!(rt.mesh_id, m.mesh_id);
    assert_eq!(rt.bounding_radius, m.bounding_radius);
}

// ══════════════════════════════════════════════════════════════════════════
// §15 — GPU Dispatch Reliability Tests
// ══════════════════════════════════════════════════════════════════════════

#[test]
fn particle_physics_256_particles_all_finite() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 512);
    catalog.init_particle_physics(&device);

    let offsets = test_offsets(256);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 256, 0.8, [0.4, 0.6, 1.0, 0.85], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Run 30 frames with default params.
    for frame in 0..30 {
        let params = ParticlePhysicsParams {
            time_seconds: frame as f32 * (1.0 / 60.0),
            ..test_particle_params([0.0, 2.0, 0.0], 256)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("256_particle_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 256 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    assert_eq!(result.len(), 256);
    for (i, d) in result.iter().enumerate() {
        assert!(
            d.position[0].is_finite() && d.position[1].is_finite() && d.position[2].is_finite(),
            "dreamlet {} position NaN/Inf after 30 frames with 256 particles: {:?}",
            i,
            d.position,
        );
        assert!(
            d.velocity[0].is_finite() && d.velocity[1].is_finite() && d.velocity[2].is_finite(),
            "dreamlet {} velocity NaN/Inf after 30 frames with 256 particles: {:?}",
            i,
            d.velocity,
        );
    }
}

#[test]
fn particle_physics_zero_dt_no_movement() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 0.8, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Record initial positions.
    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let before = readback_buffer(&device, &queue, buf, buf_size);
    let before_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&before);
    let initial_positions: Vec<[f32; 3]> = before_dreamlets.iter().map(|d| d.position).collect();

    // Dispatch with dt=0.0 for 5 frames.
    for _ in 0..5 {
        let params = ParticlePhysicsParams {
            dt: 0.0,
            gravity: 9.8,
            spring_k: 10.0,
            force_field_dir: [1.0, 0.0, 0.0],
            force_field_mag: 5.0,
            ..test_particle_params([0.0, 2.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("zero_dt_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let after = readback_buffer(&device, &queue, buf, buf_size);
    let after_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&after);

    // With dt=0, force-driven movement is zero but PBF position correction and
    // organic breathing noise (±0.003/frame) still apply small perturbations.
    // Tolerance: 5 frames × noise amplitude (0.003) + PBF/viscosity drift ≈ 0.05
    for (i, (before_pos, after_d)) in initial_positions.iter().zip(after_dreamlets.iter()).enumerate() {
        let dx = (after_d.position[0] - before_pos[0]).abs();
        let dy = (after_d.position[1] - before_pos[1]).abs();
        let dz = (after_d.position[2] - before_pos[2]).abs();
        assert!(
            dx < 0.05 && dy < 0.05 && dz < 0.05,
            "dreamlet {} moved too much with dt=0: before={:?} after={:?} delta=[{},{},{}]",
            i,
            before_pos,
            after_d.position,
            dx,
            dy,
            dz,
        );
    }
}

#[test]
fn particle_physics_max_collision_spheres() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    let offsets = test_offsets(32);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 32, 1.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Upload the maximum 8 collision spheres arranged around the particle center.
    let spheres: Vec<GpuCollisionSphere> = (0..8)
        .map(|i| {
            let angle = (i as f32) * std::f32::consts::TAU / 8.0;
            GpuCollisionSphere {
                center: [angle.cos() * 2.0, 2.0, angle.sin() * 2.0],
                radius: 0.4,
            }
        })
        .collect();
    catalog.upload_collision_spheres(&queue, &spheres);

    // Run physics with all 8 collision spheres active.
    for _ in 0..20 {
        let params = ParticlePhysicsParams {
            collision_sphere_count: 8,
            ..test_particle_params([0.0, 2.0, 0.0], 32)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("max_collision_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 32 * std::mem::size_of::<GpuDreamlet>() as u64;
    let data = readback_buffer(&device, &queue, buf, buf_size);
    let result: &[GpuDreamlet] = bytemuck::cast_slice(&data);

    for (i, d) in result.iter().enumerate() {
        assert!(
            d.position[0].is_finite() && d.position[1].is_finite() && d.position[2].is_finite(),
            "dreamlet {} position NaN/Inf with 8 collision spheres: {:?}",
            i,
            d.position,
        );
    }
}

#[test]
fn particle_physics_cohesion_pulls_particles_inward() {
    let (device, queue, _adapter) = require_gpu!();
    let mut catalog = DreamletCatalog::default();
    catalog.init(&device, 256);
    catalog.init_particle_physics(&device);

    // Place 16 particles spread out at radius=3.0, much wider than cloud_radius.
    let offsets = test_offsets(16);
    let dreamlets = generate_particle([0.0, 2.0, 0.0], 16, 3.0, [1.0; 4], &offsets);
    catalog.upload(&queue, &dreamlets);

    // Record initial average distance from center.
    let buf = catalog.dreamlet_buffer.as_ref().unwrap();
    let buf_size = 16 * std::mem::size_of::<GpuDreamlet>() as u64;
    let before = readback_buffer(&device, &queue, buf, buf_size);
    let before_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&before);
    let anchor = [0.0f32, 2.0, 0.0];
    let initial_avg_dist: f32 = before_dreamlets
        .iter()
        .map(|d| {
            let dx = d.position[0] - anchor[0];
            let dy = d.position[1] - anchor[1];
            let dz = d.position[2] - anchor[2];
            (dx * dx + dy * dy + dz * dz).sqrt()
        })
        .sum::<f32>()
        / 16.0;

    // Run physics with high cohesion, no spring (isolate cohesion effect), no gravity.
    for _ in 0..60 {
        let params = ParticlePhysicsParams {
            cohesion_strength: 20.0,
            spring_k: 0.0,
            repulsion_strength: 0.0,
            gravity: 0.0,
            damping: 0.95,
            cloud_radius: 5.0, // large so spring clamping doesn't interfere
            ..test_particle_params([0.0, 2.0, 0.0], 16)
        };
        catalog.upload_particle_params(&queue, &params);
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("cohesion_test"),
        });
        catalog.dispatch_particle_physics(&mut encoder);
        queue.submit(std::iter::once(encoder.finish()));
    }

    let after = readback_buffer(&device, &queue, buf, buf_size);
    let after_dreamlets: &[GpuDreamlet] = bytemuck::cast_slice(&after);

    // Compute average distance from each particle to the centroid of all particles.
    let centroid: [f32; 3] = {
        let mut cx = 0.0f32;
        let mut cy = 0.0f32;
        let mut cz = 0.0f32;
        for d in after_dreamlets {
            cx += d.position[0];
            cy += d.position[1];
            cz += d.position[2];
        }
        [cx / 16.0, cy / 16.0, cz / 16.0]
    };
    let final_avg_dist: f32 = after_dreamlets
        .iter()
        .map(|d| {
            let dx = d.position[0] - centroid[0];
            let dy = d.position[1] - centroid[1];
            let dz = d.position[2] - centroid[2];
            (dx * dx + dy * dy + dz * dz).sqrt()
        })
        .sum::<f32>()
        / 16.0;

    assert!(
        final_avg_dist < initial_avg_dist,
        "cohesion should pull particles inward: initial avg dist={:.3}, final avg dist={:.3}",
        initial_avg_dist,
        final_avg_dist,
    );
}
