//! Mesh contract types: the renderer-agnostic [`ModelMesh`] and its provenance.

// `AxisAlignedBoundingBox` appears in the public `ModelMesh::aabb` signature, and
// `TriMesh` is a public field type, so both are re-exported alongside the contract.
pub use three_d_asset::{AxisAlignedBoundingBox, TriMesh};

/// Source format of a loaded mesh.
///
/// STL, OBJ, and glTF (GLB, embedded, or multi-file `.gltf` with external buffers) are
/// parsed client-side from in-memory bytes. `Threemf` is **reserved**: 3MF cannot
/// compile to `wasm32`, so it is served by a remote (server-side) parser that returns
/// GLB — see the `RemoteMeshParser` drop-in contract (PM-49). There is intentionally no
/// binary/ASCII sub-label for STL — reliably telling them apart is the hard part, and
/// both yield the same mesh.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshFormat {
    /// Stereolithography (binary or ASCII).
    Stl,
    /// Wavefront OBJ (convex polygons fan-triangulated; lines/points dropped).
    Obj,
    /// glTF 2.0 — GLB, embedded-base64 `.gltf`, or multi-file `.gltf` + companions.
    Gltf,
    /// 3D Manufacturing Format. Not parsed client-side; routed to a remote parser
    /// (PM-49). Kept here so format detection and the parser seam are remote-ready.
    Threemf,
}

impl MeshFormat {
    /// Detect a mesh format from a filename extension.
    ///
    /// The extension is taken from the final `.`-delimited segment, lower-cased, and
    /// matched with no leading dot. Returns `None` for empty or unknown extensions,
    /// which the viewer surfaces as [`crate::viewer::ViewerIssue::MeshUnavailable`].
    pub fn from_extension(path: impl AsRef<str>) -> Option<Self> {
        let path = path.as_ref();
        let ext = path.rsplit('.').next()?.to_ascii_lowercase();
        // `rsplit('.').next()` yields the whole string when there is no dot; only a
        // genuine extension suffix (shorter than the input) is accepted.
        if ext.len() >= path.len() {
            return None;
        }
        match ext.as_str() {
            "stl" => Some(Self::Stl),
            "obj" => Some(Self::Obj),
            "gltf" | "glb" => Some(Self::Gltf),
            "3mf" => Some(Self::Threemf),
            _ => None,
        }
    }
}

/// A length unit.
///
/// STL is unitless, so a loaded STL reports [`LengthUnit::Unknown`] as its source and
/// relies on an assumed convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LengthUnit {
    Millimeter,
    Centimeter,
    Meter,
    Inch,
    /// A thousandth of an inch — "thou" in machining, "mil" in PCB/electronics.
    Thou,
    Foot,
    Unknown,
}

/// Unit handling for a loaded model.
///
/// `source` is the unit declared by the file; STL/OBJ declare none, so they report
/// [`LengthUnit::Unknown`] and rely on [`Units::unitless_assumed_mm`]. glTF's base unit
/// is the metre, so a loaded glTF reports [`LengthUnit::Meter`] with no assumption.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Units {
    /// The unit declared by the file (STL/OBJ: [`LengthUnit::Unknown`]; glTF: [`LengthUnit::Meter`]).
    pub source: LengthUnit,
    /// The convention assumed when `source` is unknown.
    pub assumed: Option<LengthUnit>,
}

impl Units {
    /// Unit handling for a unitless format (STL, OBJ): source unknown, assume millimetres.
    pub fn unitless_assumed_mm() -> Self {
        Self {
            source: LengthUnit::Unknown,
            assumed: Some(LengthUnit::Millimeter),
        }
    }

    /// Unit handling for a format that declares its own unit (e.g. glTF → metres),
    /// so no assumption is applied.
    pub fn declared(source: LengthUnit) -> Self {
        Self {
            source,
            assumed: None,
        }
    }
}

/// Camera framing hint derived from a mesh's axis-aligned bounding box: the centre the
/// camera should orbit and a bounding-radius estimate to frame it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraFit {
    /// AABB centre.
    pub center: [f32; 3],
    /// Half the largest AABB extent.
    pub radius: f32,
}

/// A Polymodel mesh: a [`TriMesh`] plus format provenance and unit handling.
///
/// Normals are geometry-derived (via [`TriMesh::compute_normals`]); source facet
/// normals are not relied upon. Bounds and camera framing come from [`Self::aabb`].
#[derive(Debug)]
pub struct ModelMesh {
    /// The underlying triangle mesh.
    pub trimesh: TriMesh,
    /// The format this mesh was loaded from.
    pub format: MeshFormat,
    /// Unit provenance and assumption.
    pub units: Units,
}

impl ModelMesh {
    /// The axis-aligned bounding box over all vertex positions.
    pub fn aabb(&self) -> AxisAlignedBoundingBox {
        self.trimesh.compute_aabb()
    }

    /// Camera framing derived from [`Self::aabb`]: `center` is the box centre and
    /// `radius` is half the largest box extent.
    pub fn camera_fit(&self) -> CameraFit {
        let aabb = self.aabb();
        let center = aabb.center();
        let size = aabb.size();
        CameraFit {
            center: [center.x, center.y, center.z],
            radius: size.x.max(size.y).max(size.z) / 2.0,
        }
    }
}

/// Errors that can occur loading a mesh.
#[derive(Debug, thiserror::Error)]
pub enum MeshLoadError {
    /// The input was empty (zero bytes).
    #[error("mesh input is empty")]
    Empty,
    /// The bytes could not be parsed as a valid mesh of the expected format.
    #[error("failed to parse STL: {0}")]
    Parse(#[from] std::io::Error),
    /// A format parsed by `three-d-asset` (OBJ/glTF) failed to decode, including a
    /// missing or undecodable companion resource (buffer/texture).
    #[error("mesh decode failed: {0}")]
    Decode(String),
    /// The mesh parsed but is degenerate: no triangles, an out-of-range index, a
    /// non-finite position, or a point-sized bounding box.
    #[error("mesh is degenerate")]
    Degenerate,
    /// The format is not supported by the active parser. `Threemf` returns this when no
    /// remote parser is wired in (PM-49 will supply one).
    #[error("unsupported mesh format")]
    UnsupportedFormat,
}

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

    #[test]
    fn from_extension_maps_known_formats_case_insensitively() {
        assert_eq!(
            MeshFormat::from_extension("cube.stl"),
            Some(MeshFormat::Stl)
        );
        assert_eq!(
            MeshFormat::from_extension("cube.obj"),
            Some(MeshFormat::Obj)
        );
        assert_eq!(
            MeshFormat::from_extension("cube.gltf"),
            Some(MeshFormat::Gltf)
        );
        assert_eq!(
            MeshFormat::from_extension("cube.glb"),
            Some(MeshFormat::Gltf)
        );
        assert_eq!(
            MeshFormat::from_extension("cube.3mf"),
            Some(MeshFormat::Threemf)
        );
        // Mixed case and leading dots.
        assert_eq!(
            MeshFormat::from_extension("CUBE.GLB"),
            Some(MeshFormat::Gltf)
        );
        assert_eq!(MeshFormat::from_extension(".OBJ"), Some(MeshFormat::Obj));
        assert_eq!(
            MeshFormat::from_extension("/models/Body_F_Chest-v4.STL"),
            Some(MeshFormat::Stl)
        );
    }

    #[test]
    fn from_extension_handles_dot_and_extension_edge_cases() {
        // Trailing/leading dot and dot-only strings have no real extension.
        assert_eq!(MeshFormat::from_extension("cube."), None);
        assert_eq!(MeshFormat::from_extension("."), None);
        // Only the final segment is the extension.
        assert_eq!(MeshFormat::from_extension("archive.tar.gz"), None);
        assert_eq!(
            MeshFormat::from_extension("model.backup.GLB"),
            Some(MeshFormat::Gltf)
        );
    }

    #[test]
    fn from_extension_rejects_unknown_and_extensionless() {
        assert_eq!(MeshFormat::from_extension("cube.ply"), None);
        assert_eq!(MeshFormat::from_extension("cube"), None);
        assert_eq!(MeshFormat::from_extension(""), None);
    }
}
