//! Oxide Engine — integration test harness.
//!
//! End-to-end tests are added here as each stage is completed.

#[cfg(test)]
mod stage0 {
    #[test]
    fn workspace_builds() {
        // Trivial smoke test: if this file compiles, Stage 0 foundation is in place.
    }
}

#[cfg(test)]
mod stage1 {
    //! Stage 1 — Math & Core Primitives.
    //!
    //! These integration tests exercise the public math API the way other crates
    //! will, and include the fuzz test required by the Stage 1 test criteria
    //! ("random transform chains should remain stable").

    use oxide_engine::prelude::*;

    /// A tiny xorshift PRNG so the fuzz test is deterministic and dependency-free.
    struct Rng(u64);

    impl Rng {
        fn new(seed: u64) -> Self {
            // Avoid the all-zero state, which xorshift cannot escape.
            Self(seed | 1)
        }

        fn next_u64(&mut self) -> u64 {
            let mut x = self.0;
            x ^= x << 13;
            x ^= x >> 7;
            x ^= x << 17;
            self.0 = x;
            x
        }

        /// A float in `[-range, range]`.
        fn signed(&mut self, range: f32) -> f32 {
            let unit = (self.next_u64() as f64 / u64::MAX as f64) as f32; // [0, 1]
            (unit * 2.0 - 1.0) * range
        }

        fn vec3(&mut self, range: f32) -> Vec3 {
            Vec3::new(self.signed(range), self.signed(range), self.signed(range))
        }

        fn rotation(&mut self) -> Quat {
            Quat::from_euler(
                EulerRot::XYZ,
                self.signed(std::f32::consts::PI),
                self.signed(std::f32::consts::PI),
                self.signed(std::f32::consts::PI),
            )
        }
    }

    #[test]
    fn prelude_types_are_reachable() {
        // Smoke test that the prelude re-exports compile and interoperate.
        let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
        let bb = Aabb::from_center_half_extents(t.translation, Vec3::ONE);
        let ray = Ray::new(Vec3::ZERO, Vec3::X);
        let plane = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
        let _frustum = Frustum::from_view_projection(Mat4::IDENTITY);
        let _color = Color::WHITE;
        let _rect = Rect::ZERO;
        let _range = Range3::UNIT;
        assert!(bb.contains_point(t.translation));
        assert!(ray.is_valid());
        assert!(plane.signed_distance(Vec3::Y) > 0.0);
    }

    #[test]
    fn fuzz_transform_chains_stay_stable() {
        // Build long random chains of transforms (uniform scale so composition
        // stays exact and invertible), and confirm that:
        //   1. results never become non-finite (NaN/inf),
        //   2. composing a chain with its reverse-inverse returns to identity.
        let mut rng = Rng::new(0xDEAD_BEEF);

        for _trial in 0..200 {
            let len = 2 + (rng.next_u64() % 14) as usize;
            let mut chain: Vec<Transform> = Vec::with_capacity(len);
            for _ in 0..len {
                // Uniform, strictly-positive scale near 1.0 keeps every link
                // invertible and the round-trip well-conditioned (scales that
                // span many orders of magnitude make the inverse-fold suffer
                // catastrophic cancellation, which is a property of floating
                // point, not of the composition itself).
                let s = 0.75 + (rng.next_u64() % 1000) as f32 / 1500.0; // [0.75, ~1.42]
                chain.push(Transform::from_trs(
                    rng.vec3(20.0),
                    rng.rotation(),
                    Vec3::splat(s),
                ));
            }

            // Fold the whole chain into one transform. The key stability
            // property: no link ever produces a non-finite (NaN/inf) result.
            let mut combined = Transform::IDENTITY;
            for t in &chain {
                combined = combined.mul_transform(t);
                assert!(combined.is_finite(), "chain produced non-finite transform");
            }

            // Composing with the inverse-fold should return (approximately) to
            // the identity. Build the inverse by composing inverses in reverse.
            let mut undo = Transform::IDENTITY;
            for t in chain.iter().rev() {
                undo = undo.mul_transform(&t.inverse());
            }
            let identity = combined.mul_transform(&undo);

            let p = Vec3::new(3.0, -7.0, 11.0);
            let moved = identity.transform_point(p);
            // Relative tolerance: error accumulates proportionally to the
            // largest intermediate magnitude the chain passed through.
            let tol = 1e-4 * combined.translation.length().max(1.0);
            assert!(
                (moved - p).length() <= tol,
                "round trip drifted: {moved} vs {p} (len {len}, tol {tol})"
            );
        }
    }

    #[test]
    fn aabb_frustum_culling_round_trip() {
        // A box clearly in view is kept; a box behind the camera is culled.
        let proj = Mat4::perspective_rh(70_f32.to_radians(), 16.0 / 9.0, 0.1, 1000.0);
        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y);
        let frustum = Frustum::from_view_projection(proj * view);

        let visible = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -20.0), Vec3::splat(2.0));
        let behind = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 20.0), Vec3::splat(2.0));
        assert!(frustum.intersects_aabb(&visible));
        assert!(!frustum.intersects_aabb(&behind));
    }
}

#[cfg(test)]
mod stage3 {
    //! Stage 3 — Scene Graph & Entity System.
    //!
    //! Exercises the public scene API the way the editor and later systems
    //! will: hierarchy mutation, world-transform resolution, the serialize →
    //! deserialize round trip, and the large-scene resolution required by the
    //! Stage 3 test criteria.

    use oxide_engine::prelude::*;
    use oxide_engine::scene::DespawnPolicy;
    use std::time::Instant;

    #[test]
    fn world_transform_resolves_through_hierarchy() {
        let mut scene = Scene::new();
        let a = scene.spawn("a", Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)));
        let b = scene.spawn_child(
            a,
            "b",
            Transform::from_translation(Vec3::new(0.0, 2.0, 0.0)),
        );
        let c = scene.spawn_child(
            b,
            "c",
            Transform::from_translation(Vec3::new(0.0, 0.0, 4.0)),
        );
        let world = scene.world_transform(c).unwrap();
        assert!((world.translation - Vec3::new(1.0, 2.0, 4.0)).length() <= 1e-5);
    }

    #[test]
    fn despawn_policies_behave() {
        // Recursive removes the subtree; DetachChildren keeps the grandchildren.
        let mut scene = Scene::new();
        let root = scene.spawn("root", Transform::IDENTITY);
        let mid = scene.spawn_child(root, "mid", Transform::IDENTITY);
        let leaf = scene.spawn_child(mid, "leaf", Transform::IDENTITY);

        scene.despawn(mid, DespawnPolicy::DetachChildren);
        assert!(!scene.contains(mid));
        assert_eq!(scene.parent(leaf), Some(root));

        scene.despawn(root, DespawnPolicy::Recursive);
        assert!(!scene.contains(root));
        assert!(!scene.contains(leaf));
        assert!(scene.is_empty());
    }

    #[test]
    fn serialize_round_trip_is_identical() {
        let mut scene = Scene::new();
        let root = scene.spawn(
            "root",
            Transform::from_translation(Vec3::new(3.0, -1.0, 2.0)),
        );
        let child = scene.spawn_child(root, "child", Transform::from_scale(Vec3::splat(2.0)));
        scene.spawn_child(
            child,
            "grandchild",
            Transform::from_rotation(Quat::from_rotation_x(0.7)),
        );
        scene.set_enabled(child, false);

        let ron = scene.to_ron().unwrap();
        let restored = Scene::from_ron(&ron).unwrap();
        // A second serialization must be byte-identical: structure, names,
        // flags, transforms, and ordering all survived the round trip.
        assert_eq!(ron, restored.to_ron().unwrap());
    }

    #[test]
    fn large_scene_resolves_quickly() {
        // Stage 3 criterion: 10,000 entities, 5 levels deep, resolved well
        // within budget. The hard <1ms target is measured by the criterion
        // bench (`world_transforms_10k_depth5`) in release mode; here we assert
        // a generous ceiling that still catches accidental quadratic blow-ups
        // even in an unoptimized test build.
        const TOTAL: usize = 10_000;
        const DEPTH: usize = 5;

        let mut scene = Scene::new();
        let per_level = TOTAL / DEPTH;
        let mut previous: Vec<Entity> = Vec::new();
        for level in 0..DEPTH {
            let count = if level == DEPTH - 1 {
                TOTAL - per_level * (DEPTH - 1)
            } else {
                per_level
            };
            let mut current = Vec::with_capacity(count);
            for i in 0..count {
                let t = Transform::from_translation(Vec3::new(i as f32 * 0.01, 0.5, 0.0));
                let e = if previous.is_empty() {
                    scene.spawn("n", t)
                } else {
                    scene.spawn_child(previous[i % previous.len()], "n", t)
                };
                current.push(e);
            }
            previous = current;
        }
        assert_eq!(scene.len(), TOTAL);

        let start = Instant::now();
        let worlds = scene.world_transforms();
        let elapsed = start.elapsed();
        assert_eq!(worlds.len(), TOTAL);
        assert!(
            elapsed.as_millis() < 50,
            "resolving {TOTAL} entities took {elapsed:?} (debug build); \
             see the criterion bench for the <1ms release figure"
        );
    }
}

#[cfg(test)]
mod stage2 {
    //! Stage 2 — Window & Render Context.
    //!
    //! The window/event-loop half needs a display and a human eye, so it is
    //! covered by manual testing (`hello_window`, the editor). What *can* be
    //! verified automatically is the render half: GPU acquisition and the
    //! clear pass, exercised headless against an offscreen texture.

    use oxide_engine::prelude::*;
    use oxide_engine::render::clear_view;
    use oxide_engine::wgpu;

    /// 64 px × 4 bytes = exactly wgpu's 256-byte row alignment, so the
    /// readback buffer needs no padding logic.
    const WIDTH: u32 = 64;
    const HEIGHT: u32 = 4;

    /// Clears an offscreen Rgba8Unorm texture and reads the pixels back.
    ///
    /// Verifies the whole Stage 2 GPU path (instance → adapter → device →
    /// encoder → clear pass → submit) without a window. Skips (passes with a
    /// note) on machines with no GPU adapter, e.g. bare CI runners.
    #[test]
    fn headless_clear_fills_texture_with_clear_color() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());

        let texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("stage2.clear_target"),
            size: wgpu::Extent3d {
                width: WIDTH,
                height: HEIGHT,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());

        // Component values chosen to be exactly representable in 8-bit unorm
        // (n/255), so the readback comparison can be exact.
        let clear = Color::rgba(1.0, 51.0 / 255.0, 0.0, 1.0);
        clear_view(device, queue, &view, clear);

        // Copy the texture into a mappable buffer and read it back.
        let bytes_per_row = WIDTH * 4;
        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("stage2.readback"),
            size: (bytes_per_row * HEIGHT) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
        encoder.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &buffer,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(bytes_per_row),
                    rows_per_image: None,
                },
            },
            texture.size(),
        );
        queue.submit([encoder.finish()]);

        let (tx, rx) = std::sync::mpsc::channel();
        buffer
            .slice(..)
            .map_async(wgpu::MapMode::Read, move |result| {
                tx.send(result).expect("map_async result channel closed");
            });
        device
            .poll(wgpu::PollType::wait_indefinitely())
            .expect("device poll failed");
        rx.recv()
            .expect("map_async never delivered a result")
            .expect("buffer mapping failed");

        let data = buffer.slice(..).get_mapped_range();
        let expected = [255u8, 51, 0, 255];
        assert_eq!(data.len(), (bytes_per_row * HEIGHT) as usize);
        for (i, pixel) in data.chunks_exact(4).enumerate() {
            assert_eq!(pixel, expected, "pixel {i} not cleared correctly");
        }
    }
}

#[cfg(test)]
mod stage5 {
    //! Stage 5 — Engine Core Framework.
    //!
    //! These exercise the public layer/tag API the way physics, rendering, and
    //! scene queries will, including the Stage 5 criterion that **one** named
    //! layer drives different subsystems' filters identically.

    use oxide_engine::layer::{Layer, LayerMask, LayerRegistry, Tags};
    use oxide_engine::prelude::*;

    #[test]
    fn one_named_layer_drives_render_and_query_filters_identically() {
        // A project names its layers once.
        let mut registry = LayerRegistry::new();
        registry.set(1, "Player");
        registry.set(2, "Enemy");
        registry.set(3, "Invisible");

        // Three entities, each on a single named layer.
        let player = Layer::on(registry.index_of("Player").unwrap());
        let enemy = Layer::on(registry.index_of("Enemy").unwrap());
        let hidden = Layer::on(registry.index_of("Invisible").unwrap());

        // Build two *independent* filters from the same names — one standing in
        // for a camera's visibility mask, one for a raycast's query mask. Both
        // resolve through the registry, so the named layer is the single source
        // of truth.
        let camera_visibility: LayerMask = registry.mask_of(["Player", "Enemy"]);
        let raycast_filter: LayerMask = registry.mask_of(["Player", "Enemy"]);
        assert_eq!(camera_visibility, raycast_filter);

        for entity in [player, enemy, hidden] {
            // The same membership yields the same verdict under both filters —
            // proving the primitive is shared, not reimplemented per system.
            assert_eq!(
                entity.matches(camera_visibility),
                entity.matches(raycast_filter)
            );
        }

        // And the verdicts are the intended ones.
        assert!(player.matches(camera_visibility));
        assert!(enemy.matches(camera_visibility));
        assert!(!hidden.matches(camera_visibility));
    }

    #[test]
    fn type_registry_reads_and_writes_components_generically() {
        // The dual-editability path: register types once, then an editor/script
        // reads and writes them on an entity knowing only the *name* string.
        let mut registry = TypeRegistry::new();
        registry.register::<Transform>("Transform");
        registry.register::<Node>("Node");
        registry.register::<Layer>("Layer");

        let mut scene = Scene::new();
        let e = scene.spawn(
            "widget",
            Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
        );
        scene.world_mut().insert_one(e, Layer::on(3)).unwrap();

        // A generic inspector would list exactly these, sorted, with no concrete
        // types in hand.
        assert_eq!(
            registry.components_on(scene.world(), e),
            vec!["Layer", "Node", "Transform"]
        );

        // Round-trip the Transform through RON text and confirm it is unchanged.
        let text = registry.get_ron(scene.world(), e, "Transform").unwrap();
        registry
            .set_ron(scene.world_mut(), e, "Transform", &text)
            .unwrap();
        assert!(
            (scene.local_transform(e).unwrap().translation - Vec3::new(2.0, 0.0, 0.0)).length()
                < 1e-6
        );
    }

    // --- Render pass pipeline (headless GPU) -------------------------------

    use oxide_engine::wgpu;

    const RP_SIZE: u32 = 64; // 64 px * 4 bytes = 256-byte aligned rows.

    /// Offscreen color target + readback, mirroring the Stage 4 helper.
    fn render_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
        let texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("stage5.color"),
            size: wgpu::Extent3d {
                width: RP_SIZE,
                height: RP_SIZE,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
        (texture, view)
    }

    fn read_center(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> [u8; 4] {
        let bytes_per_row = RP_SIZE * 4;
        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("stage5.readback"),
            size: (bytes_per_row * RP_SIZE) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
        encoder.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &buffer,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(bytes_per_row),
                    rows_per_image: None,
                },
            },
            texture.size(),
        );
        queue.submit([encoder.finish()]);
        let (tx, rx) = std::sync::mpsc::channel();
        buffer
            .slice(..)
            .map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
        device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
        rx.recv().unwrap().unwrap();
        let data = buffer.slice(..).get_mapped_range();
        let mid = ((RP_SIZE / 2 * RP_SIZE + RP_SIZE / 2) * 4) as usize;
        [data[mid], data[mid + 1], data[mid + 2], data[mid + 3]]
    }

    fn front_lighting() -> Lighting {
        Lighting {
            light: DirectionalLight {
                direction: Vec3::NEG_Z,
                color: Color::WHITE,
                intensity: 1.0,
            },
            ambient: Color::rgb(0.05, 0.05, 0.05),
        }
    }

    #[test]
    fn forward_pipeline_matches_direct_renderer_and_passes_compose() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());
        let cube = Mesh::cube().upload(device, "cube");
        let camera = Camera::default();
        let view = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
        let lighting = front_lighting();
        let background = Color::rgb(0.0, 0.0, 1.0);

        // Render the Stage-4 scene through the data-driven pipeline (Clear+Forward).
        let mut pipeline = RenderPipeline::forward(device, wgpu::TextureFormat::Rgba8Unorm);
        assert_eq!(
            pipeline.pass_names().collect::<Vec<_>>(),
            vec!["clear", "forward"]
        );

        let objects = [RenderObject {
            mesh: &cube,
            material: Material::diffuse(Color::RED),
            transform: Transform::IDENTITY,
        }];

        // First render: Clear + Forward.
        let (tex, target) = render_target(device);
        pipeline.render(&mut FrameContext {
            device,
            queue,
            color: &target,
            size: (RP_SIZE, RP_SIZE),
            viewport_rect: None,
            clear_color: background,
            camera: &camera,
            view_transform: &view,
            lighting: &lighting,
            objects: &objects,
        });
        let center = read_center(device, queue, &tex);
        // Same lit-red result the Stage-4 forward renderer produces.
        assert!(
            center[0] > 180 && center[2] < 80,
            "pipeline center should be lit red, got {center:?}"
        );

        // Disabling the forward pass leaves just the cleared background — proving
        // passes are independently toggleable without touching their code.
        pipeline.set_enabled("forward", false);
        let (tex2, target2) = render_target(device);
        pipeline.render(&mut FrameContext {
            device,
            queue,
            color: &target2,
            size: (RP_SIZE, RP_SIZE),
            viewport_rect: None,
            clear_color: background,
            camera: &camera,
            view_transform: &view,
            lighting: &lighting,
            objects: &objects,
        });
        let bg = read_center(device, queue, &tex2);
        assert_eq!(
            bg,
            [0, 0, 255, 255],
            "disabled forward → background, got {bg:?}"
        );

        // A removed pass is gone from the order.
        assert!(pipeline.remove("forward"));
        assert!(!pipeline.has_pass("forward"));
        assert_eq!(pipeline.pass_names().collect::<Vec<_>>(), vec!["clear"]);
    }

    #[test]
    fn camera_visibility_culls_objects_by_layer() {
        // Pure-logic check of the culling predicate the host applies before
        // building the object list (no GPU needed).
        let cube_layers = Layer::on(2);
        let main_cam = Camera::default(); // sees all
        let restricted = Camera::default().with_visibility(LayerMask::layer(5));
        assert!(main_cam.sees(cube_layers));
        assert!(!restricted.sees(cube_layers));
    }

    #[test]
    fn app_composed_from_modules_runs_and_removes_cleanly() {
        use oxide_engine::app::{App, DefaultModules, Schedule};
        use std::cell::Cell;
        use std::rc::Rc;

        // An app assembled purely by registering modules runs its schedule.
        let mut app = App::new();
        app.add_modules(DefaultModules);
        assert!(app.has_module("core") && app.has_module("render"));
        assert!(app.types.is_registered("Transform"));
        assert!(app.types.is_registered("MeshRenderer"));

        // A custom system advances some state each frame.
        let ticks = Rc::new(Cell::new(0u32));
        let t = ticks.clone();
        app.add_system(Schedule::Update, move |_| t.set(t.get() + 1));
        app.update(1.0 / 60.0);
        app.update(1.0 / 60.0);
        assert_eq!(ticks.get(), 2);

        // Removing a module removes its registered types/systems cleanly.
        let types_before = app.types.len();
        assert!(app.remove_module("render"));
        assert!(!app.has_module("render"));
        assert!(!app.types.is_registered("MeshRenderer"));
        assert!(app.types.len() < types_before);
        // The app still runs after removal.
        app.update(1.0 / 60.0);
        assert_eq!(ticks.get(), 3);
    }

    #[test]
    fn asset_server_loads_gltf_through_handles_and_dedups() {
        // The bundled cube model, resolved relative to this crate's manifest.
        const CUBE_GLTF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/models/cube.gltf");

        let assets = AssetServer::new(); // glTF loader registered by default
        let a: Handle<GltfModel> = assets.load(CUBE_GLTF);
        assert!(a.is_loaded(), "load failed: {:?}", a.error());
        assert_eq!(a.get().unwrap().meshes.len(), 1);

        // Loading the same path again returns a handle to the one shared asset.
        let b: Handle<GltfModel> = assets.load(CUBE_GLTF);
        assert_eq!(a.id(), b.id());
        assert_eq!(a.ref_count(), 2);
        assert_eq!(assets.live_asset_count(), 1);

        // Dropping every handle frees it.
        drop(a);
        drop(b);
        assert_eq!(assets.live_asset_count(), 0);
    }

    #[test]
    fn layer_and_tags_attach_to_scene_entities() {
        // The Layer component (single-value membership) and Tags (multi-valued
        // gameplay tags) are ordinary components: they ride on entities
        // through the ECS and resolve independently of the transform hierarchy.
        let mut scene = Scene::new();
        let e = scene.spawn("guard", Transform::IDENTITY);
        // Move the entity onto layer 2 (Scene::spawn auto-attaches Layer::DEFAULT
        // so insert_one replaces it).
        scene.world_mut().insert_one(e, Layer::on(2)).unwrap();
        scene
            .world_mut()
            .insert_one(e, Tags::single("Enemy"))
            .unwrap();

        assert_eq!(scene.get::<Layer>(e).unwrap().index, 2);
        assert!(scene.get::<Tags>(e).unwrap().contains("Enemy"));

        // A query filter selecting layer 2 picks this entity up.
        let filter = LayerMask::layer(2);
        let matched: Vec<_> = scene
            .world()
            .query::<&Layer>()
            .iter()
            .filter(|(_, layer)| layer.matches(filter))
            .map(|(entity, _)| entity)
            .collect();
        assert_eq!(matched, vec![e]);
    }
}

#[cfg(test)]
mod stage6 {
    //! Stage 6 — Editor Framework & Project System.

    use oxide_engine::asset::{AssetError, AssetLoader, AssetServer};
    use oxide_engine::prelude::*;
    use std::path::{Path, PathBuf};
    use std::time::{Duration, Instant};

    /// Tiny text asset + loader used by the asset-reload integration tests.
    /// Kept inline so each Stage-6 reload test can stand on its own.
    #[derive(Debug, PartialEq, Eq)]
    struct TextAsset(String);

    struct TextLoader;
    impl AssetLoader for TextLoader {
        type Asset = TextAsset;
        fn extensions(&self) -> &'static [&'static str] {
            &["oxtxt"]
        }
        fn load(&self, path: &Path) -> Result<TextAsset, AssetError> {
            let text = std::fs::read_to_string(path).map_err(|e| AssetError::Load {
                path: path.to_path_buf(),
                message: e.to_string(),
            })?;
            Ok(TextAsset(text.trim().to_string()))
        }
    }

    fn temp_path(label: &str, ext: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!("oxide_stage6_{label}_{}.{ext}", std::process::id()));
        p
    }

    #[test]
    fn project_lifecycle_through_public_api() {
        let mut root = std::env::temp_dir();
        root.push(format!("oxide_stage6_project_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);

        // Create scaffolds the folder layout and writes the project file.
        let mut project = Project::create(&root, "Demo").unwrap();
        assert!(project.scenes_dir().is_dir());
        project.enable_module("render");
        project.set_settings_section("editor", "(theme:\"dark\")");
        project.save().unwrap();

        // Reopening restores name, modules, and settings.
        let reopened = Project::open(&root).unwrap();
        assert_eq!(reopened.name(), "Demo");
        assert!(reopened.is_module_enabled("render"));
        assert_eq!(
            reopened.settings_section("editor"),
            Some("(theme:\"dark\")")
        );

        std::fs::remove_dir_all(root).ok();
    }

    /// Path-keyed reload reaches the right typed handle without the caller
    /// naming the asset type — the foundation the file-watcher's
    /// `reload_changed_assets` builds on.
    #[test]
    fn asset_server_reload_path_updates_existing_handles() {
        let server = AssetServer::empty();
        server.register_loader(TextLoader);

        let path = temp_path("reload_path", "oxtxt");
        std::fs::write(&path, "before").unwrap();
        let handle = server.load::<TextAsset>(&path);
        assert_eq!(handle.get().unwrap().0, "before");

        std::fs::write(&path, "after").unwrap();
        let reloaded = server.reload_path(&path);
        assert_eq!(reloaded, 1);
        assert_eq!(handle.get().unwrap().0, "after");

        // Paths with no cached entry report zero work; no panic, no extra load.
        let absent = temp_path("reload_path_absent", "oxtxt");
        assert_eq!(server.reload_path(&absent), 0);

        std::fs::remove_file(path).ok();
    }

    /// End-to-end: editing a watched file produces an event that, fed through
    /// `reload_changed_assets`, updates the live handle. Tolerant of slow or
    /// disabled FS-event backends — skips rather than fails flakily.
    #[test]
    fn watcher_event_drives_asset_reload() {
        let server = AssetServer::empty();
        server.register_loader(TextLoader);

        let mut dir = std::env::temp_dir();
        dir.push(format!("oxide_stage6_watch_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("note.oxtxt");
        std::fs::write(&path, "before").unwrap();

        let handle = server.load::<TextAsset>(&path);
        assert_eq!(handle.get().unwrap().0, "before");

        let (mut watcher, events) = FileWatcher::new(Duration::from_millis(80)).expect("watcher");
        watcher.watch(&dir).expect("watch");

        // Small delay so the backend has registered the watch before we write.
        std::thread::sleep(Duration::from_millis(50));
        std::fs::write(&path, "after").unwrap();

        let deadline = Instant::now() + Duration::from_secs(3);
        let mut reloaded = false;
        while Instant::now() < deadline {
            if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) {
                if ev.path.file_name() == path.file_name() {
                    let n = reload_changed_assets(&server, std::iter::once(ev));
                    if n > 0 && handle.get().unwrap().0 == "after" {
                        reloaded = true;
                        break;
                    }
                }
            }
        }

        std::fs::remove_dir_all(&dir).ok();
        if !reloaded {
            eprintln!("SKIP: filesystem-event backend did not deliver a usable event in time");
        }
    }

    /// Stage-6 criterion: "a trivial test module adds a menu item, a panel,
    /// and a settings page purely through the extension API — with no edits to
    /// editor core". The same module also pairs an engine-side `Module` impl,
    /// proving the two halves coexist on one struct and stay enable/disable-
    /// consistent through a shared name.
    #[test]
    fn external_module_extends_editor_through_public_api_only() {
        use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};
        use oxide_engine::app::{App, Module};
        use serde::{Deserialize, Serialize};

        #[derive(Serialize, Deserialize, Default)]
        struct AudioPrefs {
            volume: f32,
        }

        struct AudioPreviewModule;

        impl Module for AudioPreviewModule {
            fn name(&self) -> &'static str {
                "audio_preview"
            }
            fn build(&self, app: &mut App) {
                app.register_type::<AudioPrefs>("AudioPrefs");
            }
        }

        impl EditorModule for AudioPreviewModule {
            fn name(&self) -> &'static str {
                "audio_preview"
            }
            fn build_editor(&self, ext: &mut EditorExtensions) {
                ext.add_menu_item("Audio/Preview", || {});
                ext.add_panel("Audio Preview", DockLocation::Bottom, |_ui| {});
                ext.add_settings_page("audio_preview", "Audio Preview", |_ui| {});
            }
        }

        // Engine side: module registers its type, can be enabled/disabled.
        let mut app = App::new();
        app.add_module(AudioPreviewModule);
        assert!(app.has_module("audio_preview"));
        assert!(app.types.is_registered("AudioPrefs"));

        // Editor side: same struct, same name; contributions show up under
        // the lookups the docking shell will use in piece 6.
        let mut ext = EditorExtensions::new();
        ext.add_module(AudioPreviewModule);
        assert!(ext.has_module("audio_preview"));
        assert_eq!(
            ext.menu_item_paths().collect::<Vec<_>>(),
            vec!["Audio/Preview"]
        );
        assert_eq!(ext.panel_names().collect::<Vec<_>>(), vec!["Audio Preview"]);
        assert!(ext.has_settings_page_for("audio_preview"));

        // Disabling in editor hides UI but engine module still owns its
        // registrations until removed — Preferences toggles only the visible
        // half; full enable/disable wires both sides together in piece 6.
        ext.set_module_enabled("audio_preview", false);
        assert_eq!(ext.menu_item_paths().count(), 0);
        ext.set_module_enabled("audio_preview", true);
        assert_eq!(ext.panel_names().count(), 1);

        // Removing on either side is symmetric and fully reverses
        // registration.
        assert!(ext.remove_module("audio_preview"));
        assert!(app.remove_module("audio_preview"));
        assert!(!ext.has_module("audio_preview"));
        assert!(!app.has_module("audio_preview"));
        assert!(!app.types.is_registered("AudioPrefs"));
    }
}

#[cfg(test)]
mod stage4 {
    //! Stage 4 — Basic 3D Rendering.
    //!
    //! The editor viewport and visual fidelity need a human eye, but the core
    //! forward-rendering path *can* be verified headlessly: render meshes to an
    //! offscreen target and read pixels back to confirm (a) lit geometry appears
    //! where expected, (b) the background shows through elsewhere, and (c) depth
    //! ordering lets a near object occlude a farther one.

    use oxide_engine::prelude::*;
    use oxide_engine::render::clear_view;
    use oxide_engine::wgpu;

    const SIZE: u32 = 64; // 64 px * 4 bytes = 256-byte aligned rows, no padding.

    /// An offscreen color target plus a CPU readback path.
    struct Target {
        texture: wgpu::Texture,
        view: wgpu::TextureView,
    }

    impl Target {
        fn new(device: &wgpu::Device) -> Self {
            let texture = device.create_texture(&wgpu::TextureDescriptor {
                label: Some("stage4.color"),
                size: wgpu::Extent3d {
                    width: SIZE,
                    height: SIZE,
                    depth_or_array_layers: 1,
                },
                mip_level_count: 1,
                sample_count: 1,
                dimension: wgpu::TextureDimension::D2,
                format: wgpu::TextureFormat::Rgba8Unorm,
                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
                view_formats: &[],
            });
            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
            Self { texture, view }
        }

        /// Reads the whole target back into RGBA8 bytes.
        fn read_pixels(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> {
            let bytes_per_row = SIZE * 4;
            let buffer = device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("stage4.readback"),
                size: (bytes_per_row * SIZE) as u64,
                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
                mapped_at_creation: false,
            });
            let mut encoder =
                device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
            encoder.copy_texture_to_buffer(
                wgpu::TexelCopyTextureInfo {
                    texture: &self.texture,
                    mip_level: 0,
                    origin: wgpu::Origin3d::ZERO,
                    aspect: wgpu::TextureAspect::All,
                },
                wgpu::TexelCopyBufferInfo {
                    buffer: &buffer,
                    layout: wgpu::TexelCopyBufferLayout {
                        offset: 0,
                        bytes_per_row: Some(bytes_per_row),
                        rows_per_image: None,
                    },
                },
                self.texture.size(),
            );
            queue.submit([encoder.finish()]);

            let (tx, rx) = std::sync::mpsc::channel();
            buffer
                .slice(..)
                .map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
            device
                .poll(wgpu::PollType::wait_indefinitely())
                .expect("device poll failed");
            rx.recv().unwrap().expect("buffer mapping failed");
            buffer.slice(..).get_mapped_range().to_vec()
        }
    }

    /// The RGBA bytes of one pixel at (x, y).
    fn pixel(data: &[u8], x: u32, y: u32) -> [u8; 4] {
        let i = ((y * SIZE + x) * 4) as usize;
        [data[i], data[i + 1], data[i + 2], data[i + 3]]
    }

    /// A viewport rect covering the whole `size × size` target — the value
    /// every Stage-4 forward-render call passes now that the renderer takes
    /// an explicit sub-rect.
    fn full_viewport(size: u32) -> oxide_engine::math::Rect {
        oxide_engine::math::Rect::from_min_size(
            oxide_engine::math::Vec2::ZERO,
            oxide_engine::math::Vec2::new(size as f32, size as f32),
        )
    }

    /// A camera 5 units down +Z, looking at the origin.
    fn front_camera() -> (Camera, Transform) {
        (
            Camera::default(),
            Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y),
        )
    }

    /// Light traveling straight into the screen (-Z), so a +Z-facing front face
    /// is fully lit.
    fn front_lighting() -> Lighting {
        Lighting {
            light: DirectionalLight {
                direction: Vec3::NEG_Z,
                color: Color::WHITE,
                intensity: 1.0,
            },
            ambient: Color::rgb(0.05, 0.05, 0.05),
        }
    }

    #[test]
    fn lit_cube_renders_over_background() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());

        let target = Target::new(device);
        let mut renderer = ForwardRenderer::new(device, wgpu::TextureFormat::Rgba8Unorm);
        let cube = Mesh::cube().upload(device, "cube");
        let (camera, view) = front_camera();

        // Blue background, red cube.
        let background = Color::rgb(0.0, 0.0, 1.0);
        clear_view(device, queue, &target.view, background);
        renderer.render(
            device,
            queue,
            &target.view,
            (SIZE, SIZE),
            full_viewport(SIZE),
            &camera,
            &view,
            &front_lighting(),
            &[RenderObject {
                mesh: &cube,
                material: Material::diffuse(Color::RED),
                transform: Transform::IDENTITY,
            }],
        );

        let data = target.read_pixels(device, queue);
        let center = pixel(&data, SIZE / 2, SIZE / 2);
        let corner = pixel(&data, 1, 1);

        // Center: the lit red front face — red channel dominant and bright.
        assert!(
            center[0] > 180 && center[2] < 80,
            "center should be lit red, got {center:?}"
        );
        // Corner: untouched blue background showing through the loaded clear.
        assert_eq!(corner, [0, 0, 255, 255], "corner should be background blue");
    }

    #[test]
    fn near_object_occludes_far_via_depth() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());

        let target = Target::new(device);
        let mut renderer = ForwardRenderer::new(device, wgpu::TextureFormat::Rgba8Unorm);
        let cube = Mesh::cube().upload(device, "cube");
        let (camera, view) = front_camera();
        let lighting = front_lighting();

        // A green cube farther from the camera, a red cube nearer — both on the
        // view axis. Drawn far-first so only depth (not draw order) can make the
        // near red cube win the center pixel.
        let far = RenderObject {
            mesh: &cube,
            material: Material::diffuse(Color::GREEN),
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, -1.5)),
        };
        let near = RenderObject {
            mesh: &cube,
            material: Material::diffuse(Color::RED),
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, 1.0)),
        };

        clear_view(device, queue, &target.view, Color::BLACK);
        renderer.render(
            device,
            queue,
            &target.view,
            (SIZE, SIZE),
            full_viewport(SIZE),
            &camera,
            &view,
            &lighting,
            &[far, near],
        );

        let data = target.read_pixels(device, queue);
        let center = pixel(&data, SIZE / 2, SIZE / 2);
        assert!(
            center[0] > 150 && center[1] < 100,
            "near red cube should occlude the far green one, got {center:?}"
        );
    }

    /// The bundled cube model, resolved relative to this crate's manifest.
    const CUBE_GLTF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/models/cube.gltf");

    #[test]
    fn gltf_cube_imports_geometry_and_material() {
        let model = oxide_engine::prelude::load_gltf(CUBE_GLTF).expect("load cube.gltf");
        assert_eq!(model.meshes.len(), 1, "cube has one primitive");
        let m = &model.meshes[0];
        assert_eq!(m.mesh.vertices.len(), 8, "cube has 8 corner vertices");
        assert_eq!(m.mesh.triangle_count(), 12, "cube has 12 triangles");
        assert_eq!(model.triangle_count(), 12);
        // Normals were omitted in the asset, so the importer generated them.
        for v in &m.mesh.vertices {
            let n = Vec3::from_array(v.normal);
            assert!(
                (n.length() - 1.0).abs() < 1e-3,
                "generated normal not unit: {n}"
            );
        }
        // Base color factor from the glTF material (orange).
        let a = m.material.albedo;
        assert!(
            a.r > 0.8 && a.g > 0.4 && a.g < 0.5 && a.b < 0.2,
            "albedo {a:?}"
        );
        assert!(m.material.metallic < 0.01);
    }

    #[test]
    fn gltf_cube_renders() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());

        let model = oxide_engine::prelude::load_gltf(CUBE_GLTF).expect("load cube.gltf");
        let uploaded: Vec<_> = model
            .meshes
            .iter()
            .map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
            .collect();
        let objects: Vec<RenderObject> = uploaded
            .iter()
            .map(|(mesh, material, transform)| RenderObject {
                mesh,
                material: *material,
                transform: *transform,
            })
            .collect();

        let target = Target::new(device);
        let mut renderer = ForwardRenderer::new(device, wgpu::TextureFormat::Rgba8Unorm);
        let (camera, view) = front_camera();
        clear_view(device, queue, &target.view, Color::BLACK);
        renderer.render(
            device,
            queue,
            &target.view,
            (SIZE, SIZE),
            full_viewport(SIZE),
            &camera,
            &view,
            &front_lighting(),
            &objects,
        );

        let data = target.read_pixels(device, queue);
        let center = pixel(&data, SIZE / 2, SIZE / 2);
        // The lit orange front face: red dominant, then green, then blue.
        assert!(
            center[0] > 180 && center[0] > center[1] && center[1] > center[2] && center[2] < 90,
            "cube front face should be lit orange, got {center:?}"
        );
    }

    /// Sphere primitive must be visible from outside — i.e. its triangles
    /// must wind CCW in framebuffer coords (the wgpu front-face convention).
    /// If this test fails after a regression, back-face culling is eating
    /// the sphere's surface and the maintainer will see the inside of
    /// other intersecting meshes through it.
    #[test]
    fn lit_sphere_renders_over_background() {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };
        let (device, queue) = (gpu.device(), gpu.queue());

        let target = Target::new(device);
        let mut renderer = ForwardRenderer::new(device, wgpu::TextureFormat::Rgba8Unorm);
        let sphere = Mesh::uv_sphere(1.0, 32, 16).upload(device, "sphere");
        let (camera, view) = front_camera();

        let background = Color::rgb(0.0, 0.0, 1.0);
        clear_view(device, queue, &target.view, background);
        renderer.render(
            device,
            queue,
            &target.view,
            (SIZE, SIZE),
            full_viewport(SIZE),
            &camera,
            &view,
            &front_lighting(),
            &[RenderObject {
                mesh: &sphere,
                material: Material::diffuse(Color::RED),
                transform: Transform::IDENTITY,
            }],
        );

        let data = target.read_pixels(device, queue);
        let center = pixel(&data, SIZE / 2, SIZE / 2);
        // Center of the framebuffer is the front of the sphere. If
        // culling ate the front-facing triangles, this pixel would still
        // be the blue background. The test fires only when the sphere's
        // surface is visible AND lit (red dominant, not background blue).
        assert!(
            center[0] > 150 && center[2] < 100,
            "sphere center should be lit red, got {center:?} (background blue means \
             the sphere's front-facing triangles got culled — winding is reversed)"
        );
    }
}

#[cfg(test)]
mod stage7 {
    //! Stage 7 — Input System.
    //!
    //! Piece 1: raw [`InputState`] with per-key/button edge detection,
    //! cursor + delta tracking, and scroll accumulation, exercised through
    //! the public mutator API the engine runner uses to pump events.
    //!
    //! Piece 2: [`ActionMap`] — named button actions with defaults, runtime
    //! remapping, and RON-persistable overrides flowing through the
    //! Stage-6 [`Settings`] framework.
    //!
    //! Piece 3: [`AxisBinding`] / [`Axis2DBinding`] — directional inputs
    //! composed from button bindings, registered alongside button actions
    //! and persisted through the same overrides payload. Later pieces
    //! (editor flythrough camera, bindings page, gizmos) extend `stage7`.

    use oxide_engine::math::Vec2;
    use oxide_engine::prelude::*;
    use oxide_engine::winit::event::MouseButton;
    use oxide_engine::winit::keyboard::KeyCode;

    /// One full "game frame" worth of input: edges fire on the frame the
    /// event occurred and clear afterwards; held state persists.
    #[test]
    fn key_edges_fire_exactly_once_per_event() {
        let mut input = InputState::new();

        // Frame 1: press Space — pressed edge fires once, held becomes true.
        input.press_key(KeyCode::Space);
        assert!(input.pressed(KeyCode::Space));
        assert!(input.held(KeyCode::Space));
        input.end_frame();

        // Frame 2: held only — pressed edge gone, held persists.
        assert!(!input.pressed(KeyCode::Space));
        assert!(input.held(KeyCode::Space));
        input.end_frame();

        // Frame 3: release Space — released edge fires, held clears.
        input.release_key(KeyCode::Space);
        assert!(input.released(KeyCode::Space));
        assert!(!input.held(KeyCode::Space));
        input.end_frame();

        // Frame 4: idle — every query is false.
        assert!(!input.pressed(KeyCode::Space));
        assert!(!input.released(KeyCode::Space));
        assert!(!input.held(KeyCode::Space));
    }

    /// Mouse delta is the criterion called out explicitly in the Stage-7
    /// plan: "Mouse delta is correct across frame boundaries (no jump on
    /// first frame)."
    #[test]
    fn mouse_delta_is_zero_on_first_frame_and_accurate_after() {
        let mut input = InputState::new();

        // Frame 1: cursor enters at (100, 100). No prior position → no delta.
        input.set_cursor(Vec2::new(100.0, 100.0));
        assert_eq!(
            input.mouse_delta(),
            Vec2::ZERO,
            "first cursor event must not produce a phantom delta"
        );
        input.end_frame();

        // Frame 2: cursor moves to (108, 95) over two events. Delta is the
        // sum of segment vectors, not the gross distance from frame start.
        input.set_cursor(Vec2::new(105.0, 98.0));
        input.set_cursor(Vec2::new(108.0, 95.0));
        assert_eq!(input.mouse_delta(), Vec2::new(8.0, -5.0));
        input.end_frame();

        // Frame 3: no movement. Delta is zero again, cursor persists.
        assert_eq!(input.mouse_delta(), Vec2::ZERO);
        assert_eq!(input.cursor(), Some(Vec2::new(108.0, 95.0)));
    }

    /// Multiple physical buttons can be held independently and their edges
    /// reported per-button — the foundation piece 2 builds named actions on.
    #[test]
    fn independent_keys_and_mouse_buttons_track_separately() {
        let mut input = InputState::new();

        input.press_key(KeyCode::KeyW);
        input.press_key(KeyCode::ShiftLeft);
        input.press_mouse(MouseButton::Right);

        assert!(input.held(KeyCode::KeyW));
        assert!(input.held(KeyCode::ShiftLeft));
        assert!(input.mouse_held(MouseButton::Right));
        assert!(!input.held(KeyCode::KeyS));
        assert!(!input.mouse_held(MouseButton::Left));

        input.end_frame();
        input.release_key(KeyCode::KeyW);

        assert!(input.released(KeyCode::KeyW));
        assert!(!input.held(KeyCode::KeyW));
        assert!(
            input.held(KeyCode::ShiftLeft),
            "releasing one key must not affect another"
        );
        assert!(input.mouse_held(MouseButton::Right));
    }

    /// Scroll accumulates within a frame and resets at the boundary, so a
    /// camera zoom sees the total wheel travel for the frame even if the
    /// OS delivered it as several smaller wheel events.
    #[test]
    fn scroll_accumulates_within_a_frame() {
        let mut input = InputState::new();
        input.add_scroll(0.0, 0.5);
        input.add_scroll(0.0, 0.5);
        input.add_scroll(0.0, -0.25);
        assert_eq!(input.scroll(), Vec2::new(0.0, 0.75));

        input.end_frame();
        assert_eq!(input.scroll(), Vec2::ZERO);
    }

    // --- Piece 2: ActionMap (named actions with remapping + persistence) ---

    /// The PLAN's test criteria distilled into one end-to-end scenario:
    /// game code only ever queries `"Jump"`; the action is remapped at
    /// runtime; the remap survives a RON round-trip through the Stage-6
    /// settings framework; and "restore defaults" undoes the remap.
    #[test]
    fn action_remap_round_trips_through_settings_and_restores() {
        // The program registers its default bindings at startup.
        let mut actions = ActionMap::new();
        actions.register("Jump", [Binding::Key(KeyCode::Space)]);
        actions.register("Fire", [Binding::Mouse(MouseButton::Left)]);

        // Default behavior: Space drives Jump.
        let mut input = InputState::new();
        input.press_key(KeyCode::Space);
        assert!(actions.action_pressed("Jump", &input));
        input.end_frame();
        input.release_key(KeyCode::Space);
        input.end_frame();

        // The settings screen remaps Jump → W; game code is untouched.
        actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);

        // Persist via the Stage-6 settings framework. ActionOverrides is the
        // settings-section type — no special framework code is needed.
        let mut settings = Settings::new();
        settings.register::<ActionOverrides>("input.bindings");
        *settings
            .get_mut::<ActionOverrides>("input.bindings")
            .unwrap() = actions.overrides();
        let saved: std::collections::BTreeMap<String, String> = settings.export();

        // Simulate a relaunch: fresh program, fresh defaults, then load.
        let mut actions = ActionMap::new();
        actions.register("Jump", [Binding::Key(KeyCode::Space)]);
        actions.register("Fire", [Binding::Mouse(MouseButton::Left)]);
        let mut settings = Settings::new();
        settings.register::<ActionOverrides>("input.bindings");
        settings.import(&saved);
        actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());

        // The remap survived: pressing W (not Space) now drives Jump.
        let mut input = InputState::new();
        input.press_key(KeyCode::Space);
        assert!(
            !actions.action_held("Jump", &input),
            "Space must not drive Jump after the remap was reloaded"
        );
        input.release_key(KeyCode::Space);
        input.end_frame();

        input.press_key(KeyCode::KeyW);
        assert!(actions.action_pressed("Jump", &input));

        // Restore defaults — Space drives Jump again, game code unchanged.
        input.release_key(KeyCode::KeyW);
        input.end_frame();
        actions.restore_defaults("Jump");
        input.press_key(KeyCode::Space);
        assert!(actions.action_pressed("Jump", &input));
    }

    /// End-to-end for piece 3: a WASD 2D axis is registered with code-
    /// defined defaults, remapped to arrow keys, persisted through the
    /// Stage-6 settings framework, reloaded, and queried — all without
    /// gameplay code changing the axis name.
    #[test]
    fn axis_2d_remap_round_trips_through_settings() {
        let wasd = || {
            Axis2DBinding::new(
                [Binding::Key(KeyCode::KeyD)],
                [Binding::Key(KeyCode::KeyA)],
                [Binding::Key(KeyCode::KeyW)],
                [Binding::Key(KeyCode::KeyS)],
            )
        };
        let arrows = || {
            Axis2DBinding::new(
                [Binding::Key(KeyCode::ArrowRight)],
                [Binding::Key(KeyCode::ArrowLeft)],
                [Binding::Key(KeyCode::ArrowUp)],
                [Binding::Key(KeyCode::ArrowDown)],
            )
        };

        // Program startup: register the axis with defaults.
        let mut actions = ActionMap::new();
        actions.register_axis_2d("Move", wasd());

        // Default behavior: W+A reads as (-1, 1) (Y-up).
        let mut input = InputState::new();
        input.press_key(KeyCode::KeyA);
        input.press_key(KeyCode::KeyW);
        assert_eq!(actions.axis_2d("Move", &input), Vec2::new(-1.0, 1.0));

        // User remaps to arrow keys; persist via settings.
        actions.set_axis_2d_bindings("Move", arrows());

        let mut settings = Settings::new();
        settings.register::<ActionOverrides>("input.bindings");
        *settings
            .get_mut::<ActionOverrides>("input.bindings")
            .unwrap() = actions.overrides();
        let on_disk = settings.export();

        // Simulate a relaunch: defaults from code, then load overrides.
        let mut actions = ActionMap::new();
        actions.register_axis_2d("Move", wasd());
        let mut settings = Settings::new();
        settings.register::<ActionOverrides>("input.bindings");
        settings.import(&on_disk);
        actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());

        // After reload: WASD no longer drives "Move"; arrows do.
        let mut input = InputState::new();
        input.press_key(KeyCode::KeyW);
        input.press_key(KeyCode::KeyA);
        assert_eq!(
            actions.axis_2d("Move", &input),
            Vec2::ZERO,
            "WASD must not drive Move after the arrow-key remap was reloaded"
        );

        let mut input = InputState::new();
        input.press_key(KeyCode::ArrowUp);
        input.press_key(KeyCode::ArrowRight);
        assert_eq!(actions.axis_2d("Move", &input), Vec2::new(1.0, 1.0));
    }

    /// One key driving multiple actions, and one action driven by multiple
    /// keys, both work as expected from a single `InputState` snapshot —
    /// the action layer's edge logic is correct under both.
    #[test]
    fn action_multi_binding_topology_works_both_directions() {
        let mut actions = ActionMap::new();
        // Many bindings → one action: WASD-style sprint.
        actions.register(
            "Sprint",
            [
                Binding::Key(KeyCode::ShiftLeft),
                Binding::Key(KeyCode::ShiftRight),
            ],
        );
        // One binding → many actions: Enter both confirms and submits.
        actions.register("Confirm", [Binding::Key(KeyCode::Enter)]);
        actions.register("Submit", [Binding::Key(KeyCode::Enter)]);

        let mut input = InputState::new();
        input.press_key(KeyCode::Enter);
        assert!(actions.action_pressed("Confirm", &input));
        assert!(actions.action_pressed("Submit", &input));

        input.end_frame();
        input.release_key(KeyCode::Enter);
        input.end_frame();

        // Multi-bind: pressing either Shift engages Sprint; releasing the
        // first while the second is still held doesn't release the action.
        input.press_key(KeyCode::ShiftLeft);
        assert!(actions.action_pressed("Sprint", &input));
        input.end_frame();

        input.press_key(KeyCode::ShiftRight);
        assert!(
            !actions.action_pressed("Sprint", &input),
            "second shift while sprint already engaged must not re-fire"
        );
        assert!(actions.action_held("Sprint", &input));
        input.end_frame();

        input.release_key(KeyCode::ShiftLeft);
        assert!(!actions.action_released("Sprint", &input));
        assert!(actions.action_held("Sprint", &input));
        input.end_frame();

        input.release_key(KeyCode::ShiftRight);
        assert!(actions.action_released("Sprint", &input));
        assert!(!actions.action_held("Sprint", &input));
    }
}

#[cfg(test)]
mod stage8 {
    //! Stage 8 — Comprehensive UI System.
    //!
    //! Piece 1 — widget tree + layout. These tests exercise the public layout
    //! API the way examples and (later pieces of) the in-game UI runtime will,
    //! by building a realistic UI document and asserting on resolved screen
    //! rects. They cover the multi-mode composition path the per-file unit
    //! tests can't (a Stack containing an Anchor containing a Grid), plus
    //! the RON round-trip that proves dual-editability.
    //!
    //! Later pieces (styling, text, render pass, input routing, examples) add
    //! their own integration tests under this module.

    use oxide_engine::math::{Color, Rect, Vec2};
    use oxide_engine::ui::{
        layout, Align, Anchor, Border, FontRef, Insets, LayoutStyle, Sizing, StackDirection, Theme,
        VisualStyle, Widget,
    };

    fn viewport(w: f32, h: f32) -> Rect {
        Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
    }

    fn fill() -> LayoutStyle {
        LayoutStyle {
            width: Sizing::Grow(1.0),
            height: Sizing::Grow(1.0),
            ..Default::default()
        }
    }

    /// A toolbar / content / status-bar window layout — the kind of shape the
    /// Stage-8 `ui_menu` example will use. Exercises Column-of-rows, with a
    /// Fixed top, a Grow middle, and a Fixed bottom; the middle row hosts a
    /// nested Row stack with a sidebar and a Grow main area.
    #[test]
    fn three_band_window_layout_assigns_expected_rects() {
        let ui = Widget::column()
            .with_id("root")
            .with_style(fill())
            .with_child(Widget::row().with_id("toolbar").with_style(LayoutStyle {
                width: Sizing::Grow(1.0),
                height: Sizing::Fixed(32.0),
                ..Default::default()
            }))
            .with_child(
                Widget::row()
                    .with_id("body")
                    .with_style(fill())
                    .with_child(
                        Widget::default()
                            .with_id("sidebar")
                            .with_style(LayoutStyle {
                                width: Sizing::Fixed(200.0),
                                height: Sizing::Grow(1.0),
                                ..Default::default()
                            }),
                    )
                    .with_child(Widget::default().with_id("main").with_style(fill())),
            )
            .with_child(Widget::row().with_id("status").with_style(LayoutStyle {
                width: Sizing::Grow(1.0),
                height: Sizing::Fixed(24.0),
                ..Default::default()
            }));

        let tree = layout(&ui, viewport(1024.0, 768.0), 1.0);
        let toolbar = tree.find(&"toolbar".into()).unwrap().rect;
        let body = tree.find(&"body".into()).unwrap().rect;
        let status = tree.find(&"status".into()).unwrap().rect;
        let sidebar = tree.find(&"sidebar".into()).unwrap().rect;
        let main = tree.find(&"main".into()).unwrap().rect;

        assert_eq!(
            toolbar,
            Rect::from_min_size(Vec2::ZERO, Vec2::new(1024.0, 32.0))
        );
        assert_eq!(
            body,
            Rect::from_min_size(Vec2::new(0.0, 32.0), Vec2::new(1024.0, 712.0))
        );
        assert_eq!(
            status,
            Rect::from_min_size(Vec2::new(0.0, 744.0), Vec2::new(1024.0, 24.0))
        );
        assert_eq!(
            sidebar,
            Rect::from_min_size(Vec2::new(0.0, 32.0), Vec2::new(200.0, 712.0))
        );
        assert_eq!(
            main,
            Rect::from_min_size(Vec2::new(200.0, 32.0), Vec2::new(824.0, 712.0))
        );
    }

    /// A HUD layout — fullscreen anchor parent with corner-docked children
    /// (a top bar, a centered crosshair, a bottom-right minimap). Models the
    /// Stage-8 `ui_hud` example.
    #[test]
    fn hud_anchor_layout_docks_to_each_corner() {
        let hud = Widget::anchor()
            .with_id("hud")
            .with_style(fill())
            .with_child(
                Widget::leaf(Vec2::ZERO)
                    .with_id("top_bar")
                    .with_style(LayoutStyle {
                        anchor: Anchor::TOP.with_offsets(Vec2::ZERO, Vec2::new(0.0, 48.0)),
                        ..Default::default()
                    }),
            )
            .with_child(
                Widget::leaf(Vec2::ZERO)
                    .with_id("crosshair")
                    .with_style(LayoutStyle {
                        anchor: Anchor::between(Vec2::splat(0.5), Vec2::splat(0.5))
                            .with_offsets(Vec2::splat(-8.0), Vec2::splat(8.0)),
                        ..Default::default()
                    }),
            )
            .with_child(
                Widget::leaf(Vec2::ZERO)
                    .with_id("minimap")
                    .with_style(LayoutStyle {
                        anchor: Anchor::BOTTOM_RIGHT
                            .with_offsets(Vec2::new(-200.0, -200.0), Vec2::ZERO),
                        ..Default::default()
                    }),
            );

        let tree = layout(&hud, viewport(1280.0, 720.0), 1.0);
        let top_bar = tree.find(&"top_bar".into()).unwrap().rect;
        let crosshair = tree.find(&"crosshair".into()).unwrap().rect;
        let minimap = tree.find(&"minimap".into()).unwrap().rect;

        assert_eq!(
            top_bar,
            Rect::from_min_size(Vec2::ZERO, Vec2::new(1280.0, 48.0))
        );
        // Crosshair: pin at center, offsets pull min by -8 and push max by +8
        // → a 16×16 square centered on (640, 360).
        assert_eq!(
            crosshair,
            Rect::new(Vec2::new(632.0, 352.0), Vec2::new(648.0, 368.0))
        );
        assert_eq!(
            minimap,
            Rect::from_min_size(Vec2::new(1080.0, 520.0), Vec2::new(200.0, 200.0))
        );
    }

    /// A keybindings grid — what the Stage-7 bindings preferences page would
    /// look like rebuilt as a Stage-8 in-game UI document. Two columns
    /// (action name | binding label), four rows, gap 4px. The action labels
    /// align Start, the binding labels align End within their cells.
    #[test]
    fn keybindings_grid_aligns_labels() {
        let actions = ["Move Forward", "Move Back", "Jump", "Sprint"];
        let bindings = ["W", "S", "Space", "LShift"];

        let mut grid = Widget::grid(2, 4)
            .with_id("bindings")
            .with_style(LayoutStyle {
                width: Sizing::Fixed(400.0),
                height: Sizing::Fixed(100.0),
                ..Default::default()
            })
            .with_grid_gap(Vec2::new(4.0, 4.0));
        for i in 0..4 {
            grid = grid
                .with_child(
                    Widget::leaf(Vec2::new(180.0, 20.0))
                        .with_id(actions[i])
                        .with_style(LayoutStyle {
                            align_horizontal: Align::Start,
                            align_vertical: Align::Center,
                            ..Default::default()
                        }),
                )
                .with_child(
                    Widget::leaf(Vec2::new(60.0, 20.0))
                        .with_id(bindings[i])
                        .with_style(LayoutStyle {
                            align_horizontal: Align::End,
                            align_vertical: Align::Center,
                            ..Default::default()
                        }),
                );
        }

        let tree = layout(&grid, viewport(800.0, 600.0), 1.0);
        // (400 - 4 gap) / 2 = 198 per column.
        let action_label = tree.find(&"Move Forward".into()).unwrap().rect;
        assert_eq!(action_label.min.x, 0.0); // Start within its 198-wide cell.
        assert_eq!(action_label.width(), 180.0);

        let binding_label = tree.find(&"W".into()).unwrap().rect;
        // End-aligned 60-wide in a 198-wide cell starting at x=202 (198+4).
        assert_eq!(binding_label.min.x, 202.0 + (198.0 - 60.0));
        assert_eq!(binding_label.max.x, 400.0);
    }

    /// DPI scaling: every length in the widget tree multiplies by the layout
    /// scale factor, producing identical *relative* layout at 1× and 2×.
    #[test]
    fn dpi_scale_doubles_every_length() {
        let ui = Widget::row()
            .with_id("row")
            .with_style(LayoutStyle {
                width: Sizing::Grow(1.0),
                height: Sizing::Fixed(40.0),
                padding: Insets::all(8.0),
                ..Default::default()
            })
            .with_gap(6.0)
            .with_child(Widget::leaf(Vec2::new(60.0, 20.0)).with_id("a"))
            .with_child(Widget::leaf(Vec2::new(60.0, 20.0)).with_id("b"));

        let at_1x = layout(&ui, viewport(800.0, 600.0), 1.0);
        let at_2x = layout(&ui, viewport(1600.0, 1200.0), 2.0);

        let row_1x = at_1x.find(&"row".into()).unwrap().rect;
        let row_2x = at_2x.find(&"row".into()).unwrap().rect;
        assert_eq!(row_1x.size() * 2.0, row_2x.size());

        let a_1x = at_1x.find(&"a".into()).unwrap().rect;
        let a_2x = at_2x.find(&"a".into()).unwrap().rect;
        assert_eq!(a_1x.size() * 2.0, a_2x.size());

        let b_1x = at_1x.find(&"b".into()).unwrap().rect;
        let b_2x = at_2x.find(&"b".into()).unwrap().rect;
        // Gap between A and B should also have doubled: (b.x - a.max.x) doubles.
        let gap_1x = b_1x.min.x - a_1x.max.x;
        let gap_2x = b_2x.min.x - a_2x.max.x;
        assert!((gap_2x - gap_1x * 2.0).abs() < 1e-3);
    }

    /// Dual-editability: a non-trivial UI document round-trips through RON
    /// with no field loss, so an external editor or AI agent can edit the same
    /// file the runtime loads.
    #[test]
    fn ui_document_round_trips_through_ron() {
        let doc =
            Widget::stack(StackDirection::Column)
                .with_id("settings")
                .with_style(fill())
                .with_gap(8.0)
                .with_child(
                    Widget::row()
                        .with_id("header")
                        .with_style(LayoutStyle {
                            width: Sizing::Grow(1.0),
                            height: Sizing::Fixed(48.0),
                            padding: Insets::symmetric(16.0, 0.0),
                            ..Default::default()
                        })
                        .with_main_align(Align::Center)
                        .with_child(Widget::leaf(Vec2::new(160.0, 32.0)).with_id("title")),
                )
                .with_child(
                    Widget::grid(2, 3)
                        .with_id("rows")
                        .with_grid_gap(Vec2::new(4.0, 4.0))
                        .with_style(fill())
                        .with_children((0..6).map(|i| {
                            Widget::leaf(Vec2::new(80.0, 24.0)).with_id(format!("cell-{i}"))
                        })),
                );

        let text = doc.to_ron().unwrap();
        let decoded = Widget::from_ron(&text).unwrap();
        assert_eq!(doc, decoded);

        // And the laid-out trees match too — the round-trip is layout-stable.
        let tree_a = layout(&doc, viewport(640.0, 480.0), 1.0);
        let tree_b = layout(&decoded, viewport(640.0, 480.0), 1.0);
        assert_eq!(tree_a, tree_b);
    }

    /// Piece-2 theme cascade end-to-end: a UI document with three buttons
    /// (default styling, "button" theme style, "button" theme style + a
    /// per-instance background override) resolves under one theme to three
    /// different visual styles, and the document + theme each round-trip
    /// through RON.
    #[test]
    fn themed_buttons_resolve_to_distinct_visuals() {
        let theme = Theme::new()
            .with_default(VisualStyle {
                foreground: Some(Color::BLACK),
                background: Some(Color::WHITE),
                font: Some(FontRef::regular("Inter")),
                font_size: Some(14.0),
                ..VisualStyle::EMPTY
            })
            .with_style(
                "button",
                VisualStyle {
                    background: Some(Color::rgb(0.85, 0.85, 0.9)),
                    border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
                    corner_radius: Some(4.0),
                    ..VisualStyle::EMPTY
                },
            );

        let doc = Widget::row()
            .with_id("row")
            .with_gap(8.0)
            .with_child(
                // Plain button — picks up only the theme's default.
                Widget::leaf(Vec2::new(80.0, 24.0)).with_id("plain"),
            )
            .with_child(
                // Theme-styled button — picks up default + "button".
                Widget::leaf(Vec2::new(80.0, 24.0))
                    .with_id("themed")
                    .with_theme_style("button"),
            )
            .with_child(
                // Themed + per-instance override.
                Widget::leaf(Vec2::new(80.0, 24.0))
                    .with_id("primary")
                    .with_theme_style("button")
                    .with_visual(VisualStyle {
                        background: Some(Color::rgb(0.2, 0.4, 0.8)),
                        foreground: Some(Color::WHITE),
                        ..VisualStyle::EMPTY
                    }),
            );

        let plain = doc.children()[0].resolve_visual(&theme);
        let themed = doc.children()[1].resolve_visual(&theme);
        let primary = doc.children()[2].resolve_visual(&theme);

        // Plain inherits both colors from the theme default.
        assert_eq!(plain.foreground, Some(Color::BLACK));
        assert_eq!(plain.background, Some(Color::WHITE));
        assert_eq!(plain.border, None);

        // Themed picks up the named-style background + border but keeps
        // the default foreground.
        assert_eq!(themed.foreground, Some(Color::BLACK));
        assert_eq!(themed.background, Some(Color::rgb(0.85, 0.85, 0.9)));
        assert_eq!(
            themed.border,
            Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0))
        );

        // Primary overrides the named background + foreground but inherits
        // the border + corner radius.
        assert_eq!(primary.foreground, Some(Color::WHITE));
        assert_eq!(primary.background, Some(Color::rgb(0.2, 0.4, 0.8)));
        assert_eq!(primary.corner_radius, Some(4.0));

        // Round-trip: theme + document both survive RON dual-edit.
        let theme_decoded = Theme::from_ron(&theme.to_ron().unwrap()).unwrap();
        let doc_decoded = Widget::from_ron(&doc.to_ron().unwrap()).unwrap();
        assert_eq!(theme, theme_decoded);
        assert_eq!(doc, doc_decoded);

        // Resolving against the decoded theme yields the same visuals.
        let primary_after = doc_decoded.children()[2].resolve_visual(&theme_decoded);
        assert_eq!(primary_after, primary);
    }

    /// Visual style + layout are orthogonal: adding a per-instance
    /// `VisualStyle` to a widget does not change its laid-out rect.
    #[test]
    fn visual_style_does_not_affect_layout() {
        let plain = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a");
        let styled = plain
            .clone()
            .with_theme_style("button")
            .with_visual(VisualStyle {
                background: Some(Color::RED),
                border: Some(Border::new(Color::BLACK, 2.0)),
                corner_radius: Some(8.0),
                ..VisualStyle::EMPTY
            });
        let tree_a = layout(&plain, viewport(200.0, 100.0), 1.0);
        let tree_b = layout(&styled, viewport(200.0, 100.0), 1.0);
        assert_eq!(
            tree_a.find(&"a".into()).unwrap().rect,
            tree_b.find(&"a".into()).unwrap().rect
        );
    }

    /// Piece-3 end-to-end: shape a multi-line HUD-style string, walk the
    /// shaped output, pull each glyph through the atlas, and assert that
    /// the cache absorbs the second frame entirely (i.e., the rasterizer is
    /// never invoked again after warm-up). This is the property that makes
    /// HUD-heavy games viable on either ab_glyph or fontdue — the discussion
    /// promised in the design notes, validated here.
    #[test]
    fn shaped_hud_text_is_cached_after_one_frame() {
        use oxide_engine::ui::text::{
            common_system_font_paths, shape, Font, FontStore, GlyphAtlas, ShapeParams, TextStyle,
        };

        // Skip if no system font (CI sandbox without /usr/share/fonts).
        let font = (|| {
            for path in common_system_font_paths() {
                if std::path::Path::new(path).exists() {
                    if let Ok(font) = Font::from_path(path) {
                        return Some(font);
                    }
                }
            }
            None
        })();
        let Some(font) = font else {
            eprintln!("SKIP: no system font available for text integration test");
            return;
        };

        let mut fonts = FontStore::new();
        let id = fonts.insert(font);
        let style = TextStyle {
            font: id,
            size_px: 14.0,
        };
        let params = ShapeParams {
            max_width: Some(220.0),
            ..ShapeParams::default()
        };

        let hud = "HP: 1234 / 1500\nAmmo: 85 / 200\nScore: 0042";
        let shaped = shape(hud, style, &params, &fonts);
        assert_eq!(shaped.lines.len(), 3);

        // Frame 1: route every glyph through the atlas — this populates the
        // cache. Rasterization happens here.
        let mut atlas = GlyphAtlas::new(512, 512);
        for line in &shaped.lines {
            for glyph in &line.glyphs {
                let _ = atlas.get_or_rasterize(glyph.key, &fonts);
            }
        }
        let cached_after_frame_1 = atlas.len();
        assert!(cached_after_frame_1 > 0);
        assert!(atlas.dirty());
        atlas.clear_dirty();

        // Frame 2: re-shape the same text, re-walk the same glyphs. Every
        // lookup is a cache hit, nothing rasterizes, the dirty flag stays
        // false.
        let shaped_2 = shape(hud, style, &params, &fonts);
        for line in &shaped_2.lines {
            for glyph in &line.glyphs {
                let _ = atlas.get_or_rasterize(glyph.key, &fonts);
            }
        }
        assert_eq!(atlas.len(), cached_after_frame_1);
        assert!(
            !atlas.dirty(),
            "second frame must produce zero new rasterizations"
        );

        // Now imagine the player gained one HP: HP changes from 1234 to
        // 1235. The '5' glyph is already in the atlas (it appeared in
        // "1500"). Still no new rasterizations.
        let updated = shape(
            "HP: 1235 / 1500\nAmmo: 85 / 200\nScore: 0042",
            style,
            &params,
            &fonts,
        );
        for line in &updated.lines {
            for glyph in &line.glyphs {
                let _ = atlas.get_or_rasterize(glyph.key, &fonts);
            }
        }
        assert!(
            !atlas.dirty(),
            "HUD updates that reuse known glyphs must not re-rasterize"
        );
    }

    /// DPI scaling end-to-end: shaping the same text at 2× scale doubles
    /// every line metric, and produces distinct atlas keys (different
    /// `size_px`) so the two sizes coexist in one atlas.
    #[test]
    fn dpi_scale_doubles_shape_metrics_and_creates_distinct_atlas_keys() {
        use oxide_engine::ui::text::{
            common_system_font_paths, shape, Font, FontStore, GlyphAtlas, ShapeParams, TextStyle,
        };

        let font = (|| {
            for path in common_system_font_paths() {
                if std::path::Path::new(path).exists() {
                    if let Ok(font) = Font::from_path(path) {
                        return Some(font);
                    }
                }
            }
            None
        })();
        let Some(font) = font else {
            eprintln!("SKIP: no system font available for text integration test");
            return;
        };

        let mut fonts = FontStore::new();
        let id = fonts.insert(font);
        let style = TextStyle {
            font: id,
            size_px: 16.0,
        };

        let at_1x = shape("Hello", style, &ShapeParams::default(), &fonts);
        let at_2x = shape(
            "Hello",
            style,
            &ShapeParams {
                scale: 2.0,
                ..ShapeParams::default()
            },
            &fonts,
        );

        // Widths and baselines double (within a hair from rounding).
        let w_ratio = at_2x.lines[0].width / at_1x.lines[0].width;
        assert!((w_ratio - 2.0).abs() < 0.05);

        // Atlas keys carry the post-scale pixel size, so 1× and 2× produce
        // distinct cache entries even though the TextStyle is identical.
        let mut atlas = GlyphAtlas::new(512, 512);
        for g in &at_1x.lines[0].glyphs {
            let _ = atlas.get_or_rasterize(g.key, &fonts);
        }
        let after_1x = atlas.len();
        for g in &at_2x.lines[0].glyphs {
            let _ = atlas.get_or_rasterize(g.key, &fonts);
        }
        // Five glyphs at each size → ten distinct entries.
        assert_eq!(atlas.len(), after_1x * 2);
    }

    /// Piece-4 end-to-end: lay out a small UI document with a colored panel,
    /// paint it into draw commands, render through the screen-space
    /// `UiOverlayPass` into a headless target, and read back pixels. Proves
    /// the layout → paint → render pipeline carries colors to the right
    /// pixels on the actual GPU (not just in pure-logic mocks).
    #[test]
    fn screen_space_panel_renders_to_correct_pixels() {
        use oxide_engine::math::{Color, Rect, Transform, Vec2};
        use oxide_engine::render::{
            Camera, FrameContext, Gpu, Lighting, RenderPass, UiBatch, UiOverlayPass,
        };
        use oxide_engine::ui::{
            layout, paint, Anchor, FontStore, LayoutStyle, Sizing, Theme, VisualStyle, Widget,
        };
        use oxide_engine::wgpu;

        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };

        // A 48×48 red panel centered in a 64×64 viewport, achieved by
        // anchoring its bounds via the parent's `AnchorGroup`: anchor.min =
        // (0.5, 0.5), anchor.max = (0.5, 0.5), offsets push the corners
        // outward by 24px on each side → a 48×48 rect centered on the
        // viewport center (32, 32).
        let centered_anchor = Anchor::between(Vec2::splat(0.5), Vec2::splat(0.5))
            .with_offsets(Vec2::splat(-24.0), Vec2::splat(24.0));
        let panel = Widget::leaf(Vec2::ZERO)
            .with_visual(VisualStyle {
                background: Some(Color::RED),
                ..VisualStyle::EMPTY
            })
            .with_style(LayoutStyle {
                anchor: centered_anchor,
                ..Default::default()
            });
        let document = Widget::anchor()
            .with_style(LayoutStyle {
                width: Sizing::Grow(1.0),
                height: Sizing::Grow(1.0),
                ..Default::default()
            })
            .with_child(panel);

        let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(64.0, 64.0));
        let tree = layout(&document, viewport, 1.0);
        let theme = Theme::new();
        let fonts = FontStore::new();
        let painted = paint(&document, &tree, &theme, &fonts, 1.0);
        // Expect at least one quad (the red panel).
        assert!(!painted.commands.is_empty());

        let device = gpu.device();
        let queue = gpu.queue();
        let mut pass =
            UiOverlayPass::with_atlas_size(device, wgpu::TextureFormat::Rgba8Unorm, 64, 64);
        pass.set_batches(vec![UiBatch::screen_space(painted, (64, 64))]);

        // Build an offscreen target + readback buffer (same pattern as the
        // Stage-4 lit-sphere test).
        let target = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("ui-test-target"),
            size: wgpu::Extent3d {
                width: 64,
                height: 64,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let target_view = target.create_view(&wgpu::TextureViewDescriptor::default());
        // Pre-clear to dark blue so we can distinguish background pixels.
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("ui-test-clear"),
        });
        {
            let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("ui-test-clear-pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &target_view,
                    depth_slice: None,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color {
                            r: 0.0,
                            g: 0.0,
                            b: 0.2,
                            a: 1.0,
                        }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
        }
        queue.submit(Some(encoder.finish()));

        let camera = Camera::default();
        let view_transform = Transform::default();
        let lighting = Lighting::default();
        let mut frame = FrameContext {
            device,
            queue,
            color: &target_view,
            size: (64, 64),
            viewport_rect: None,
            clear_color: Color::rgb(0.0, 0.0, 0.2),
            camera: &camera,
            view_transform: &view_transform,
            lighting: &lighting,
            objects: &[],
        };
        pass.run(&mut frame);

        // Read back and assert.
        let bytes_per_row = (64 * 4u32).div_ceil(256) * 256;
        let readback = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("ui-test-readback"),
            size: (bytes_per_row * 64) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("ui-test-copy"),
        });
        enc.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &target,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &readback,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(bytes_per_row),
                    rows_per_image: Some(64),
                },
            },
            wgpu::Extent3d {
                width: 64,
                height: 64,
                depth_or_array_layers: 1,
            },
        );
        queue.submit(Some(enc.finish()));

        let slice = readback.slice(..);
        let (tx, rx) = std::sync::mpsc::channel();
        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
        device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
        rx.recv().unwrap().unwrap();
        let view = slice.get_mapped_range();
        let pixel = |x: u32, y: u32| -> (u8, u8, u8) {
            let i = (y * bytes_per_row + x * 4) as usize;
            (view[i], view[i + 1], view[i + 2])
        };
        // Center pixel (32, 32) is inside the centered 48×48 red panel.
        let (r, g, b) = pixel(32, 32);
        assert!(r > 200, "center should be red, r={r}");
        assert!(g < 30 && b < 30);
        // Corner pixel (0, 0) is in the 8-pixel gutter around the panel →
        // clear color (dark blue).
        let (r, g, b) = pixel(0, 0);
        assert!(r < 30 && g < 30 && b > 30);
        drop(view);
        readback.unmap();
    }

    /// Piece-4b end-to-end: build a [`UiPanel`] holding a small UI
    /// document, place it in 3D, render through a perspective camera,
    /// and assert the panel's centre appears red in the framebuffer
    /// centre. Goes through the panel's own `build_batch` helper so the
    /// "easy path" stays covered (the host doesn't have to compose the
    /// MVP math by hand).
    #[test]
    fn ui_panel_in_3d_renders_under_perspective_camera() {
        use oxide_engine::math::{Color, Transform, Vec2, Vec3};
        use oxide_engine::render::{
            Camera, FrameContext, Gpu, Lighting, RenderPass, UiOverlayPass,
        };
        use oxide_engine::ui::{
            FontStore, LayoutStyle, Sizing, Theme, UiPanel, VisualStyle, Widget,
        };
        use oxide_engine::wgpu;

        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return;
            }
        };

        // A red panel filling its 32×32 pixel canvas, sized 2 m × 2 m in
        // world units.
        let root = Widget::default()
            .with_style(LayoutStyle {
                width: Sizing::Grow(1.0),
                height: Sizing::Grow(1.0),
                ..Default::default()
            })
            .with_visual(VisualStyle {
                background: Some(Color::RED),
                ..VisualStyle::EMPTY
            });
        let panel = UiPanel::new(root, Vec2::new(32.0, 32.0), Vec2::new(2.0, 2.0));

        let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0);
        let view_transform = Transform::looking_at(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y);
        let view_projection = camera.view_projection(1.0, &view_transform);

        let theme = Theme::new();
        let fonts = FontStore::new();
        let panel_transform = Transform::default();
        let batch = panel
            .build_batch(&theme, &fonts, &panel_transform, view_projection)
            .expect("build_batch should succeed");

        let device = gpu.device();
        let queue = gpu.queue();
        let mut pass =
            UiOverlayPass::with_atlas_size(device, wgpu::TextureFormat::Rgba8Unorm, 64, 64);
        pass.set_batches(vec![batch]);

        let target = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("panel-3d-target"),
            size: wgpu::Extent3d {
                width: 64,
                height: 64,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        let target_view = target.create_view(&wgpu::TextureViewDescriptor::default());

        // Clear the target to black first.
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("panel-3d-clear"),
        });
        {
            let _ = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("panel-3d-clear-pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &target_view,
                    depth_slice: None,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color {
                            r: 0.0,
                            g: 0.0,
                            b: 0.0,
                            a: 1.0,
                        }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
        }
        queue.submit(Some(enc.finish()));

        let lighting = Lighting::default();
        let mut frame = FrameContext {
            device,
            queue,
            color: &target_view,
            size: (64, 64),
            viewport_rect: None,
            clear_color: Color::BLACK,
            camera: &camera,
            view_transform: &view_transform,
            lighting: &lighting,
            objects: &[],
        };
        pass.run(&mut frame);

        // Read back and assert centre red, corner black.
        let bytes_per_row = (64 * 4u32).div_ceil(256) * 256;
        let readback = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("panel-3d-readback"),
            size: (bytes_per_row * 64) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("panel-3d-copy"),
        });
        enc.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &target,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &readback,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(bytes_per_row),
                    rows_per_image: Some(64),
                },
            },
            wgpu::Extent3d {
                width: 64,
                height: 64,
                depth_or_array_layers: 1,
            },
        );
        queue.submit(Some(enc.finish()));

        let slice = readback.slice(..);
        let (tx, rx) = std::sync::mpsc::channel();
        slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
        device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
        rx.recv().unwrap().unwrap();
        let view = slice.get_mapped_range();
        let pixel = |x: u32, y: u32| -> (u8, u8, u8) {
            let i = (y * bytes_per_row + x * 4) as usize;
            (view[i], view[i + 1], view[i + 2])
        };
        let (r, g, b) = pixel(32, 32);
        assert!(r > 200 && g < 30 && b < 30, "centre should be red");
        let (r, g, b) = pixel(1, 1);
        assert!(r < 30 && g < 30 && b < 30, "corner should be black");
        drop(view);
        readback.unmap();
    }

    /// Piece-5 end-to-end: build a toolbar with two buttons, drive the
    /// router across multiple frames with a synthetic `InputState`, and
    /// assert the hover → press → release → click sequence produces the
    /// right events at the right widget. The shape every example will use.
    #[test]
    fn router_translates_input_state_into_widget_events() {
        use oxide_engine::math::{Rect, Vec2};
        use oxide_engine::prelude::InputState;
        use oxide_engine::ui::{layout, LayoutStyle, Router, RouterEvent, Sizing, Widget};
        use oxide_engine::winit::event::MouseButton;

        let document = Widget::row()
            .with_id("toolbar")
            .with_style(LayoutStyle {
                width: Sizing::Fixed(200.0),
                height: Sizing::Fixed(24.0),
                ..Default::default()
            })
            .with_child(
                Widget::leaf(Vec2::new(80.0, 24.0))
                    .with_id("save")
                    .with_style(LayoutStyle {
                        width: Sizing::Fixed(80.0),
                        height: Sizing::Fixed(24.0),
                        ..Default::default()
                    }),
            )
            .with_child(
                Widget::leaf(Vec2::new(80.0, 24.0))
                    .with_id("cancel")
                    .with_style(LayoutStyle {
                        width: Sizing::Fixed(80.0),
                        height: Sizing::Fixed(24.0),
                        ..Default::default()
                    }),
            );

        let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(400.0, 100.0));
        let tree = layout(&document, viewport, 1.0);

        let mut router = Router::new();
        let mut input = InputState::new();

        // Frame 1: cursor onto "save" → Hovered.
        input.set_cursor(Vec2::new(40.0, 12.0));
        let f = router.process(&tree, &input);
        assert_eq!(f.events, vec![RouterEvent::Hovered("save".into())]);
        assert!(f.captured_mouse);
        assert!(!f.captured_keyboard);

        // Frame 2: press → Pressed + FocusGained.
        input.press_mouse(MouseButton::Left);
        let f = router.process(&tree, &input);
        assert!(f
            .events
            .contains(&RouterEvent::Pressed("save".into(), MouseButton::Left)));
        assert!(f.events.contains(&RouterEvent::FocusGained("save".into())));
        assert_eq!(router.focused(), Some(&"save".into()));
        assert!(f.captured_keyboard);

        // Frame 3: release over the same widget → Released + Clicked.
        input.end_frame();
        input.release_mouse(MouseButton::Left);
        let f = router.process(&tree, &input);
        assert!(f
            .events
            .contains(&RouterEvent::Released("save".into(), MouseButton::Left)));
        assert!(f
            .events
            .contains(&RouterEvent::Clicked("save".into(), MouseButton::Left)));

        // Frame 4: move to "cancel".
        input.end_frame();
        input.set_cursor(Vec2::new(140.0, 12.0));
        let f = router.process(&tree, &input);
        assert!(f.events.contains(&RouterEvent::Unhovered("save".into())));
        assert!(f.events.contains(&RouterEvent::Hovered("cancel".into())));
        // Focus persists from the previous click.
        assert_eq!(router.focused(), Some(&"save".into()));

        // Frame 5: cursor leaves the toolbar; mouse no longer captured,
        // focus stays.
        input.end_frame();
        input.set_cursor(Vec2::new(300.0, 80.0));
        let f = router.process(&tree, &input);
        assert!(!f.captured_mouse);
        assert!(f.captured_keyboard);

        // Frame 6: click outside any widget → focus cleared.
        input.press_mouse(MouseButton::Left);
        let f = router.process(&tree, &input);
        assert!(f.events.contains(&RouterEvent::FocusLost("save".into())));
        assert_eq!(router.focused(), None);
    }

    /// Piece-6 end-to-end: the immediate-mode loop. The host pulls game
    /// state into widget values at the start of the frame, runs the
    /// router, queries `frame.clicked_left(...)`, and pushes widget
    /// values back into game state. Mirrors how piece-7's settings
    /// example will look.
    #[test]
    fn settings_widget_tree_round_trips_game_state_each_frame() {
        use oxide_engine::math::{Rect, Vec2};
        use oxide_engine::prelude::InputState;
        use oxide_engine::ui::{layout, LayoutStyle, Router, Sizing, Widget, WidgetValue};
        use oxide_engine::winit::event::MouseButton;

        struct GameState {
            master_volume: f32,
            invert_y: bool,
            applied_count: u32,
        }
        let mut game = GameState {
            master_volume: 0.5,
            invert_y: false,
            applied_count: 0,
        };

        let mut document = Widget::column()
            .with_id("settings")
            .with_style(LayoutStyle {
                width: Sizing::Fixed(300.0),
                height: Sizing::Fixed(120.0),
                ..Default::default()
            })
            .with_child(
                Widget::leaf(Vec2::new(300.0, 32.0))
                    .with_id("volume")
                    .with_style(LayoutStyle {
                        width: Sizing::Fixed(300.0),
                        height: Sizing::Fixed(32.0),
                        ..Default::default()
                    }),
            )
            .with_child(
                Widget::leaf(Vec2::new(300.0, 32.0))
                    .with_id("invert_y")
                    .with_style(LayoutStyle {
                        width: Sizing::Fixed(300.0),
                        height: Sizing::Fixed(32.0),
                        ..Default::default()
                    }),
            )
            .with_child(
                Widget::leaf(Vec2::new(300.0, 32.0))
                    .with_id("apply")
                    .with_style(LayoutStyle {
                        width: Sizing::Fixed(300.0),
                        height: Sizing::Fixed(32.0),
                        ..Default::default()
                    }),
            );

        let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(640.0, 480.0));
        let mut router = Router::new();
        let mut input = InputState::new();

        // Start of frame: pull current game state into the widget tree.
        document.set_value(&"volume".into(), game.master_volume);
        document.set_value(&"invert_y".into(), game.invert_y);

        // Simulate the user changing the slider/checkbox (a real
        // slider/checkbox widget in piece 7+ would do this in response
        // to drag/click events).
        document.set_value(&"volume".into(), 0.8_f32);
        document.set_value(&"invert_y".into(), true);

        // User clicks Apply.
        let tree = layout(&document, viewport, 1.0);
        input.set_cursor(Vec2::new(150.0, 80.0)); // over the apply row
        router.process(&tree, &input);
        input.press_mouse(MouseButton::Left);
        router.process(&tree, &input);
        input.end_frame();
        input.release_mouse(MouseButton::Left);
        let frame = router.process(&tree, &input);

        // Immediate-mode query.
        assert!(frame.clicked_left("apply"));

        // End of frame: push widget values back into game state.
        if let Some(v) = document.value(&"volume".into()).and_then(|v| v.as_float()) {
            game.master_volume = v as f32;
        }
        if let Some(v) = document.value(&"invert_y".into()).and_then(|v| v.as_bool()) {
            game.invert_y = v;
        }
        if frame.clicked_left("apply") {
            game.applied_count += 1;
        }

        assert!((game.master_volume - 0.8_f32).abs() < 1e-5);
        assert!(game.invert_y);
        assert_eq!(game.applied_count, 1);

        // The widget values carry typed variants.
        assert!(matches!(
            document.value(&"volume".into()),
            Some(WidgetValue::Float(_))
        ));
        assert!(matches!(
            document.value(&"invert_y".into()),
            Some(WidgetValue::Bool(_))
        ));
    }
}
