//! [`UiOverlayPass`]: batches Stage-8 UI [`DrawCommand`]s into one render pass.
//!
//! Slots into the Stage-5 [`RenderPipeline`](super::RenderPipeline) **after**
//! the forward pass (so UI draws on top of the 3D scene) and **before**
//! any future post-process. It consumes a list of
//! [`UiBatch`]es per frame — each carries its own MVP matrix and a flat
//! [`PaintedFrame`] of draw commands — uploads the CPU glyph atlas to a
//! single R8 texture (re-uploading only on dirty), and submits one draw call
//! per batch (vertices buffered into a single growable vertex buffer).
//!
//! # Why batches
//!
//! The same pipeline draws **screen-space UI** (the host adds one batch
//! whose MVP is an orthographic projection from window pixels to NDC) and
//! **world-space UI** (piece 4b adds one batch per `UiPanel`, each with its
//! own world-to-clip MVP). The vertex format is identical; the only thing
//! that differs is the MVP — and that's a small per-batch uniform update,
//! so the GPU pipeline never has to switch state between a HUD and a
//! diegetic panel.
//!
//! # Test strategy
//!
//! [`Gpu::headless()`](super::Gpu::headless) gives us a no-window device.
//! The pass renders into an offscreen `Rgba8Unorm` texture; the host reads
//! pixels back via a copy buffer and asserts on them. The Stage-4
//! `lit_sphere_renders_over_background` test pattern carries over directly —
//! a UI batch whose only command is a `Quad { rect, color: RED }` should
//! produce red pixels inside that rect and the clear color outside it. Tests
//! that need text load a system font via
//! [`common_system_font_paths`](super::super::ui::text::common_system_font_paths)
//! and skip gracefully on hosts without one.

use std::num::NonZeroU64;

use bytemuck::{Pod, Zeroable};
use glam::{Mat4, Vec2, Vec3, Vec4};

use super::pipeline::{FrameContext, RenderPass};
use crate::math::{Color, Rect, Transform};
use crate::ui::paint::{DrawCommand, PaintedFrame};
use crate::ui::text::{FontStore, GlyphAtlas};

/// One batch of UI to draw with a single MVP — either a screen-space tree or
/// a world-space panel.
pub struct UiBatch {
    /// Clip-space matrix applied to every vertex in this batch's commands.
    pub mvp: Mat4,
    /// The painted commands, in submission order (back-to-front).
    pub frame: PaintedFrame,
}

impl UiBatch {
    /// Screen-space batch: maps pixel coordinates `(0, 0)..(width, height)`
    /// to NDC with y-down (origin at the top-left, matching UI convention).
    pub fn screen_space(frame: PaintedFrame, target_size: (u32, u32)) -> Self {
        let (w, h) = (target_size.0.max(1) as f32, target_size.1.max(1) as f32);
        // ortho(left, right, bottom, top, near, far)
        // For y-down with origin at the top-left: bottom = h, top = 0.
        let mvp = Mat4::orthographic_rh(0.0, w, h, 0.0, -1.0, 1.0);
        Self { mvp, frame }
    }

    /// World-space batch: place a panel's UI inside 3D world space.
    ///
    /// The painted frame's vertices are in **panel-pixel** coordinates
    /// (`(0, 0)..=pixel_size`). This constructor composes the MVP that
    /// maps each vertex through:
    ///
    /// 1. Recenter the pixel origin to the panel's centre (so the pixel
    ///    midpoint maps to the panel's local origin).
    /// 2. Scale pixels → world units using `world_size / pixel_size`, with
    ///    the y axis **negated** because UI is y-down but world is y-up.
    /// 3. Apply `panel_transform` (the panel's world placement).
    /// 4. Apply `view_projection` (the camera's clip-space matrix).
    ///
    /// The end-to-end effect: a pixel at `(0, 0)` in the painted frame
    /// lands at world position `panel_transform * (-world.x/2, +world.y/2,
    /// 0)` (the panel's top-left corner); a pixel at `pixel_size` lands
    /// at the panel's bottom-right.
    pub fn world_space(
        frame: PaintedFrame,
        pixel_size: Vec2,
        world_size: Vec2,
        panel_transform: &Transform,
        view_projection: Mat4,
    ) -> Self {
        let pixel_to_centered =
            Mat4::from_translation(Vec3::new(-pixel_size.x * 0.5, -pixel_size.y * 0.5, 0.0));
        let centered_to_world_local = Mat4::from_scale(Vec3::new(
            world_size.x / pixel_size.x.max(1.0),
            -world_size.y / pixel_size.y.max(1.0), // y-down → y-up
            1.0,
        ));
        let world_local_to_world = panel_transform.to_matrix();
        let mvp =
            view_projection * world_local_to_world * centered_to_world_local * pixel_to_centered;
        Self { mvp, frame }
    }
}

/// A render pass that draws Stage-8 UI batches over the existing color
/// target.
pub struct UiOverlayPass {
    pipeline: wgpu::RenderPipeline,
    bind_group: wgpu::BindGroup,

    atlas_texture: wgpu::Texture,
    // Held to keep the texture view alive while the bind group references
    // it (wgpu Arc-counts internally, but storing it here makes the
    // ownership explicit).
    _atlas_view: wgpu::TextureView,
    atlas_size: (u32, u32),
    _atlas_sampler: wgpu::Sampler,

    uniform_buffer: wgpu::Buffer,
    vertex_buffer: wgpu::Buffer,
    vertex_capacity: u64,

    cpu_atlas: GlyphAtlas,
    fonts: FontStore,
    pending: Vec<UiBatch>,
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct UiUniform {
    mvp: [[f32; 4]; 4],
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct UiVertex {
    position: [f32; 2],
    uv: [f32; 2],
    color: [f32; 4],
}

impl UiVertex {
    const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
        array_stride: std::mem::size_of::<UiVertex>() as u64,
        step_mode: wgpu::VertexStepMode::Vertex,
        attributes: &wgpu::vertex_attr_array![
            0 => Float32x2, // position
            1 => Float32x2, // uv
            2 => Float32x4, // color
        ],
    };
}

const DEFAULT_ATLAS_SIZE: u32 = 1024;
const DEFAULT_VERTEX_CAPACITY: u64 = 4096;
/// Sentinel UV for solid quads. The shader treats any `uv.x < 0.0` as
/// "skip atlas sample" — see `engine/src/render/shaders/ui.wgsl`.
const SOLID_UV: Vec2 = Vec2::new(-1.0, -1.0);

impl UiOverlayPass {
    /// Build a pass for the given color target format. Initialises a
    /// 1024×1024 R8 atlas, the pipeline, and the bind group; the host wires
    /// it into [`RenderPipeline`](super::RenderPipeline) with
    /// `add_pass("ui", pass)` *after* the forward pass.
    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
        Self::with_atlas_size(device, color_format, DEFAULT_ATLAS_SIZE, DEFAULT_ATLAS_SIZE)
    }

    /// Build a pass with an explicit atlas resolution — useful in tests
    /// where a 1024×1024 atlas is overkill.
    pub fn with_atlas_size(
        device: &wgpu::Device,
        color_format: wgpu::TextureFormat,
        atlas_w: u32,
        atlas_h: u32,
    ) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("oxide.ui.shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/ui.wgsl").into()),
        });

        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("oxide.ui.bind_group_layout"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: NonZeroU64::new(std::mem::size_of::<UiUniform>() as u64),
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });

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

        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("oxide.ui.pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                compilation_options: Default::default(),
                buffers: &[UiVertex::LAYOUT],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                // No cull — UI quads are CPU-emitted CCW but flipping the
                // MVP for world-space panels can swap the winding; rely on
                // alpha blending instead.
                cull_mode: None,
                unclipped_depth: false,
                polygon_mode: wgpu::PolygonMode::Fill,
                conservative: false,
            },
            // UI doesn't read depth (it overlays).
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: color_format,
                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });

        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("oxide.ui.atlas"),
            size: wgpu::Extent3d {
                width: atlas_w,
                height: atlas_h,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::R8Unorm,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });
        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());

        let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("oxide.ui.atlas_sampler"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
            ..Default::default()
        });

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

        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("oxide.ui.vertices"),
            size: DEFAULT_VERTEX_CAPACITY * std::mem::size_of::<UiVertex>() as u64,
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("oxide.ui.bind_group"),
            layout: &bind_group_layout,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: uniform_buffer.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::TextureView(&atlas_view),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: wgpu::BindingResource::Sampler(&atlas_sampler),
                },
            ],
        });

        Self {
            pipeline,
            bind_group,
            atlas_texture,
            _atlas_view: atlas_view,
            atlas_size: (atlas_w, atlas_h),
            _atlas_sampler: atlas_sampler,
            uniform_buffer,
            vertex_buffer,
            vertex_capacity: DEFAULT_VERTEX_CAPACITY,
            cpu_atlas: GlyphAtlas::new(atlas_w, atlas_h),
            fonts: FontStore::new(),
            pending: Vec::new(),
        }
    }

    /// Borrow the pass's font store mutably to register fonts. Fonts
    /// referenced by [`DrawCommand::Glyph`] keys must already be in this
    /// store before the pass runs.
    pub fn fonts_mut(&mut self) -> &mut FontStore {
        &mut self.fonts
    }

    /// Borrow the pass's font store. Useful for shaping outside the pass
    /// (e.g. in [`paint`](crate::ui::paint::paint)) using the same `FontId`s.
    pub fn fonts(&self) -> &FontStore {
        &self.fonts
    }

    /// Replace the pending batches for this frame. The pass renders these on
    /// its next [`run`](Self::run) call and then clears them.
    pub fn set_batches(&mut self, batches: Vec<UiBatch>) {
        self.pending = batches;
    }

    /// Number of batches currently queued for the next `run`.
    pub fn batch_count(&self) -> usize {
        self.pending.len()
    }

    /// Resolution of the CPU/GPU glyph atlas.
    pub fn atlas_size(&self) -> (u32, u32) {
        self.atlas_size
    }

    /// Number of distinct glyphs currently cached in the atlas.
    ///
    /// Useful for diagnostics: once this count stops growing across frames,
    /// every glyph the UI draws is a cache hit and `run` no longer rasterizes
    /// or re-uploads the atlas. HUD-style overlays that animate numeric values
    /// reach this steady state after the digits `0`–`9` (and any static
    /// labels) have each been seen once.
    pub fn atlas_glyph_count(&self) -> usize {
        self.cpu_atlas.len()
    }

    /// Whether the atlas gained a glyph during the most recent `run` and has
    /// not yet been re-uploaded. `run` clears this immediately after uploading,
    /// so from a host's perspective it reads `false` in steady state.
    pub fn atlas_dirty(&self) -> bool {
        self.cpu_atlas.dirty()
    }
}

impl RenderPass for UiOverlayPass {
    fn run(&mut self, frame: &mut FrameContext<'_>) {
        if self.pending.is_empty() {
            return;
        }

        // Step 1: walk every glyph in every batch to ensure the atlas has
        // their entries. This is the only step that can mutate `cpu_atlas`
        // and the only step that may raise the dirty flag.
        for batch in &self.pending {
            for cmd in &batch.frame.commands {
                if let DrawCommand::Glyph { key, .. } = cmd {
                    let _ = self.cpu_atlas.get_or_rasterize(*key, &self.fonts);
                }
            }
        }

        // Step 2: re-upload the atlas to the GPU texture if it grew.
        if self.cpu_atlas.dirty() {
            let (w, h) = self.atlas_size;
            frame.queue.write_texture(
                wgpu::TexelCopyTextureInfo {
                    texture: &self.atlas_texture,
                    mip_level: 0,
                    origin: wgpu::Origin3d::ZERO,
                    aspect: wgpu::TextureAspect::All,
                },
                self.cpu_atlas.pixels(),
                wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(w),
                    rows_per_image: Some(h),
                },
                wgpu::Extent3d {
                    width: w,
                    height: h,
                    depth_or_array_layers: 1,
                },
            );
            self.cpu_atlas.clear_dirty();
        }

        // Step 3: render each batch — one draw call per batch.
        let resolved_viewport = frame.resolved_viewport();
        for batch in std::mem::take(&mut self.pending) {
            self.render_batch(frame, &batch, resolved_viewport);
        }
    }
}

impl UiOverlayPass {
    fn render_batch(&mut self, frame: &mut FrameContext<'_>, batch: &UiBatch, viewport_rect: Rect) {
        // 1. Translate draw commands into a vertex buffer.
        let vertices = self.commands_to_vertices(&batch.frame.commands);
        if vertices.is_empty() {
            return;
        }
        self.ensure_vertex_capacity(frame.device, vertices.len() as u64);

        frame
            .queue
            .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices));

        // 2. Update the MVP uniform.
        let uniform = UiUniform {
            mvp: batch.mvp.to_cols_array_2d(),
        };
        frame
            .queue
            .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform));

        // 3. Encode the render pass.
        let mut encoder = frame
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("oxide.ui.encoder"),
            });
        {
            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("oxide.ui.pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: frame.color,
                    depth_slice: None,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Load,
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            rpass.set_pipeline(&self.pipeline);
            rpass.set_bind_group(0, &self.bind_group, &[]);
            rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
            rpass.set_viewport(
                viewport_rect.min.x,
                viewport_rect.min.y,
                viewport_rect.width().max(1.0),
                viewport_rect.height().max(1.0),
                0.0,
                1.0,
            );
            rpass.draw(0..vertices.len() as u32, 0..1);
        }
        frame.queue.submit(Some(encoder.finish()));
    }

    fn commands_to_vertices(&self, commands: &[DrawCommand]) -> Vec<UiVertex> {
        let mut vertices = Vec::with_capacity(commands.len() * 6);
        let (atlas_w, atlas_h) = (self.atlas_size.0 as f32, self.atlas_size.1 as f32);
        for cmd in commands {
            match cmd {
                DrawCommand::Quad { rect, color } => {
                    push_quad(
                        &mut vertices,
                        rect.min,
                        rect.max,
                        SOLID_UV,
                        SOLID_UV,
                        color_to_array(*color),
                    );
                }
                DrawCommand::Glyph {
                    key,
                    pen_position,
                    color,
                } => {
                    let Some(entry) = self.cpu_atlas.get(key) else {
                        continue; // glyph not yet rasterized (e.g., space)
                    };
                    let top_left = *pen_position + entry.bearing;
                    let bottom_right = top_left + entry.size_px;
                    push_quad(
                        &mut vertices,
                        top_left,
                        bottom_right,
                        entry.uv_min,
                        entry.uv_max,
                        color_to_array(*color),
                    );
                    let _ = (atlas_w, atlas_h);
                }
            }
        }
        vertices
    }

    fn ensure_vertex_capacity(&mut self, device: &wgpu::Device, needed: u64) {
        if needed <= self.vertex_capacity {
            return;
        }
        let mut new_cap = self.vertex_capacity.max(1);
        while new_cap < needed {
            new_cap *= 2;
        }
        self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("oxide.ui.vertices"),
            size: new_cap * std::mem::size_of::<UiVertex>() as u64,
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        self.vertex_capacity = new_cap;
    }
}

fn push_quad(
    out: &mut Vec<UiVertex>,
    min: Vec2,
    max: Vec2,
    uv_min: Vec2,
    uv_max: Vec2,
    color: [f32; 4],
) {
    // Two triangles: (TL, BL, BR), (TL, BR, TR). Counter-clockwise in
    // pixel coords (where y increases downward), which becomes CW after
    // the y-flip orthographic projection — `cull_mode: None` covers either.
    let tl = UiVertex {
        position: [min.x, min.y],
        uv: [uv_min.x, uv_min.y],
        color,
    };
    let tr = UiVertex {
        position: [max.x, min.y],
        uv: [uv_max.x, uv_min.y],
        color,
    };
    let bl = UiVertex {
        position: [min.x, max.y],
        uv: [uv_min.x, uv_max.y],
        color,
    };
    let br = UiVertex {
        position: [max.x, max.y],
        uv: [uv_max.x, uv_max.y],
        color,
    };
    out.push(tl);
    out.push(bl);
    out.push(br);
    out.push(tl);
    out.push(br);
    out.push(tr);
}

fn color_to_array(c: Color) -> [f32; 4] {
    let v: Vec4 = Vec4::new(c.r, c.g, c.b, c.a);
    v.to_array()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::math::{Color, Transform, Vec2 as MVec2};
    use crate::render::{Camera, Gpu, Lighting};
    use crate::ui::paint::{DrawCommand, PaintedFrame};

    /// Build a headless GPU + an offscreen Rgba8 target + a [`FrameContext`]
    /// with sensible defaults, ready to feed a pass's `run`.
    fn make_headless(target_w: u32, target_h: u32) -> Option<HeadlessHarness> {
        let gpu = match Gpu::headless() {
            Ok(gpu) => gpu,
            Err(err) => {
                eprintln!("SKIP: no GPU adapter available ({err})");
                return None;
            }
        };
        Some(HeadlessHarness::new(gpu, target_w, target_h))
    }

    struct HeadlessHarness {
        gpu: Gpu,
        target: wgpu::Texture,
        target_view: wgpu::TextureView,
        readback: wgpu::Buffer,
        target_size: (u32, u32),
    }

    impl HeadlessHarness {
        fn new(gpu: Gpu, w: u32, h: u32) -> Self {
            let device = gpu.device();
            let target = device.create_texture(&wgpu::TextureDescriptor {
                label: Some("test-target"),
                size: wgpu::Extent3d {
                    width: w,
                    height: h,
                    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());
            let bytes_per_row = align_up(w * 4, 256);
            let readback = device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("test-readback"),
                size: (bytes_per_row * h) as u64,
                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
                mapped_at_creation: false,
            });
            Self {
                gpu,
                target,
                target_view,
                readback,
                target_size: (w, h),
            }
        }

        /// Read back the target's pixels as `Rgba8`.
        fn read_pixels(&self) -> Vec<u8> {
            let (w, h) = self.target_size;
            let bytes_per_row = align_up(w * 4, 256);
            let device = self.gpu.device();
            let queue = self.gpu.queue();
            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("test-copy"),
            });
            encoder.copy_texture_to_buffer(
                wgpu::TexelCopyTextureInfo {
                    texture: &self.target,
                    mip_level: 0,
                    origin: wgpu::Origin3d::ZERO,
                    aspect: wgpu::TextureAspect::All,
                },
                wgpu::TexelCopyBufferInfo {
                    buffer: &self.readback,
                    layout: wgpu::TexelCopyBufferLayout {
                        offset: 0,
                        bytes_per_row: Some(bytes_per_row),
                        rows_per_image: Some(h),
                    },
                },
                wgpu::Extent3d {
                    width: w,
                    height: h,
                    depth_or_array_layers: 1,
                },
            );
            queue.submit(Some(encoder.finish()));

            let slice = self.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 mut out = Vec::with_capacity((w * h * 4) as usize);
            for row in 0..h {
                let start = (row * bytes_per_row) as usize;
                out.extend_from_slice(&view[start..start + (w * 4) as usize]);
            }
            drop(view);
            self.readback.unmap();
            out
        }

        fn run_pass(&self, pass: &mut UiOverlayPass, clear: Color) {
            let device = self.gpu.device();
            let queue = self.gpu.queue();
            // Clear the target first (using a one-off render pass).
            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("test-clear"),
            });
            {
                let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                    label: Some("test-clear-pass"),
                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                        view: &self.target_view,
                        depth_slice: None,
                        resolve_target: None,
                        ops: wgpu::Operations {
                            load: wgpu::LoadOp::Clear(wgpu::Color {
                                r: clear.r as f64,
                                g: clear.g as f64,
                                b: clear.b as f64,
                                a: clear.a as f64,
                            }),
                            store: wgpu::StoreOp::Store,
                        },
                    })],
                    depth_stencil_attachment: None,
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
            }
            queue.submit(Some(encoder.finish()));

            // Build a `FrameContext` to feed the pass.
            let camera = Camera::default();
            let view_transform = Transform::default();
            let lighting = Lighting::default();
            let mut frame = FrameContext {
                device,
                queue,
                color: &self.target_view,
                size: self.target_size,
                viewport_rect: None,
                clear_color: clear,
                camera: &camera,
                view_transform: &view_transform,
                lighting: &lighting,
                objects: &[],
            };
            pass.run(&mut frame);
        }
    }

    fn align_up(x: u32, to: u32) -> u32 {
        x.div_ceil(to) * to
    }

    fn pixel(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) {
        let i = ((y * w + x) * 4) as usize;
        (buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
    }

    #[test]
    fn solid_red_quad_renders_inside_its_rect_only() {
        let Some(harness) = make_headless(64, 64) else {
            return;
        };
        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            64,
            64,
        );
        // A 20×20 red rect centered in the 64×64 target.
        let frame = PaintedFrame {
            size: MVec2::new(64.0, 64.0),
            commands: vec![DrawCommand::Quad {
                rect: Rect::from_min_size(MVec2::new(22.0, 22.0), MVec2::new(20.0, 20.0)),
                color: Color::RED,
            }],
        };
        pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]);
        harness.run_pass(&mut pass, Color::rgb(0.0, 0.0, 0.2));

        let pixels = harness.read_pixels();
        // Center pixel (32, 32) is inside the rect → red.
        let (r, g, b, _a) = pixel(&pixels, 64, 32, 32);
        assert!(r > 200, "center pixel should be red, got r={r}");
        assert!(g < 30, "center pixel should not have green, got g={g}");
        assert!(b < 30, "center pixel should not have blue, got b={b}");
        // Corner pixel (0, 0) is outside → the clear color (dark blue).
        let (r, g, b, _) = pixel(&pixels, 64, 0, 0);
        assert!(r < 30 && g < 30 && b > 30, "corner should be clear color");
    }

    #[test]
    fn empty_batch_list_is_a_noop() {
        let Some(harness) = make_headless(16, 16) else {
            return;
        };
        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            64,
            64,
        );
        // No batches queued — the run should not panic.
        harness.run_pass(&mut pass, Color::WHITE);
        let pixels = harness.read_pixels();
        let (r, g, b, _) = pixel(&pixels, 16, 8, 8);
        assert!(r > 200 && g > 200 && b > 200, "should still be white");
    }

    #[test]
    fn two_quads_in_one_batch_both_render() {
        let Some(harness) = make_headless(48, 32) else {
            return;
        };
        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            64,
            64,
        );
        let frame = PaintedFrame {
            size: MVec2::new(48.0, 32.0),
            commands: vec![
                DrawCommand::Quad {
                    rect: Rect::from_min_size(MVec2::new(2.0, 2.0), MVec2::new(20.0, 28.0)),
                    color: Color::RED,
                },
                DrawCommand::Quad {
                    rect: Rect::from_min_size(MVec2::new(26.0, 2.0), MVec2::new(20.0, 28.0)),
                    color: Color::GREEN,
                },
            ],
        };
        pass.set_batches(vec![UiBatch::screen_space(frame, (48, 32))]);
        harness.run_pass(&mut pass, Color::BLACK);

        let pixels = harness.read_pixels();
        // Left rect → red.
        let (r, g, b, _) = pixel(&pixels, 48, 10, 16);
        assert!(r > 200 && g < 30 && b < 30);
        // Right rect → green.
        let (r, g, b, _) = pixel(&pixels, 48, 36, 16);
        assert!(r < 30 && g > 200 && b < 30);
        // Gap between rects → clear (black).
        let (r, g, b, _) = pixel(&pixels, 48, 24, 16);
        assert!(r < 30 && g < 30 && b < 30);
    }

    #[test]
    fn vertex_buffer_grows_when_command_count_exceeds_capacity() {
        let Some(harness) = make_headless(32, 32) else {
            return;
        };
        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            64,
            64,
        );
        // Default vertex capacity is 4096; one quad uses 6 vertices, so
        // 1000 quads = 6000 vertices, triggering one growth.
        let commands: Vec<_> = (0..1000)
            .map(|i| DrawCommand::Quad {
                rect: Rect::from_min_size(
                    MVec2::new((i % 32) as f32, (i / 32) as f32),
                    MVec2::new(1.0, 1.0),
                ),
                color: Color::WHITE,
            })
            .collect();
        let frame = PaintedFrame {
            size: MVec2::new(32.0, 32.0),
            commands,
        };
        pass.set_batches(vec![UiBatch::screen_space(frame, (32, 32))]);
        // The run should not panic on the buffer regrow.
        harness.run_pass(&mut pass, Color::BLACK);
    }

    /// World-space UI panel rendered through a 3D camera. Places a red
    /// panel at the origin facing the camera, renders, and asserts that
    /// the centre of the framebuffer is red while the corners stay clear.
    /// This is the piece-4b gate: the `UiBatch::world_space` MVP path
    /// produces pixels at the right place under a real perspective
    /// projection.
    #[test]
    fn world_space_panel_renders_inside_its_projected_region() {
        use crate::math::{Transform, Vec3};
        use crate::render::Camera;
        use crate::ui::paint::{DrawCommand, PaintedFrame};

        let Some(harness) = make_headless(64, 64) else {
            return;
        };
        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            64,
            64,
        );

        // A 2 m × 2 m panel filled with red, laid out at 32×32 pixels.
        let pixel_size = MVec2::new(32.0, 32.0);
        let world_size = MVec2::new(2.0, 2.0);
        let painted = PaintedFrame {
            size: pixel_size,
            commands: vec![DrawCommand::Quad {
                rect: Rect::from_min_size(MVec2::ZERO, pixel_size),
                color: Color::RED,
            }],
        };
        // Panel sits at the origin with default rotation (its normal
        // points along +Z in panel-local space, which is +Z in world).
        let panel_transform = Transform::default();
        // Camera at (0, 0, 3) looking at the origin: it sees the panel's
        // front face. With a 60° FOV and 1:1 aspect the visible width at
        // distance 3 is ~3.46 m, so a 2×2 m panel covers about 58% of
        // the view's centre — corners stay outside.
        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);

        pass.set_batches(vec![UiBatch::world_space(
            painted,
            pixel_size,
            world_size,
            &panel_transform,
            view_projection,
        )]);
        harness.run_pass(&mut pass, Color::BLACK);

        let pixels = harness.read_pixels();
        // Centre of the framebuffer → red panel.
        let (r, g, b, _) = pixel(&pixels, 64, 32, 32);
        assert!(
            r > 200 && g < 30 && b < 30,
            "centre should be red, got ({r}, {g}, {b})"
        );
        // Corner of the framebuffer → black (panel doesn't reach there).
        let (r, g, b, _) = pixel(&pixels, 64, 1, 1);
        assert!(
            r < 30 && g < 30 && b < 30,
            "corner should be clear-black, got ({r}, {g}, {b})"
        );
    }

    /// End-to-end glyph rendering on the GPU: load a system font, build a
    /// painted frame with a single white glyph drawn over a black
    /// background, render through the pass, read back pixels, and assert
    /// that the glyph's region contains at least one near-white pixel and
    /// that the corners stay black. This is the test that proves the path
    /// from `DrawCommand::Glyph` through atlas → vertex buffer → shader
    /// fragment is intact on the actual GPU (the solid-quad tests cover
    /// only the `uv.x < 0.0` fast path).
    #[test]
    fn glyph_command_renders_visible_pixels_in_its_region() {
        use crate::ui::text::{common_system_font_paths, Font, GlyphKey};

        let Some(harness) = make_headless(64, 64) else {
            return;
        };
        // Load a system font (skip if none available — same pattern as the
        // text-shaping tests).
        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 GPU glyph test");
            return;
        };

        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            128,
            128,
        );
        // Register the font with the pass so the atlas can rasterize it.
        let font_id = pass.fonts_mut().insert(font);
        // Capital 'H' at 32px — a tall, mostly-solid glyph that's easy to
        // hit-test in the centre of a 64×64 target.
        let glyph = pass.fonts().get(font_id).unwrap().glyph_id('H');
        let key = GlyphKey::new(font_id, glyph, 32.0);
        let frame = PaintedFrame {
            size: MVec2::new(64.0, 64.0),
            commands: vec![DrawCommand::Glyph {
                key,
                // Pen position at (16, 48): baseline near the vertical
                // middle, so the glyph occupies roughly the central rect.
                pen_position: MVec2::new(16.0, 48.0),
                color: Color::WHITE,
            }],
        };
        pass.set_batches(vec![UiBatch::screen_space(frame, (64, 64))]);
        harness.run_pass(&mut pass, Color::BLACK);

        let pixels = harness.read_pixels();
        // Scan a 32×32 window around the glyph centre for any near-white
        // pixel. We don't assert a specific pixel because exact glyph
        // bitmap layout varies per font face; we only assert *something*
        // got drawn there.
        let mut found_lit = false;
        for y in 18..50 {
            for x in 16..48 {
                let (r, g, b, _) = pixel(&pixels, 64, x, y);
                if r > 200 && g > 200 && b > 200 {
                    found_lit = true;
                }
            }
        }
        assert!(
            found_lit,
            "expected at least one near-white pixel inside the glyph's region"
        );
        // Corner pixel must still be the clear color (black) — the glyph
        // is bounded, not splatted across the whole target.
        let (r, g, b, _) = pixel(&pixels, 64, 0, 0);
        assert!(
            r < 30 && g < 30 && b < 30,
            "corner should remain clear-black, got ({r}, {g}, {b})"
        );
    }

    /// The atlas grows once per distinct glyph, then stops — the property the
    /// `ui_hud` example relies on to claim animated HUD digits become 100%
    /// cache hits. Renders the digits `0`–`9` one at a time (the atlas grows
    /// each frame), then re-renders an already-seen digit (no growth, no
    /// dirty flag).
    #[test]
    fn atlas_caches_glyphs_and_reaches_steady_state() {
        use crate::ui::text::{common_system_font_paths, Font, GlyphKey};

        let Some(harness) = make_headless(32, 32) else {
            return;
        };
        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 atlas-cache test");
            return;
        };

        let mut pass = UiOverlayPass::with_atlas_size(
            harness.gpu.device(),
            wgpu::TextureFormat::Rgba8Unorm,
            128,
            128,
        );
        let font_id = pass.fonts_mut().insert(font);
        let glyph_key = |pass: &UiOverlayPass, c: char| {
            let glyph = pass.fonts().get(font_id).unwrap().glyph_id(c);
            GlyphKey::new(font_id, glyph, 24.0)
        };
        let draw = |key: GlyphKey| {
            UiBatch::screen_space(
                PaintedFrame {
                    size: MVec2::new(32.0, 32.0),
                    commands: vec![DrawCommand::Glyph {
                        key,
                        pen_position: MVec2::new(8.0, 24.0),
                        color: Color::WHITE,
                    }],
                },
                (32, 32),
            )
        };

        assert_eq!(pass.atlas_glyph_count(), 0, "atlas starts empty");

        // Each distinct digit grows the atlas by exactly one entry.
        for (i, c) in "0123456789".chars().enumerate() {
            let key = glyph_key(&pass, c);
            pass.set_batches(vec![draw(key)]);
            harness.run_pass(&mut pass, Color::BLACK);
            assert_eq!(
                pass.atlas_glyph_count(),
                i + 1,
                "atlas should hold {} glyphs after digit '{c}'",
                i + 1
            );
            // `run` clears the dirty flag after uploading, so a host always
            // observes it false post-run.
            assert!(!pass.atlas_dirty(), "dirty flag is cleared after upload");
        }

        // Re-rendering an already-cached digit is a pure cache hit: the count
        // holds and nothing is re-rasterized or marked dirty.
        let key = glyph_key(&pass, '7');
        pass.set_batches(vec![draw(key)]);
        harness.run_pass(&mut pass, Color::BLACK);
        assert_eq!(
            pass.atlas_glyph_count(),
            10,
            "re-drawing a cached glyph must not grow the atlas"
        );
        assert!(!pass.atlas_dirty(), "cache hit leaves the atlas clean");
    }
}
