//! Content / asset management, modeled on XNA's `ContentManager`.
//!
//! Decoding (CPU side) lives here and is backend-agnostic: a texture is loaded
//! into RGBA8 bytes and assigned a [`TextureId`]. The GPU upload of those bytes
//! is the backend's job (see `gfx`), so this module has no wgpu dependency and
//! stays unit-testable.

use crate::error::{MawidaError, Result};
use std::collections::HashMap;
use std::path::Path;

/// An opaque handle to a loaded texture. Returned by [`ContentManager::load_texture`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TextureId(pub u64);

/// Decoded, CPU-side RGBA8 image data plus its dimensions.
#[derive(Debug, Clone)]
pub struct TextureData {
    pub width: u32,
    pub height: u32,
    /// Tightly packed RGBA8, row-major, top-left origin.
    pub rgba: Vec<u8>,
}

/// Owns decoded assets and hands out handles. The backend pulls [`TextureData`]
/// by id to create GPU resources.
#[derive(Debug, Default)]
pub struct ContentManager {
    next_id: u64,
    textures: HashMap<TextureId, TextureData>,
    /// Optional base directory prepended to relative load paths (XNA's RootDirectory).
    pub root: Option<std::path::PathBuf>,
}

impl ContentManager {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_root(root: impl Into<std::path::PathBuf>) -> Self {
        Self {
            root: Some(root.into()),
            ..Self::default()
        }
    }

    fn resolve(&self, path: &Path) -> std::path::PathBuf {
        match &self.root {
            Some(root) if path.is_relative() => root.join(path),
            _ => path.to_path_buf(),
        }
    }

    /// Decode an image file (PNG, JPEG, etc. via the `image` crate) into RGBA8
    /// and register it. Returns a handle you store on a [`crate::drawable::Material`].
    pub fn load_texture(&mut self, path: impl AsRef<Path>) -> Result<TextureId> {
        let full = self.resolve(path.as_ref());
        let img = image::open(&full)?.to_rgba8();
        let (width, height) = img.dimensions();
        let data = TextureData {
            width,
            height,
            rgba: img.into_raw(),
        };
        Ok(self.insert(data))
    }

    /// Register already-decoded RGBA8 bytes (useful for procedural textures or
    /// a 1x1 white pixel for untextured draws).
    pub fn insert_rgba(&mut self, width: u32, height: u32, rgba: Vec<u8>) -> TextureId {
        self.insert(TextureData {
            width,
            height,
            rgba,
        })
    }

    fn insert(&mut self, data: TextureData) -> TextureId {
        let id = TextureId(self.next_id);
        self.next_id += 1;
        self.textures.insert(id, data);
        id
    }

    pub fn get(&self, id: TextureId) -> Result<&TextureData> {
        self.textures
            .get(&id)
            .ok_or_else(|| MawidaError::AssetNotFound(format!("texture {:?}", id)))
    }

    /// A 1x1 opaque white texture. Handy as the default bound texture so the
    /// shader can always sample, even for solid-color (untextured) draws.
    pub fn white_pixel(&mut self) -> TextureId {
        self.insert_rgba(1, 1, vec![255, 255, 255, 255])
    }

    /// The root-resolved path for `path` (XNA-style RootDirectory rules).
    /// Scripts use it to remember where they came from, for hot reload.
    pub fn full_path(&self, path: impl AsRef<Path>) -> std::path::PathBuf {
        self.resolve(path.as_ref())
    }

    /// Raw bytes of an asset file, root-resolved. Scripts are content, like
    /// textures: bytes in, no interpretation here.
    pub fn load_bytes(&self, path: impl AsRef<Path>) -> Result<Vec<u8>> {
        Ok(std::fs::read(self.resolve(path.as_ref()))?)
    }

    /// Load a glTF 2.0 model (v0.3, behind the `gltf` feature): every
    /// primitive of every mesh merged into ONE [`Mesh`](crate::drawable::Mesh)
    /// with positions, normals, uvs, and indices read through the crate's
    /// typed accessors (no hand-rolled byte offsets), plus a
    /// [`Material`](crate::drawable::Material) carrying the first
    /// primitive's base color (factor and texture, if any). The texture
    /// rides the SAME TextureData path as any sprite PNG.
    ///
    /// Scene-graph aware (item 14): node transforms compose parent-first
    /// and bake into the vertices (positions by the world matrix, normals
    /// by its inverse transpose), so multi-node, multi-part files load
    /// correctly into one flat Mesh. Deliberately still out of scope:
    /// PBR beyond base color, skins, and animations (v0.5 territory -
    /// animation needs the hierarchy kept live, not baked).
    #[cfg(feature = "gltf")]
    pub fn load_gltf(&mut self, path: impl AsRef<Path>) -> Result<LoadedModel> {
        use crate::color::Color;
        use crate::drawable::{Material, Mesh, Shading, Vertex};

        let full = self.resolve(path.as_ref());
        let (doc, buffers, images) = gltf::import(&full)
            .map_err(|e| MawidaError::AssetNotFound(format!("gltf {full:?}: {e}")))?;

        let mut vertices: Vec<Vertex> = Vec::new();
        let mut indices: Vec<u32> = Vec::new();
        let mut material: Option<Material> = None;

        // The scene graph, walked (item 14 of the v0.4 sweep): every node
        // carries a transform, children compose with their parents, and a
        // node's mesh is baked into world space by the ACCUMULATED matrix.
        // Baking keeps Mesh flat (one vertex list): right for props and
        // multi-part models; animation/skinning would keep the hierarchy
        // live instead, and that is v0.5 territory.
        let mut stack: Vec<(gltf::Node, glam::Mat4)> = doc
            .default_scene()
            .or_else(|| doc.scenes().next())
            .map(|s| s.nodes().map(|n| (n, glam::Mat4::IDENTITY)).collect())
            .unwrap_or_default();

        while let Some((node, parent)) = stack.pop() {
            // glTF stores column-major; glam agrees. Compose parent-first.
            let local = glam::Mat4::from_cols_array_2d(&node.transform().matrix());
            let world = parent * local;
            for child in node.children() {
                stack.push((child, world));
            }
            let Some(mesh) = node.mesh() else { continue };
            // Directions transform without translation; the inverse
            // transpose handles non-uniform scale correctly.
            let normal_matrix = world.inverse().transpose();

            for primitive in mesh.primitives() {
                let reader = primitive.reader(|b| Some(&buffers[b.index()].0[..]));
                let positions: Vec<[f32; 3]> = match reader.read_positions() {
                    Some(p) => p.collect(),
                    None => continue, // a primitive with no geometry
                };
                // Normals and uvs are optional in the format; default flat.
                let normals: Vec<[f32; 3]> = reader
                    .read_normals()
                    .map(|n| n.collect())
                    .unwrap_or_else(|| vec![[0.0, 0.0, 1.0]; positions.len()]);
                let uvs: Vec<[f32; 2]> = reader
                    .read_tex_coords(0)
                    .map(|t| t.into_f32().collect())
                    .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);

                let base = vertices.len() as u32;
                for i in 0..positions.len() {
                    let p = world.transform_point3(glam::Vec3::from_array(positions[i]));
                    let n = normal_matrix
                        .transform_vector3(glam::Vec3::from_array(normals[i]))
                        .normalize_or_zero();
                    vertices.push(Vertex {
                        position: p.to_array(),
                        normal: n.to_array(),
                        uv: uvs[i],
                        color: [1.0, 1.0, 1.0, 1.0],
                    });
                }
                if let Some(idx) = reader.read_indices() {
                    indices.extend(idx.into_u32().map(|i| base + i));
                } else {
                    // non-indexed primitive: triangles in vertex order
                    indices.extend(base..base + positions.len() as u32);
                }

                // First material wins (v0.3 scope: one material per model).
                if material.is_none() {
                    let pbr = primitive.material().pbr_metallic_roughness();
                    let f = pbr.base_color_factor();
                    let texture = pbr.base_color_texture().map(|info| {
                        let img = &images[info.texture().source().index()];
                        // glTF images may be RGB; the GPU path wants RGBA.
                        let rgba = match img.format {
                            gltf::image::Format::R8G8B8A8 => img.pixels.clone(),
                            gltf::image::Format::R8G8B8 => img
                                .pixels
                                .chunks_exact(3)
                                .flat_map(|p| [p[0], p[1], p[2], 255])
                                .collect(),
                            _ => vec![255; (img.width * img.height * 4) as usize],
                        };
                        self.insert_rgba(img.width, img.height, rgba)
                    });
                    material = Some(Material {
                        texture,
                        base_color: Color::rgba(f[0], f[1], f[2], f[3]),
                        uv_rect: [0.0, 0.0, 1.0, 1.0],
                        shading: Shading::Lit, // models light by default
                    });
                }
            }
        }

        if vertices.is_empty() {
            return Err(MawidaError::AssetNotFound(format!(
                "gltf {full:?} contains no geometry"
            )));
        }
        Ok(LoadedModel {
            mesh: Mesh { vertices, indices },
            material: material.unwrap_or_default(),
        })
    }
}

/// What [`ContentManager::load_gltf`] returns: a mesh and a material,
/// nothing new. Wrap it in a [`Model`](crate::drawable::Model) with a
/// transform and it draws through the same call as every sprite.
#[cfg(feature = "gltf")]
#[derive(Debug, Clone)]
pub struct LoadedModel {
    pub mesh: crate::drawable::Mesh,
    pub material: crate::drawable::Material,
}

// ---------------------------------------------------------------------------
// The texture atlas builder (v0.2 step 10b)
// ---------------------------------------------------------------------------

/// Packs many images into ONE texture so batching never has to break for a
/// texture change: each packed image gets a NORMALIZED uv rectangle
/// `[x/W, y/H, w/W, h/H]` that a sprite carries in `Material::uv_rect`, and
/// every sprite shares one `TextureId`, which means one draw call.
///
/// No wgpu here: the atlas canvas is just another [`TextureData`], uploaded
/// through the exact same path as any texture, and this stays unit-testable
/// (it is pure byte shuffling).
///
/// The packer is a simple SHELF packer: place images left to right; when a
/// row fills, start a new shelf below the tallest image of the row. Twenty
/// lines that cover most 2D games; an offline packer (TexturePacker etc.)
/// emits the same data (one image + rects) if you outgrow it.
#[derive(Debug)]
pub struct AtlasBuilder {
    width: u32,
    height: u32,
    pixels: Vec<u8>,     // the RGBA8 canvas
    cursor: (u32, u32),  // shelf packer: left-to-right, wrap to a new shelf
    shelf_h: u32,        // tallest image on the current shelf
    pad: u32,            // the gutter, in pixels (2 is plenty)
}

impl AtlasBuilder {
    /// An empty transparent canvas. `pad` is THE GUTTER: with linear
    /// filtering (or scaling/mipmaps), sampling near a region's edge blends
    /// in the neighboring texel, and in an atlas the neighbor is a different
    /// image: colored fringes leak in (bleeding). One to two pixels of
    /// padding around every region is the classic fix. Pad now; it is cheap.
    pub fn new(width: u32, height: u32, pad: u32) -> Self {
        Self {
            width,
            height,
            pixels: vec![0; (width * height * 4) as usize],
            cursor: (pad, pad),
            shelf_h: 0,
            pad,
        }
    }

    /// Blit `img` at the cursor (plus gutter), advance, and return where it
    /// landed as a NORMALIZED `[min_x, min_y, size_x, size_y]`. That array
    /// is exactly what `Material::uv_rect` (and the instance buffer) carry;
    /// the shader never learns pixel coordinates.
    ///
    /// Errors if the image cannot fit on any shelf.
    pub fn add(&mut self, img: &TextureData) -> Result<[f32; 4]> {
        let (w, h) = (img.width, img.height);

        // Wrap to a new shelf if this image would run off the right edge.
        if self.cursor.0 + w + self.pad > self.width {
            self.cursor = (self.pad, self.cursor.1 + self.shelf_h + self.pad);
            self.shelf_h = 0;
        }
        // Out of vertical room: the canvas is full.
        if self.cursor.1 + h + self.pad > self.height || self.cursor.0 + w + self.pad > self.width
        {
            return Err(MawidaError::Other(format!(
                "atlas full: {}x{} image does not fit a {}x{} canvas",
                w, h, self.width, self.height
            )));
        }

        // Blit, row by row (both buffers are tightly packed RGBA8).
        let (x0, y0) = self.cursor;
        for row in 0..h {
            let src = (row * w * 4) as usize;
            let dst = (((y0 + row) * self.width + x0) * 4) as usize;
            self.pixels[dst..dst + (w * 4) as usize]
                .copy_from_slice(&img.rgba[src..src + (w * 4) as usize]);
        }

        self.cursor.0 += w + self.pad;
        self.shelf_h = self.shelf_h.max(h);

        // Normalized: downstream code never depends on the atlas size.
        Ok([
            x0 as f32 / self.width as f32,
            y0 as f32 / self.height as f32,
            w as f32 / self.width as f32,
            h as f32 / self.height as f32,
        ])
    }

    /// Register the canvas as one texture; every region shares this id.
    /// Takes `self` BY VALUE, consuming the builder: you cannot keep adding
    /// images after the canvas has been registered. In C++ that invariant
    /// would be a comment; here it is the signature.
    pub fn build(self, content: &mut ContentManager) -> TextureId {
        content.insert_rgba(self.width, self.height, self.pixels)
    }
}

// ---------------------------------------------------------------------------
// The sprite sheet (v0.2 step 11): an atlas whose rects you compute
// ---------------------------------------------------------------------------

/// An atlas with a rule: same-size frames on a grid, so six numbers replace
/// the whole rect table and a formula replaces the lookup. No wgpu: the
/// sheet is one ordinary texture; this struct only describes its layout.
#[derive(Debug, Clone)]
pub struct SpriteSheet {
    /// The whole sheet: ONE texture, uploaded once.
    pub texture: TextureId,
    /// Full image, pixels.
    pub sheet_size: (u32, u32),
    /// One frame, pixels.
    pub frame_size: (u32, u32),
    /// Frames per row.
    pub columns: u32,
    /// Total count (the last row may be partial).
    pub frames: u32,
    /// Gutter around each frame, pixels (0 if the sheet has none). Assumed
    /// BEFORE each frame as well as between them, matching AtlasBuilder.
    pub pad: u32,
}

impl SpriteSheet {
    /// The normalized uv rect for frame `i`, row-major: left to right, then
    /// down. Exactly the `[min.x, min.y, size.x, size.y]` a `Material`
    /// carries since step 10. `(i % columns, i / columns)` is the whole
    /// trick: remainder walks across a row, division drops to the next.
    /// All arithmetic stays in u32 until the final normalize.
    pub fn frame_rect(&self, i: u32) -> [f32; 4] {
        let i = i.min(self.frames.saturating_sub(1)); // clamp bad input; never panic
        let (col, row) = (i % self.columns, i / self.columns); // grid math
        let cell = (self.frame_size.0 + self.pad, self.frame_size.1 + self.pad);
        let (w, h) = (self.sheet_size.0 as f32, self.sheet_size.1 as f32);
        [
            (self.pad + col * cell.0) as f32 / w, // min.x
            (self.pad + row * cell.1) as f32 / h, // min.y
            self.frame_size.0 as f32 / w,         // size.x
            self.frame_size.1 as f32 / h,         // size.y
        ]
    }
}

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

    #[test]
    fn insert_and_get_roundtrip() {
        let mut cm = ContentManager::new();
        let id = cm.insert_rgba(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 255]);
        let data = cm.get(id).unwrap();
        assert_eq!(data.width, 2);
        assert_eq!(data.height, 1);
        assert_eq!(data.rgba.len(), 8);
    }

    #[test]
    fn missing_asset_errors() {
        let cm = ContentManager::new();
        assert!(cm.get(TextureId(999)).is_err());
    }

    #[test]
    fn ids_are_unique() {
        let mut cm = ContentManager::new();
        let a = cm.white_pixel();
        let b = cm.white_pixel();
        assert_ne!(a, b);
    }

    /// A solid-color test image.
    fn solid(w: u32, h: u32, rgba: [u8; 4]) -> TextureData {
        TextureData {
            width: w,
            height: h,
            rgba: rgba.repeat((w * h) as usize),
        }
    }

    #[test]
    fn atlas_packs_and_normalizes() {
        let mut atlas = AtlasBuilder::new(16, 16, 2);
        let red = atlas.add(&solid(4, 4, [255, 0, 0, 255])).unwrap();
        let green = atlas.add(&solid(4, 4, [0, 255, 0, 255])).unwrap();

        // First image lands at (pad, pad) = (2, 2); 4/16 = 0.25 wide.
        assert_eq!(red, [2.0 / 16.0, 2.0 / 16.0, 0.25, 0.25]);
        // Second advances right by w + pad: x = 2 + 4 + 2 = 8.
        assert_eq!(green, [8.0 / 16.0, 2.0 / 16.0, 0.25, 0.25]);

        let mut cm = ContentManager::new();
        let id = atlas.build(&mut cm);
        let data = cm.get(id).unwrap();
        assert_eq!((data.width, data.height), (16, 16));
        // The red image's top-left pixel sits at canvas (2, 2).
        let i = ((2 * 16 + 2) * 4) as usize;
        assert_eq!(&data.rgba[i..i + 4], &[255, 0, 0, 255]);
    }

    #[test]
    fn atlas_wraps_to_a_new_shelf() {
        let mut atlas = AtlasBuilder::new(16, 32, 2);
        let a = atlas.add(&solid(5, 4, [1, 1, 1, 255])).unwrap();
        let b = atlas.add(&solid(5, 4, [2, 2, 2, 255])).unwrap();
        // 2+5+2 +5+2 = 16 fits exactly; the third must wrap below.
        let c = atlas.add(&solid(5, 4, [3, 3, 3, 255])).unwrap();
        assert_eq!(a[1], b[1]); // same shelf
        assert!(c[1] > a[1]); // new shelf, further down
        assert_eq!(c[0], 2.0 / 16.0); // back at the left edge (plus pad)
    }

    #[test]
    fn atlas_full_is_an_error_not_a_panic() {
        let mut atlas = AtlasBuilder::new(8, 8, 2);
        assert!(atlas.add(&solid(16, 16, [0, 0, 0, 255])).is_err());
    }

    #[test]
    fn sheet_frame_rect_grid_math() {
        // A 192x32 sheet of six 32x32 frames in one row, no padding.
        let sheet = SpriteSheet {
            texture: TextureId(0),
            sheet_size: (192, 32),
            frame_size: (32, 32),
            columns: 6,
            frames: 6,
            pad: 0,
        };
        assert_eq!(sheet.frame_rect(0), [0.0, 0.0, 32.0 / 192.0, 1.0]);
        assert_eq!(sheet.frame_rect(3), [96.0 / 192.0, 0.0, 32.0 / 192.0, 1.0]);
        // Bad input clamps to the last frame; it never panics.
        assert_eq!(sheet.frame_rect(99), sheet.frame_rect(5));
    }

    /// Loads the repo's generated cube (assets/models/cube.gltf): the
    /// whole accessor path proven headless. Runs under --features gltf.
    #[cfg(feature = "gltf")]
    #[test]
    fn gltf_cube_round_trips() {
        let mut cm = ContentManager::new();
        let m = cm.load_gltf("assets/models/cube.gltf").expect("load cube");
        assert_eq!(m.mesh.vertices.len(), 24); // four per face, own normals
        assert_eq!(m.mesh.indices.len(), 36); // two triangles per face
        assert!(m.mesh.vertices.iter().all(|v| v.normal != [0.0, 0.0, 0.0]));
        assert_eq!(m.material.shading, crate::drawable::Shading::Lit);
        // the generator baked an orange-ish base color factor
        assert!(m.material.base_color.r > 0.5 && m.material.base_color.b < 0.5);
    }

    /// The scene-graph walk, proven: three nodes share one cube mesh; one
    /// is translated, one scaled, and one is a CHILD of the scaled node
    /// (so its translation is scaled by the parent). 72 verts total.
    #[cfg(feature = "gltf")]
    #[test]
    fn gltf_scene_graph_bakes_node_transforms() {
        let mut cm = ContentManager::new();
        let m = cm.load_gltf("assets/models/two_cubes.gltf").expect("load");
        assert_eq!(m.mesh.vertices.len(), 72); // 24 x 3 nodes, baked

        let xs: Vec<f32> = m.mesh.vertices.iter().map(|v| v.position[0]).collect();
        let ys: Vec<f32> = m.mesh.vertices.iter().map(|v| v.position[1]).collect();
        // node 0: translated to x = 3 (cube spans 2.5..3.5)
        assert!(xs.iter().cloned().fold(f32::MIN, f32::max) > 3.4);
        // node 2: child of the half-scale parent, so its +4 y translation
        // bakes to +2 (cube spans 1.75..2.25): parent-first composition
        let max_y = ys.iter().cloned().fold(f32::MIN, f32::max);
        assert!((max_y - 2.25).abs() < 1e-4, "child transform must compose through the parent, got max_y {max_y}");
    }

    #[test]
    fn sheet_wraps_rows_and_honors_pad() {
        // 2 columns of 8x8 frames with a 1 px gutter: cell = 9x9.
        let sheet = SpriteSheet {
            texture: TextureId(0),
            sheet_size: (19, 19),
            frame_size: (8, 8),
            columns: 2,
            frames: 4,
            pad: 1,
        };
        // Frame 2 starts row 1: x = pad, y = pad + cell.
        assert_eq!(
            sheet.frame_rect(2),
            [1.0 / 19.0, 10.0 / 19.0, 8.0 / 19.0, 8.0 / 19.0]
        );
    }
}
