use std::io::Cursor;

use binrw::BinRead;
use tracing::{info, info_span};

use crate::error::{Error, Result};
use crate::mdl::bone::{BONE_TABLE_BONE_SLOTS, BoneTable};
use crate::mdl::bounding_box::{BOUNDING_BOX_SIZE, BoundingBox};
use crate::mdl::header::{
    MODEL_FILE_HEADER_SIZE, MODEL_HEADER_SIZE, ModelFileHeader, ModelHeader, WireModelHeader,
};
use crate::mdl::lod::{ELEMENT_ID_SIZE, EXTRA_LOD_SIZE, ElementId, ExtraLod, LOD_SIZE, Lod};
use crate::mdl::mesh::{
    MESH_SIZE, Mesh, SUBMESH_SIZE, Submesh, TERRAIN_SHADOW_MESH_SIZE, TERRAIN_SHADOW_SUBMESH_SIZE,
    TerrainShadowMesh, TerrainShadowSubmesh,
};
use crate::mdl::shape::{
    SHAPE_MESH_SIZE, SHAPE_SIZE, SHAPE_VALUE_SIZE, Shape, ShapeMesh, ShapeValue,
};
use crate::mdl::vertex::{
    VERTEX_DECLARATION_SLOT_BYTES, VERTEX_ELEMENT_SLOTS, VertexDeclaration, VertexElement,
    WireVertexElement,
};
use crate::tracing_schema::span_names;

/// Number of LOD levels in every MDL file.
pub const LOD_COUNT: usize = 3;

const MAX_VERTEX_DECLARATIONS: usize = 4096;

const MAX_MESHES: usize = 4096;

const MAX_SUBMESHES: usize = 16384;

const MAX_TERRAIN_SHADOW_MESHES: usize = 4096;

const MAX_ELEMENT_IDS: usize = 1024;

const MAX_NAME_OFFSETS: usize = 8192;

const MAX_BONE_TABLES: usize = 1024;

const MAX_SHAPE_ENTRIES: usize = 16384;

const MAX_SUBMESH_BONE_MAP_BYTES: u32 = 1 << 20;

const MAX_STRING_TABLE_BYTES: u32 = 256 * 1024;

/// Zero-copy view over a parsed MDL file.
///
/// Holds a reference to the original byte slice.  All structural sections
/// (meshes, bone tables, shapes, …) are decoded eagerly on [`Mdl::parse`] and stored as
/// `Vec`s; vertex and index buffer data remain as byte-slice windows.
#[derive(Debug, Clone)]
pub struct Mdl<'a> {
    bytes: &'a [u8],
    file_header: ModelFileHeader,
    model_header: ModelHeader,
    vertex_declarations: Vec<VertexDeclaration>,
    string_table: &'a [u8],
    element_ids: Vec<ElementId>,
    lods: [Lod; LOD_COUNT],
    extra_lods: Option<[ExtraLod; LOD_COUNT]>,
    meshes: Vec<Mesh>,
    attribute_offsets: Vec<u32>,
    terrain_shadow_meshes: Vec<TerrainShadowMesh>,
    submeshes: Vec<Submesh>,
    terrain_shadow_submeshes: Vec<TerrainShadowSubmesh>,
    material_offsets: Vec<u32>,
    bone_offsets: Vec<u32>,
    bone_tables: Vec<BoneTable>,
    shapes: Vec<Shape>,
    shape_meshes: Vec<ShapeMesh>,
    shape_values: Vec<ShapeValue>,
    submesh_bone_map: Vec<u16>,
    bounding_box: BoundingBox,
    model_bounding_box: BoundingBox,
    water_bounding_box: BoundingBox,
    vertical_fog_bounding_box: BoundingBox,
    bone_bounding_boxes: Vec<BoundingBox>,
}

impl<'a> Mdl<'a> {
    /// Parses an MDL file from `bytes`, returning a zero-copy view.
    ///
    /// Emits a `mdl.parse` tracing event on success.  Returns
    /// [`Error::InvalidMdlLength`] if the buffer is too short for any section, or
    /// [`Error::InvalidMdlSection`] for structural violations such as oversized counts.
    ///
    /// Bone-table format is selected automatically based on [`ModelFileHeader::version`]:
    /// version `≥ 0x01000006` uses the Dawntrail v2 layout; earlier versions use the legacy
    /// 132-byte-per-table layout.
    ///
    /// # Panics
    ///
    /// Panics if internal vector-to-array conversions fail after length-checked reads — these
    /// are guarded invariants that cannot be triggered by malformed input.
    #[allow(clippy::too_many_lines)]
    pub fn parse(bytes: &'a [u8]) -> Result<Self> {
        let span = info_span!(span_names::MDL_PARSE, bytes = bytes.len());
        let _enter = span.enter();
        let start = std::time::Instant::now();

        if bytes.len() < MODEL_FILE_HEADER_SIZE {
            return Err(Error::InvalidMdlLength {
                found: bytes.len(),
                minimum: MODEL_FILE_HEADER_SIZE,
            });
        }

        let mut cursor = Cursor::new(bytes);
        let file_header =
            ModelFileHeader::read(&mut cursor).map_err(|e| Error::decode("mdl.file_header", &e))?;

        let vertex_decl_count = usize::from(file_header.vertex_declaration_count);
        if vertex_decl_count > MAX_VERTEX_DECLARATIONS {
            return Err(Error::InvalidMdlSection {
                section: "vertex_declarations",
                reason: "count exceeds MAX_VERTEX_DECLARATIONS",
            });
        }
        let vertex_declarations = read_vertex_declarations(&mut cursor, vertex_decl_count)?;

        let _ = read_u16(&mut cursor, "mdl.string_count")?;
        let _ = read_u16(&mut cursor, "mdl.string_pad")?;
        let string_size = read_u32(&mut cursor, "mdl.string_size")?;
        if string_size > MAX_STRING_TABLE_BYTES {
            return Err(Error::InvalidMdlSection {
                section: "string_table",
                reason: "size exceeds MAX_STRING_TABLE_BYTES",
            });
        }
        let string_table_pos = position(&cursor)?;
        let string_table_end =
            string_table_pos
                .checked_add(string_size as usize)
                .ok_or(Error::InvalidMdlSection {
                    section: "string_table",
                    reason: "position + size overflows usize",
                })?;
        require_len(bytes, string_table_end, "string_table")?;
        let string_table = &bytes[string_table_pos..string_table_end];
        cursor.set_position(string_table_end as u64);

        require_len(bytes, string_table_end + MODEL_HEADER_SIZE, "model_header")?;
        let wire = WireModelHeader::read(&mut cursor)
            .map_err(|e| Error::decode("mdl.model_header", &e))?;
        let model_header: ModelHeader = wire.into();

        let element_id_count = usize::from(model_header.element_id_count);
        if element_id_count > MAX_ELEMENT_IDS {
            return Err(Error::InvalidMdlSection {
                section: "element_ids",
                reason: "count exceeds MAX_ELEMENT_IDS",
            });
        }
        let element_ids = read_vec::<ElementId, _>(
            &mut cursor,
            bytes,
            element_id_count,
            ELEMENT_ID_SIZE,
            "mdl.element_ids",
        )?;

        require_len(bytes, position(&cursor)? + LOD_SIZE * LOD_COUNT, "lods")?;
        let mut lods_vec: Vec<Lod> = Vec::with_capacity(LOD_COUNT);
        for _ in 0..LOD_COUNT {
            lods_vec.push(Lod::read(&mut cursor).map_err(|e| Error::decode("mdl.lod", &e))?);
        }
        let lods: [Lod; LOD_COUNT] = lods_vec
            .try_into()
            .expect("read_vec produced exactly LOD_COUNT entries");

        let extra_lods = if model_header.extra_lod_enabled() {
            require_len(
                bytes,
                position(&cursor)? + EXTRA_LOD_SIZE * LOD_COUNT,
                "extra_lods",
            )?;
            let mut v: Vec<ExtraLod> = Vec::with_capacity(LOD_COUNT);
            for _ in 0..LOD_COUNT {
                v.push(
                    ExtraLod::read(&mut cursor).map_err(|e| Error::decode("mdl.extra_lod", &e))?,
                );
            }
            let arr: [ExtraLod; LOD_COUNT] = v
                .try_into()
                .expect("read produced exactly LOD_COUNT extra lods");
            Some(arr)
        } else {
            None
        };

        let mesh_count = usize::from(model_header.mesh_count);
        if mesh_count > MAX_MESHES {
            return Err(Error::InvalidMdlSection {
                section: "meshes",
                reason: "count exceeds MAX_MESHES",
            });
        }
        let meshes = read_vec::<Mesh, _>(&mut cursor, bytes, mesh_count, MESH_SIZE, "mdl.meshes")?;

        let attribute_count = usize::from(model_header.attribute_count);
        if attribute_count > MAX_NAME_OFFSETS {
            return Err(Error::InvalidMdlSection {
                section: "attribute_offsets",
                reason: "count exceeds MAX_NAME_OFFSETS",
            });
        }
        let attribute_offsets =
            read_u32_array(&mut cursor, bytes, attribute_count, "mdl.attribute_offsets")?;

        let terrain_shadow_mesh_count = usize::from(model_header.terrain_shadow_mesh_count);
        if terrain_shadow_mesh_count > MAX_TERRAIN_SHADOW_MESHES {
            return Err(Error::InvalidMdlSection {
                section: "terrain_shadow_meshes",
                reason: "count exceeds MAX_TERRAIN_SHADOW_MESHES",
            });
        }
        let terrain_shadow_meshes = read_vec::<TerrainShadowMesh, _>(
            &mut cursor,
            bytes,
            terrain_shadow_mesh_count,
            TERRAIN_SHADOW_MESH_SIZE,
            "mdl.terrain_shadow_meshes",
        )?;

        let submesh_count = usize::from(model_header.submesh_count);
        if submesh_count > MAX_SUBMESHES {
            return Err(Error::InvalidMdlSection {
                section: "submeshes",
                reason: "count exceeds MAX_SUBMESHES",
            });
        }
        let submeshes = read_vec::<Submesh, _>(
            &mut cursor,
            bytes,
            submesh_count,
            SUBMESH_SIZE,
            "mdl.submeshes",
        )?;

        let terrain_shadow_submesh_count = usize::from(model_header.terrain_shadow_submesh_count);
        if terrain_shadow_submesh_count > MAX_TERRAIN_SHADOW_MESHES {
            return Err(Error::InvalidMdlSection {
                section: "terrain_shadow_submeshes",
                reason: "count exceeds MAX_TERRAIN_SHADOW_MESHES",
            });
        }
        let terrain_shadow_submeshes = read_vec::<TerrainShadowSubmesh, _>(
            &mut cursor,
            bytes,
            terrain_shadow_submesh_count,
            TERRAIN_SHADOW_SUBMESH_SIZE,
            "mdl.terrain_shadow_submeshes",
        )?;

        let material_count = usize::from(model_header.material_count);
        if material_count > MAX_NAME_OFFSETS {
            return Err(Error::InvalidMdlSection {
                section: "material_offsets",
                reason: "count exceeds MAX_NAME_OFFSETS",
            });
        }
        let material_offsets =
            read_u32_array(&mut cursor, bytes, material_count, "mdl.material_offsets")?;

        let bone_count = usize::from(model_header.bone_count);
        if bone_count > MAX_NAME_OFFSETS {
            return Err(Error::InvalidMdlSection {
                section: "bone_offsets",
                reason: "count exceeds MAX_NAME_OFFSETS",
            });
        }
        let bone_offsets = read_u32_array(&mut cursor, bytes, bone_count, "mdl.bone_offsets")?;

        let bone_table_count = usize::from(model_header.bone_table_count);
        if bone_table_count > MAX_BONE_TABLES {
            return Err(Error::InvalidMdlSection {
                section: "bone_tables",
                reason: "count exceeds MAX_BONE_TABLES",
            });
        }
        let bone_tables = if file_header.version >= BoneTable::V2_VERSION_MIN {
            read_bone_tables_v2(
                &mut cursor,
                bytes,
                bone_table_count,
                model_header.bone_count,
            )?
        } else {
            read_bone_tables_legacy(&mut cursor, bytes, bone_table_count)?
        };

        let shape_count = usize::from(model_header.shape_count);
        if shape_count > MAX_SHAPE_ENTRIES {
            return Err(Error::InvalidMdlSection {
                section: "shapes",
                reason: "count exceeds MAX_SHAPE_ENTRIES",
            });
        }
        let shapes =
            read_vec::<Shape, _>(&mut cursor, bytes, shape_count, SHAPE_SIZE, "mdl.shapes")?;

        let shape_mesh_count = usize::from(model_header.shape_mesh_count);
        if shape_mesh_count > MAX_SHAPE_ENTRIES {
            return Err(Error::InvalidMdlSection {
                section: "shape_meshes",
                reason: "count exceeds MAX_SHAPE_ENTRIES",
            });
        }
        let shape_meshes = read_vec::<ShapeMesh, _>(
            &mut cursor,
            bytes,
            shape_mesh_count,
            SHAPE_MESH_SIZE,
            "mdl.shape_meshes",
        )?;

        let shape_value_count = usize::from(model_header.shape_value_count);
        if shape_value_count > MAX_SHAPE_ENTRIES {
            return Err(Error::InvalidMdlSection {
                section: "shape_values",
                reason: "count exceeds MAX_SHAPE_ENTRIES",
            });
        }
        let shape_values = read_vec::<ShapeValue, _>(
            &mut cursor,
            bytes,
            shape_value_count,
            SHAPE_VALUE_SIZE,
            "mdl.shape_values",
        )?;

        let submesh_bone_map_size = read_u32(&mut cursor, "mdl.submesh_bone_map_size")?;
        if submesh_bone_map_size > MAX_SUBMESH_BONE_MAP_BYTES {
            return Err(Error::InvalidMdlSection {
                section: "submesh_bone_map",
                reason: "size exceeds MAX_SUBMESH_BONE_MAP_BYTES",
            });
        }
        if submesh_bone_map_size % 2 != 0 {
            return Err(Error::InvalidMdlSection {
                section: "submesh_bone_map",
                reason: "byte size not a multiple of 2",
            });
        }
        let submesh_bone_map_count = (submesh_bone_map_size / 2) as usize;
        let bone_map_pos = position(&cursor)?;
        let bone_map_end = bone_map_pos
            .checked_add(submesh_bone_map_size as usize)
            .ok_or(Error::InvalidMdlSection {
                section: "submesh_bone_map",
                reason: "position + size overflows usize",
            })?;
        require_len(bytes, bone_map_end, "submesh_bone_map")?;
        let mut submesh_bone_map = Vec::with_capacity(submesh_bone_map_count);
        for _ in 0..submesh_bone_map_count {
            let mut buf = [0u8; 2];
            std::io::Read::read_exact(&mut cursor, &mut buf)
                .map_err(|e| Error::decode("mdl.submesh_bone_map", &binrw::Error::Io(e)))?;
            submesh_bone_map.push(u16::from_le_bytes(buf));
        }

        let pad_amount = read_u8(&mut cursor, "mdl.padding_amount")?;
        let after_pad = position(&cursor)?
            .checked_add(usize::from(pad_amount))
            .ok_or(Error::InvalidMdlSection {
                section: "padding",
                reason: "position + pad_amount overflows usize",
            })?;
        require_len(bytes, after_pad, "padding")?;
        cursor.set_position(after_pad as u64);

        require_len(bytes, position(&cursor)? + 4 * BOUNDING_BOX_SIZE, "bboxes")?;
        let bounding_box =
            BoundingBox::read(&mut cursor).map_err(|e| Error::decode("mdl.bounding_box", &e))?;
        let model_bounding_box = BoundingBox::read(&mut cursor)
            .map_err(|e| Error::decode("mdl.model_bounding_box", &e))?;
        let water_bounding_box = BoundingBox::read(&mut cursor)
            .map_err(|e| Error::decode("mdl.water_bounding_box", &e))?;
        let vertical_fog_bounding_box = BoundingBox::read(&mut cursor)
            .map_err(|e| Error::decode("mdl.vertical_fog_bounding_box", &e))?;

        let bone_bounding_boxes = read_vec::<BoundingBox, _>(
            &mut cursor,
            bytes,
            bone_count,
            BOUNDING_BOX_SIZE,
            "mdl.bone_bounding_boxes",
        )?;

        let mdl = Mdl {
            bytes,
            file_header,
            model_header,
            vertex_declarations,
            string_table,
            element_ids,
            lods,
            extra_lods,
            meshes,
            attribute_offsets,
            terrain_shadow_meshes,
            submeshes,
            terrain_shadow_submeshes,
            material_offsets,
            bone_offsets,
            bone_tables,
            shapes,
            shape_meshes,
            shape_values,
            submesh_bone_map,
            bounding_box,
            model_bounding_box,
            water_bounding_box,
            vertical_fog_bounding_box,
            bone_bounding_boxes,
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = start.elapsed().as_millis() as u64;
        info!(
            target: "mdl",
            bytes = bytes.len(),
            version = mdl.file_header.version,
            lod_count = mdl.file_header.lod_count,
            mesh_count = mdl.model_header.mesh_count,
            submesh_count = mdl.model_header.submesh_count,
            material_count = mdl.model_header.material_count,
            bone_count = mdl.model_header.bone_count,
            shape_count = mdl.model_header.shape_count,
            elapsed_ms,
            "mdl.parse",
        );

        Ok(mdl)
    }

    /// Returns the entire raw byte slice passed to [`Mdl::parse`].
    #[must_use]
    pub fn raw(&self) -> &'a [u8] {
        self.bytes
    }

    /// Returns the [`ModelFileHeader`] decoded from the start of the file.
    #[must_use]
    pub fn file_header(&self) -> &ModelFileHeader {
        &self.file_header
    }

    /// Returns the [`ModelHeader`] decoded after the vertex declarations and string table.
    #[must_use]
    pub fn model_header(&self) -> &ModelHeader {
        &self.model_header
    }

    /// Returns the vertex declarations that describe each mesh's buffer layout.
    #[must_use]
    pub fn vertex_declarations(&self) -> &[VertexDeclaration] {
        &self.vertex_declarations
    }

    /// Returns the raw null-terminated string table bytes.
    ///
    /// Use [`Mdl::string_at`] to resolve an offset to a `&str`.
    #[must_use]
    pub fn string_table(&self) -> &'a [u8] {
        self.string_table
    }

    /// Resolves a byte offset into the string table to a UTF-8 string slice.
    ///
    /// Returns [`Error::InvalidMdlStringOffset`] if `offset` is out of range or if the
    /// string is not null-terminated, and [`Error::InvalidUtf8InMdlString`] if the bytes
    /// are not valid UTF-8.
    pub fn string_at(&self, offset: u32) -> Result<&'a str> {
        let off = offset as usize;
        if off > self.string_table.len() {
            return Err(Error::InvalidMdlStringOffset {
                offset,
                table_len: self.string_table.len(),
            });
        }
        let tail = &self.string_table[off..];
        let end = tail
            .iter()
            .position(|&b| b == 0)
            .ok_or(Error::InvalidMdlStringOffset {
                offset,
                table_len: self.string_table.len(),
            })?;
        std::str::from_utf8(&tail[..end]).map_err(|_| Error::InvalidUtf8InMdlString { offset })
    }

    /// Returns the [`ElementId`] table.
    #[must_use]
    pub fn element_ids(&self) -> &[ElementId] {
        &self.element_ids
    }

    /// Returns the fixed-size array of all three [`Lod`] definitions.
    #[must_use]
    pub fn lods(&self) -> &[Lod; LOD_COUNT] {
        &self.lods
    }

    /// Returns the [`Lod`] at the given index, or [`Error::MdlLodOutOfRange`].
    pub fn lod(&self, lod: usize) -> Result<&Lod> {
        self.lods.get(lod).ok_or(Error::MdlLodOutOfRange {
            lod,
            lod_count: LOD_COUNT,
        })
    }

    /// Returns the optional extra-LOD array, present when [`ModelFlags2::EXTRA_LOD_ENABLED`] is
    /// set.
    #[must_use]
    pub fn extra_lods(&self) -> Option<&[ExtraLod; LOD_COUNT]> {
        self.extra_lods.as_ref()
    }

    /// Returns all [`Mesh`] descriptors.
    #[must_use]
    pub fn meshes(&self) -> &[Mesh] {
        &self.meshes
    }

    /// Returns all [`Submesh`] descriptors.
    #[must_use]
    pub fn submeshes(&self) -> &[Submesh] {
        &self.submeshes
    }

    /// Returns terrain shadow mesh descriptors.
    #[must_use]
    pub fn terrain_shadow_meshes(&self) -> &[TerrainShadowMesh] {
        &self.terrain_shadow_meshes
    }

    /// Returns terrain shadow submesh descriptors.
    #[must_use]
    pub fn terrain_shadow_submeshes(&self) -> &[TerrainShadowSubmesh] {
        &self.terrain_shadow_submeshes
    }

    /// Returns raw string-table offsets for each material name.
    ///
    /// Use [`Mdl::material_name`] to resolve an index to a `&str`.
    #[must_use]
    pub fn material_offsets(&self) -> &[u32] {
        &self.material_offsets
    }

    /// Returns raw string-table offsets for each bone name.
    ///
    /// Use [`Mdl::bone_name`] to resolve an index to a `&str`.
    #[must_use]
    pub fn bone_offsets(&self) -> &[u32] {
        &self.bone_offsets
    }

    /// Returns raw string-table offsets for each attribute name.
    ///
    /// Use [`Mdl::attribute_name`] to resolve an index to a `&str`.
    #[must_use]
    pub fn attribute_offsets(&self) -> &[u32] {
        &self.attribute_offsets
    }

    /// Resolves a material index to its name string.
    ///
    /// Returns [`Error::MdlMaterialOutOfRange`] if `idx` is out of bounds.
    pub fn material_name(&self, idx: usize) -> Result<&'a str> {
        let off = *self
            .material_offsets
            .get(idx)
            .ok_or(Error::MdlMaterialOutOfRange {
                index: idx,
                material_count: self.material_offsets.len(),
            })?;
        self.string_at(off)
    }

    /// Resolves a bone index to its name string.
    ///
    /// Returns [`Error::MdlBoneOutOfRange`] if `idx` is out of bounds.
    pub fn bone_name(&self, idx: usize) -> Result<&'a str> {
        let off = *self.bone_offsets.get(idx).ok_or(Error::MdlBoneOutOfRange {
            index: idx,
            bone_count: self.bone_offsets.len(),
        })?;
        self.string_at(off)
    }

    /// Resolves an attribute index to its name string.
    ///
    /// Returns [`Error::MdlAttributeOutOfRange`] if `idx` is out of bounds.
    pub fn attribute_name(&self, idx: usize) -> Result<&'a str> {
        let off = *self
            .attribute_offsets
            .get(idx)
            .ok_or(Error::MdlAttributeOutOfRange {
                index: idx,
                attribute_count: self.attribute_offsets.len(),
            })?;
        self.string_at(off)
    }

    /// Resolves a shape index to its name string via the embedded string offset.
    ///
    /// Returns [`Error::MdlShapeOutOfRange`] if `idx` is out of bounds.
    pub fn shape_name(&self, idx: usize) -> Result<&'a str> {
        let s = self.shapes.get(idx).ok_or(Error::MdlShapeOutOfRange {
            index: idx,
            shape_count: self.shapes.len(),
        })?;
        self.string_at(s.string_offset)
    }

    /// Resolves all material offsets and returns a `Vec` of name strings.
    pub fn materials(&self) -> Result<Vec<&'a str>> {
        (0..self.material_offsets.len())
            .map(|i| self.material_name(i))
            .collect()
    }

    /// Resolves all bone offsets and returns a `Vec` of name strings.
    pub fn bones(&self) -> Result<Vec<&'a str>> {
        (0..self.bone_offsets.len())
            .map(|i| self.bone_name(i))
            .collect()
    }

    /// Resolves all attribute offsets and returns a `Vec` of name strings.
    pub fn attributes(&self) -> Result<Vec<&'a str>> {
        (0..self.attribute_offsets.len())
            .map(|i| self.attribute_name(i))
            .collect()
    }

    /// Returns the per-mesh bone palette tables.
    #[must_use]
    pub fn bone_tables(&self) -> &[BoneTable] {
        &self.bone_tables
    }

    /// Returns all [`Shape`] descriptors (morph target names and mesh ranges).
    #[must_use]
    pub fn shapes(&self) -> &[Shape] {
        &self.shapes
    }

    /// Returns all [`ShapeMesh`] entries linking shapes to mesh regions.
    #[must_use]
    pub fn shape_meshes(&self) -> &[ShapeMesh] {
        &self.shape_meshes
    }

    /// Returns all [`ShapeValue`] entries describing per-vertex displacement data.
    #[must_use]
    pub fn shape_values(&self) -> &[ShapeValue] {
        &self.shape_values
    }

    /// Returns the global bone-index remap table used by submeshes.
    #[must_use]
    pub fn submesh_bone_map(&self) -> &[u16] {
        &self.submesh_bone_map
    }

    /// Returns the top-level axis-aligned bounding box for the whole model.
    #[must_use]
    pub fn bounding_box(&self) -> &BoundingBox {
        &self.bounding_box
    }

    /// Returns the bounding box used for model-level culling.
    #[must_use]
    pub fn model_bounding_box(&self) -> &BoundingBox {
        &self.model_bounding_box
    }

    /// Returns the bounding box of water surfaces in the model.
    #[must_use]
    pub fn water_bounding_box(&self) -> &BoundingBox {
        &self.water_bounding_box
    }

    /// Returns the bounding box of vertical fog geometry.
    #[must_use]
    pub fn vertical_fog_bounding_box(&self) -> &BoundingBox {
        &self.vertical_fog_bounding_box
    }

    /// Returns per-bone bounding boxes in the same order as [`Mdl::bone_offsets`].
    #[must_use]
    pub fn bone_bounding_boxes(&self) -> &[BoundingBox] {
        &self.bone_bounding_boxes
    }

    /// Returns a zero-copy view of the vertex buffer for the given LOD.
    ///
    /// Returns [`Error::MdlLodOutOfRange`] for `lod ≥ LOD_COUNT`, or
    /// [`Error::InvalidMdlSection`] if the declared window extends past the file.
    pub fn vertex_buffer(&self, lod: usize) -> Result<&'a [u8]> {
        if lod >= LOD_COUNT {
            return Err(Error::MdlLodOutOfRange {
                lod,
                lod_count: LOD_COUNT,
            });
        }
        let off = self.file_header.vertex_offset[lod];
        let size = self.file_header.vertex_buffer_size[lod];
        slice_window(self.bytes, off, size, "vertex_buffer")
    }

    /// Returns a zero-copy view of the index buffer for the given LOD.
    ///
    /// Returns [`Error::MdlLodOutOfRange`] for `lod ≥ LOD_COUNT`, or
    /// [`Error::InvalidMdlSection`] if the declared window extends past the file.
    pub fn index_buffer(&self, lod: usize) -> Result<&'a [u8]> {
        if lod >= LOD_COUNT {
            return Err(Error::MdlLodOutOfRange {
                lod,
                lod_count: LOD_COUNT,
            });
        }
        let off = self.file_header.index_offset[lod];
        let size = self.file_header.index_buffer_size[lod];
        slice_window(self.bytes, off, size, "index_buffer")
    }

    /// Decodes the index buffer for the given LOD into a `Vec<u16>`.
    ///
    /// The index buffer stores little-endian `u16` values; this method performs the
    /// conversion.  Returns an error if the byte count is not a multiple of 2.
    ///
    /// # Panics
    ///
    /// Panics if a 2-byte window cannot be converted to `[u8; 2]` — this is an internal
    /// invariant of `chunks_exact(2)` and cannot be triggered by callers.
    pub fn indices(&self, lod: usize) -> Result<Vec<u16>> {
        let bytes = self.index_buffer(lod)?;
        if bytes.len() % 2 != 0 {
            return Err(Error::InvalidMdlSection {
                section: "index_buffer",
                reason: "byte size not a multiple of 2",
            });
        }
        let mut out = Vec::with_capacity(bytes.len() / 2);
        for chunk in bytes.chunks_exact(2) {
            let arr: [u8; 2] = chunk
                .try_into()
                .expect("chunks_exact(2) yields 2-byte windows");
            out.push(u16::from_le_bytes(arr));
        }
        Ok(out)
    }
}

fn read_bone_tables_legacy(
    cursor: &mut Cursor<&[u8]>,
    bytes: &[u8],
    count: usize,
) -> Result<Vec<BoneTable>> {
    const SLOT_BYTES: usize = 132;
    let start = position(cursor)?;
    let total = count
        .checked_mul(SLOT_BYTES)
        .ok_or(Error::InvalidMdlSection {
            section: "bone_tables",
            reason: "count * 132 overflows usize",
        })?;
    let end = start.checked_add(total).ok_or(Error::InvalidMdlSection {
        section: "bone_tables",
        reason: "position + count*132 overflows usize",
    })?;
    require_len(bytes, end, "bone_tables")?;
    let mut out = Vec::with_capacity(count);
    for _ in 0..count {
        let mut slots = [0u16; BONE_TABLE_BONE_SLOTS];
        for slot in &mut slots {
            *slot = read_u16(cursor, "mdl.bone_tables.legacy_slot")?;
        }
        let bone_count = read_u8(cursor, "mdl.bone_tables.legacy_count")?;
        let mut pad = [0u8; 3];
        std::io::Read::read_exact(cursor, &mut pad)
            .map_err(|e| Error::decode("mdl.bone_tables.legacy_pad", &binrw::Error::Io(e)))?;
        out.push(BoneTable::from_legacy(slots, bone_count));
    }
    Ok(out)
}

fn read_bone_tables_v2(
    cursor: &mut Cursor<&[u8]>,
    bytes: &[u8],
    count: usize,
    total_bone_count: u16,
) -> Result<Vec<BoneTable>> {
    let mut headers = Vec::with_capacity(count);
    let header_bytes = count.checked_mul(4).ok_or(Error::InvalidMdlSection {
        section: "bone_tables",
        reason: "count * 4 overflows usize",
    })?;
    let header_end =
        position(cursor)?
            .checked_add(header_bytes)
            .ok_or(Error::InvalidMdlSection {
                section: "bone_tables",
                reason: "position + count*4 overflows usize",
            })?;
    require_len(bytes, header_end, "bone_tables")?;
    for _ in 0..count {
        let first_index = read_u16(cursor, "mdl.bone_tables.v2_first_index")?;
        let bt_count = read_u16(cursor, "mdl.bone_tables.v2_count")?;
        if bt_count > total_bone_count {
            return Err(Error::InvalidMdlSection {
                section: "bone_tables",
                reason: "v2 bone-table count exceeds model bone_count",
            });
        }
        headers.push((first_index, bt_count));
    }

    let mut out = Vec::with_capacity(count);
    for (first_index, bt_count) in headers {
        let array_bytes = usize::from(bt_count)
            .checked_mul(2)
            .ok_or(Error::InvalidMdlSection {
                section: "bone_tables",
                reason: "v2 bone-table count*2 overflows usize",
            })?;
        let array_end =
            position(cursor)?
                .checked_add(array_bytes)
                .ok_or(Error::InvalidMdlSection {
                    section: "bone_tables",
                    reason: "v2 position + count*2 overflows usize",
                })?;
        require_len(bytes, array_end, "bone_tables")?;
        let mut indices = Vec::with_capacity(usize::from(bt_count));
        for _ in 0..bt_count {
            indices.push(read_u16(cursor, "mdl.bone_tables.v2_index")?);
        }
        out.push(BoneTable::from_v2(first_index, indices));
        let after = position(cursor)?;
        let pad = (4 - (after % 4)) % 4;
        if pad > 0 {
            let pad_end = after.checked_add(pad).ok_or(Error::InvalidMdlSection {
                section: "bone_tables",
                reason: "v2 pad position overflows usize",
            })?;
            require_len(bytes, pad_end, "bone_tables")?;
            let mut buf = [0u8; 3];
            std::io::Read::read_exact(cursor, &mut buf[..pad])
                .map_err(|e| Error::decode("mdl.bone_tables.v2_pad", &binrw::Error::Io(e)))?;
        }
    }
    Ok(out)
}

fn read_vertex_declarations<R: std::io::Read + std::io::Seek>(
    cursor: &mut R,
    count: usize,
) -> Result<Vec<VertexDeclaration>> {
    let mut out = Vec::with_capacity(count);
    for _ in 0..count {
        let start_pos = cursor
            .stream_position()
            .map_err(|e| Error::decode("mdl.vertex_decl", &binrw::Error::Io(e)))?;
        let mut elements = Vec::new();
        let mut sentinel_seen = false;
        for _ in 0..VERTEX_ELEMENT_SLOTS {
            let wire = WireVertexElement::read(cursor)
                .map_err(|e| Error::decode("mdl.vertex_element", &e))?;
            if !sentinel_seen {
                if wire.is_sentinel() {
                    sentinel_seen = true;
                } else {
                    elements.push(VertexElement::from(wire));
                }
            }
        }
        let end_pos = cursor
            .stream_position()
            .map_err(|e| Error::decode("mdl.vertex_decl", &binrw::Error::Io(e)))?;
        if end_pos.saturating_sub(start_pos) != VERTEX_DECLARATION_SLOT_BYTES as u64 {
            return Err(Error::InvalidMdlSection {
                section: "vertex_declarations",
                reason: "declaration did not consume 136 bytes",
            });
        }
        if !sentinel_seen && elements.len() == VERTEX_ELEMENT_SLOTS {
            return Err(Error::InvalidMdlSection {
                section: "vertex_declarations",
                reason: "declaration missing terminating 0xFF sentinel",
            });
        }
        out.push(VertexDeclaration { elements });
    }
    Ok(out)
}

fn slice_window<'a>(
    bytes: &'a [u8],
    offset: u32,
    size: u32,
    section: &'static str,
) -> Result<&'a [u8]> {
    let off = offset as usize;
    let sz = size as usize;
    let end = off.checked_add(sz).ok_or(Error::InvalidMdlSection {
        section,
        reason: "offset + size overflows usize",
    })?;
    if end > bytes.len() {
        return Err(Error::InvalidMdlSection {
            section,
            reason: "window extends past end of file",
        });
    }
    Ok(&bytes[off..end])
}

fn position(cursor: &Cursor<&[u8]>) -> Result<usize> {
    let p = cursor.position();
    usize::try_from(p).map_err(|_| Error::InvalidMdlSection {
        section: "cursor",
        reason: "cursor position does not fit usize",
    })
}

fn require_len(bytes: &[u8], required: usize, _section: &'static str) -> Result<()> {
    if bytes.len() < required {
        return Err(Error::InvalidMdlLength {
            found: bytes.len(),
            minimum: required,
        });
    }
    Ok(())
}

fn read_u32_array(
    cursor: &mut Cursor<&[u8]>,
    bytes: &[u8],
    count: usize,
    _section: &'static str,
) -> Result<Vec<u32>> {
    require_len(
        bytes,
        position(cursor)?
            .checked_add(count.checked_mul(4).ok_or(Error::InvalidMdlSection {
                section: "u32_array",
                reason: "count * 4 overflows usize",
            })?)
            .ok_or(Error::InvalidMdlSection {
                section: "u32_array",
                reason: "position + count*4 overflows usize",
            })?,
        "u32_array",
    )?;
    let mut out = Vec::with_capacity(count);
    for _ in 0..count {
        let mut buf = [0u8; 4];
        std::io::Read::read_exact(cursor, &mut buf)
            .map_err(|e| Error::decode("mdl.u32_array", &binrw::Error::Io(e)))?;
        out.push(u32::from_le_bytes(buf));
    }
    Ok(out)
}

fn read_vec<T, R>(
    cursor: &mut R,
    bytes: &[u8],
    count: usize,
    entry_size: usize,
    section: &'static str,
) -> Result<Vec<T>>
where
    T: for<'r> BinRead<Args<'r> = ()> + binrw::meta::ReadEndian,
    R: std::io::Read + std::io::Seek,
{
    let start = usize::try_from(
        cursor
            .stream_position()
            .map_err(|e| Error::decode(section, &binrw::Error::Io(e)))?,
    )
    .map_err(|_| Error::InvalidMdlSection {
        section,
        reason: "cursor position does not fit usize",
    })?;
    let total = count
        .checked_mul(entry_size)
        .ok_or(Error::InvalidMdlSection {
            section,
            reason: "count * entry_size overflows usize",
        })?;
    let end = start.checked_add(total).ok_or(Error::InvalidMdlSection {
        section,
        reason: "start + count*entry_size overflows usize",
    })?;
    if end > bytes.len() {
        return Err(Error::InvalidMdlLength {
            found: bytes.len(),
            minimum: end,
        });
    }
    let mut out = Vec::with_capacity(count);
    for _ in 0..count {
        out.push(T::read(cursor).map_err(|e| Error::decode(section, &e))?);
    }
    Ok(out)
}

fn read_u16(cursor: &mut Cursor<&[u8]>, section: &'static str) -> Result<u16> {
    let mut buf = [0u8; 2];
    std::io::Read::read_exact(cursor, &mut buf)
        .map_err(|e| Error::decode(section, &binrw::Error::Io(e)))?;
    Ok(u16::from_le_bytes(buf))
}

fn read_u32(cursor: &mut Cursor<&[u8]>, section: &'static str) -> Result<u32> {
    let mut buf = [0u8; 4];
    std::io::Read::read_exact(cursor, &mut buf)
        .map_err(|e| Error::decode(section, &binrw::Error::Io(e)))?;
    Ok(u32::from_le_bytes(buf))
}

fn read_u8(cursor: &mut Cursor<&[u8]>, section: &'static str) -> Result<u8> {
    let mut buf = [0u8; 1];
    std::io::Read::read_exact(cursor, &mut buf)
        .map_err(|e| Error::decode(section, &binrw::Error::Io(e)))?;
    Ok(buf[0])
}

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

    fn build_minimal_mdl() -> Vec<u8> {
        let mut buf = Vec::new();

        buf.extend_from_slice(&1u32.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());
        buf.extend_from_slice(&1u16.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        for _ in 0..3 {
            buf.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            buf.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            buf.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            buf.extend_from_slice(&0u32.to_le_bytes());
        }
        buf.push(1);
        buf.push(0);
        buf.push(0);
        buf.push(0);
        assert_eq!(buf.len(), MODEL_FILE_HEADER_SIZE);

        buf.push(0);
        buf.push(0);
        buf.push(0);
        buf.push(0);
        buf.push(0);
        buf.extend_from_slice(&[0, 0, 0]);
        buf.push(0xFF);
        buf.extend_from_slice(&[0; 7]);
        for _ in 0..15 {
            buf.extend_from_slice(&[0u8; 8]);
        }

        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());

        buf.extend_from_slice(&0f32.to_le_bytes());
        for _ in 0..9 {
            buf.extend_from_slice(&0u16.to_le_bytes());
        }
        buf.push(1);
        buf.push(0);
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.push(0);
        buf.push(0);
        buf.extend_from_slice(&0f32.to_le_bytes());
        buf.extend_from_slice(&0f32.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.push(0);
        buf.push(0);
        buf.push(0);
        buf.push(0);
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&0u16.to_le_bytes());
        buf.extend_from_slice(&[0u8; 6]);

        for _ in 0..3 {
            buf.extend_from_slice(&[0u8; LOD_SIZE]);
        }

        buf.extend_from_slice(&0u32.to_le_bytes());
        buf.push(0);

        for _ in 0..4 {
            buf.extend_from_slice(&[0u8; BOUNDING_BOX_SIZE]);
        }

        buf
    }

    #[test]
    fn parses_minimal_mdl() {
        let bytes = build_minimal_mdl();
        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.file_header().version, 1);
        assert_eq!(mdl.file_header().vertex_declaration_count, 1);
        assert_eq!(mdl.file_header().lod_count, 1);
        assert_eq!(mdl.vertex_declarations().len(), 1);
        assert_eq!(mdl.vertex_declarations()[0].elements.len(), 1);
        assert_eq!(
            mdl.vertex_declarations()[0].elements[0].usage,
            crate::mdl::vertex::VertexUsage::Position
        );
        assert_eq!(mdl.meshes().len(), 0);
        assert_eq!(mdl.material_offsets().len(), 0);
        assert_eq!(mdl.bone_offsets().len(), 0);
        assert!(mdl.extra_lods().is_none());
        assert!(mdl.materials().expect("empty materials").is_empty());
    }

    #[test]
    fn rejects_short_input() {
        let bytes = vec![0u8; 32];
        let err = Mdl::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlLength {
                found: 32,
                minimum: MODEL_FILE_HEADER_SIZE
            }
        ));
    }

    #[test]
    fn rejects_oversized_vertex_decl_count() {
        let mut bytes = build_minimal_mdl();
        let raw = u16::try_from(MAX_VERTEX_DECLARATIONS + 1)
            .expect("MAX_VERTEX_DECLARATIONS + 1 fits in u16");
        bytes[0x0C..0x0E].copy_from_slice(&raw.to_le_bytes());
        let err = Mdl::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlSection {
                section: "vertex_declarations",
                ..
            }
        ));
    }

    #[test]
    fn rejects_vertex_buffer_window_past_eof() {
        let mut bytes = build_minimal_mdl();
        let off: u32 = u32::try_from(bytes.len()).expect("len fits u32") + 100;
        bytes[0x10..0x14].copy_from_slice(&off.to_le_bytes());
        bytes[0x28..0x2C].copy_from_slice(&16u32.to_le_bytes());
        let mdl = Mdl::parse(&bytes).expect("parse");
        let err = mdl.vertex_buffer(0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlSection {
                section: "vertex_buffer",
                ..
            }
        ));
    }

    #[test]
    fn rejects_index_buffer_window_past_eof() {
        let mut bytes = build_minimal_mdl();
        let off: u32 = u32::try_from(bytes.len()).expect("len fits u32") + 100;
        bytes[0x1C..0x20].copy_from_slice(&off.to_le_bytes());
        bytes[0x34..0x38].copy_from_slice(&16u32.to_le_bytes());
        let mdl = Mdl::parse(&bytes).expect("parse");
        let err = mdl.index_buffer(0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlSection {
                section: "index_buffer",
                ..
            }
        ));
    }

    #[test]
    fn vertex_and_index_buffers_zero_copy_view() {
        let mut bytes = build_minimal_mdl();
        let vertex_off = u32::try_from(bytes.len()).expect("len fits u32");
        bytes.extend_from_slice(&[0xAAu8; 16]);
        let index_off = u32::try_from(bytes.len()).expect("len fits u32");
        bytes.extend_from_slice(&[0xBB, 0x00, 0xCC, 0x00, 0xDD, 0x00, 0xEE, 0x00]);
        bytes[0x10..0x14].copy_from_slice(&vertex_off.to_le_bytes());
        bytes[0x28..0x2C].copy_from_slice(&16u32.to_le_bytes());
        bytes[0x1C..0x20].copy_from_slice(&index_off.to_le_bytes());
        bytes[0x34..0x38].copy_from_slice(&8u32.to_le_bytes());

        let mdl = Mdl::parse(&bytes).expect("parse");
        let v = mdl.vertex_buffer(0).expect("vertex buffer");
        assert_eq!(v.len(), 16);
        assert!(v.iter().all(|&b| b == 0xAA));
        let i = mdl.index_buffer(0).expect("index buffer");
        assert_eq!(i.len(), 8);
        let idx = mdl.indices(0).expect("indices");
        assert_eq!(idx, vec![0xBB, 0xCC, 0xDD, 0xEE]);
    }

    #[test]
    fn lod_out_of_range_errors() {
        let bytes = build_minimal_mdl();
        let mdl = Mdl::parse(&bytes).expect("parse");
        let err = mdl.vertex_buffer(7).unwrap_err();
        assert!(matches!(err, Error::MdlLodOutOfRange { lod: 7, .. }));
        let err = mdl.index_buffer(3).unwrap_err();
        assert!(matches!(err, Error::MdlLodOutOfRange { lod: 3, .. }));
        let err = mdl.indices(99).unwrap_err();
        assert!(matches!(err, Error::MdlLodOutOfRange { lod: 99, .. }));
    }

    #[test]
    fn rejects_missing_sentinel_in_vertex_decl() {
        let mut bytes = build_minimal_mdl();
        let sentinel_pos = MODEL_FILE_HEADER_SIZE + 8;
        bytes[sentinel_pos] = 0;
        for i in 2..VERTEX_ELEMENT_SLOTS {
            let row_pos = MODEL_FILE_HEADER_SIZE + i * 8;
            bytes[row_pos] = 0;
        }
        let err = Mdl::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlSection {
                section: "vertex_declarations",
                ..
            }
        ));
    }

    #[test]
    fn rejects_odd_submesh_bone_map_size() {
        let mut bytes = build_minimal_mdl();
        let pos = MODEL_FILE_HEADER_SIZE
            + VERTEX_DECLARATION_SLOT_BYTES
            + 8
            + MODEL_HEADER_SIZE
            + 3 * LOD_SIZE;
        bytes[pos..pos + 4].copy_from_slice(&3u32.to_le_bytes());
        let err = Mdl::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidMdlSection {
                section: "submesh_bone_map",
                ..
            }
        ));
    }

    fn build_mdl_with_one_each() -> Vec<u8> {
        let table: &[u8] = b"mat\0bone\0attr\0shape\0";
        let string_size = u32::try_from(table.len()).expect("table.len() fits u32");

        let mut bytes = Vec::new();

        bytes.extend_from_slice(&1u32.to_le_bytes());
        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        bytes.push(1);
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        assert_eq!(bytes.len(), MODEL_FILE_HEADER_SIZE);

        bytes.push(0xFF);
        bytes.extend_from_slice(&[0; 7]);
        for _ in 1..VERTEX_ELEMENT_SLOTS {
            bytes.extend_from_slice(&[0u8; 8]);
        }

        bytes.extend_from_slice(&4u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&string_size.to_le_bytes());
        bytes.extend_from_slice(table);

        let hdr_start = bytes.len();
        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(1);
        bytes.push(0);
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(0);
        bytes.push(0);
        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 6]);
        assert_eq!(
            bytes.len() - hdr_start,
            MODEL_HEADER_SIZE,
            "ModelHeader must be 56 bytes"
        );

        for _ in 0..3 {
            bytes.extend_from_slice(&[0u8; LOD_SIZE]);
        }

        bytes.extend_from_slice(&9u32.to_le_bytes());

        bytes.extend_from_slice(&0u32.to_le_bytes());

        bytes.extend_from_slice(&4u32.to_le_bytes());

        let shape_string_offset: u32 = 14;
        bytes.extend_from_slice(&shape_string_offset.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());

        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.push(0);

        for _ in 0..=4 {
            bytes.extend_from_slice(&[0u8; BOUNDING_BOX_SIZE]);
        }

        bytes
    }

    #[test]
    fn material_out_of_range_returns_mdl_material_error() {
        let bytes = build_mdl_with_one_each();
        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.material_offsets().len(), 1);
        mdl.material_name(0).expect("index 0 in range");
        let err = mdl.material_name(999).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::Error::MdlMaterialOutOfRange {
                    index: 999,
                    material_count: 1
                }
            ),
            "unexpected error: {err:?}"
        );
    }

    #[test]
    fn bone_out_of_range_returns_mdl_bone_error() {
        let bytes = build_mdl_with_one_each();
        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.bone_offsets().len(), 1);
        mdl.bone_name(0).expect("index 0 in range");
        let err = mdl.bone_name(999).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::Error::MdlBoneOutOfRange {
                    index: 999,
                    bone_count: 1
                }
            ),
            "unexpected error: {err:?}"
        );
    }

    #[test]
    fn attribute_out_of_range_returns_mdl_attribute_error() {
        let bytes = build_mdl_with_one_each();
        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.attribute_offsets().len(), 1);
        mdl.attribute_name(0).expect("index 0 in range");
        let err = mdl.attribute_name(999).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::Error::MdlAttributeOutOfRange {
                    index: 999,
                    attribute_count: 1
                }
            ),
            "unexpected error: {err:?}"
        );
    }

    #[test]
    fn shape_out_of_range_returns_mdl_shape_error() {
        let bytes = build_mdl_with_one_each();
        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.shapes().len(), 1);
        mdl.shape_name(0).expect("index 0 in range");
        let err = mdl.shape_name(999).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::Error::MdlShapeOutOfRange {
                    index: 999,
                    shape_count: 1
                }
            ),
            "unexpected error: {err:?}"
        );
    }

    #[test]
    fn invalid_utf8_in_mdl_string_returns_correct_error() {
        let table: &[u8] = &[0xFF, 0x00];
        let string_size = u32::try_from(table.len()).expect("fits u32");

        let mut bytes = build_minimal_mdl();
        let string_size_pos = MODEL_FILE_HEADER_SIZE + VERTEX_DECLARATION_SLOT_BYTES + 4;
        bytes[string_size_pos..string_size_pos + 4].copy_from_slice(&string_size.to_le_bytes());
        let insert_at = string_size_pos + 4;
        let mut new_bytes = Vec::with_capacity(bytes.len() + table.len());
        new_bytes.extend_from_slice(&bytes[..insert_at]);
        new_bytes.extend_from_slice(table);
        new_bytes.extend_from_slice(&bytes[insert_at..]);

        let mdl = Mdl::parse(&new_bytes).expect("parse");
        let err = mdl.string_at(0).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::Error::InvalidUtf8InMdlString { offset: 0 }
            ),
            "unexpected error: {err:?}"
        );
    }

    #[test]
    fn legacy_bone_table_reads_correct_indices_from_cursor() {
        use crate::mdl::bone::BoneTableVersion;

        let table: &[u8] = b"boneA\0boneB\0boneC\0";
        let string_size = u32::try_from(table.len()).expect("fits u32");

        let mut bytes = Vec::new();

        bytes.extend_from_slice(&1u32.to_le_bytes());
        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        for _ in 0..3 {
            bytes.extend_from_slice(&0u32.to_le_bytes());
        }
        bytes.push(1);
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        assert_eq!(bytes.len(), MODEL_FILE_HEADER_SIZE);

        bytes.push(0xFF);
        bytes.extend_from_slice(&[0; 7]);
        for _ in 1..VERTEX_ELEMENT_SLOTS {
            bytes.extend_from_slice(&[0u8; 8]);
        }

        bytes.extend_from_slice(&3u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&string_size.to_le_bytes());
        bytes.extend_from_slice(table);

        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&3u16.to_le_bytes());
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(1);
        bytes.push(0);
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(0);
        bytes.push(0);
        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0f32.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        bytes.push(0);
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&0u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 6]);

        for _ in 0..3 {
            bytes.extend_from_slice(&[0u8; LOD_SIZE]);
        }

        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.extend_from_slice(&6u32.to_le_bytes());
        bytes.extend_from_slice(&12u32.to_le_bytes());

        let mut slot = [0u8; 132];
        slot[0..2].copy_from_slice(&0u16.to_le_bytes());
        slot[2..4].copy_from_slice(&2u16.to_le_bytes());
        slot[4..6].copy_from_slice(&1u16.to_le_bytes());
        slot[128] = 3;
        bytes.extend_from_slice(&slot);

        bytes.extend_from_slice(&0u32.to_le_bytes());
        bytes.push(0);

        for _ in 0..(4 + 3) {
            bytes.extend_from_slice(&[0u8; BOUNDING_BOX_SIZE]);
        }

        let mdl = Mdl::parse(&bytes).expect("parse");
        assert_eq!(mdl.bone_tables().len(), 1);
        let bt = &mdl.bone_tables()[0];
        assert_eq!(
            bt.version_tag(),
            BoneTableVersion::Legacy,
            "version <= 0x0100_0005 must use Legacy path"
        );
        assert_eq!(bt.indices(), &[0, 2, 1], "first 3 indices in slot order");
        assert_eq!(bt.indices().len(), 3, "bone_count = 3 indices retained");
    }

    fn lcg_next(state: &mut u64) -> u8 {
        *state = state
            .wrapping_mul(6_364_136_223_846_793_005)
            .wrapping_add(1_442_695_040_888_963_407);
        ((*state >> 33) & 0xFF) as u8
    }

    #[test]
    fn mdl_parse_never_panics_on_random_buffers_up_to_2kib() {
        let mut state: u64 = 0xFEED_FACE_DEAD_BEEF;
        for _ in 0..1000 {
            let len = usize::from(lcg_next(&mut state)) * 8;
            let buf: Vec<u8> = (0..len).map(|_| lcg_next(&mut state)).collect();
            let _ = Mdl::parse(&buf);
        }
    }

    #[test]
    fn string_at_returns_resolved_strings() {
        let mut bytes = build_minimal_mdl();
        let string_size_pos = MODEL_FILE_HEADER_SIZE + VERTEX_DECLARATION_SLOT_BYTES + 4;
        let table: &[u8] = b"mat0\0bone0\0";
        let string_size = u32::try_from(table.len()).expect("table.len() fits u32");
        bytes[string_size_pos..string_size_pos + 4].copy_from_slice(&string_size.to_le_bytes());
        let insert_at = string_size_pos + 4;
        let mut new_bytes = Vec::with_capacity(bytes.len() + table.len());
        new_bytes.extend_from_slice(&bytes[..insert_at]);
        new_bytes.extend_from_slice(table);
        new_bytes.extend_from_slice(&bytes[insert_at..]);

        let mdl = Mdl::parse(&new_bytes).expect("parse");
        assert_eq!(mdl.string_at(0).expect("mat0"), "mat0");
        assert_eq!(mdl.string_at(5).expect("bone0"), "bone0");
        let past = u32::try_from(table.len()).expect("table.len() fits u32") + 10;
        let err = mdl.string_at(past).unwrap_err();
        assert!(matches!(err, Error::InvalidMdlStringOffset { .. }));
    }
}
