//! Renderer-agnostic mesh contract and multi-format ingestion.
//!
//! Defines [`ModelMesh`] (in [`contract`]), a thin Polymodel wrapper over
//! [`three_d_asset::TriMesh`] that carries format provenance, unit assumptions, and a
//! camera framing hint. STL, OBJ, and glTF (GLB, embedded-base64, or multi-file `.gltf`
//! with external companions) are parsed client-side from in-memory bytes via
//! `three-d-asset`, so this module compiles to `wasm32-unknown-unknown`. 3MF cannot
//! target `wasm32` and is routed to a remote parser (PM-49); the parser seam in
//! [`parser`] is remote-ready. See the [PM-14 research][1] and the [PM-23 plan][2].
//!
//! [1]: https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131340
//! [2]: https://radiant-industries.atlassian.net/browse/PM-23

// This module is a renderer-agnostic ingestion foundation. Its public surface is
// consumed by the viewer and renderer; some items are exercised only through
// format-specific parsers and not yet on the binary's main path, so dead-code analysis
// is relaxed here.
#![allow(dead_code)]

pub mod companions;
pub mod contract;
pub mod gltf;
pub mod obj;
pub mod parser;
pub mod stl;

use three_d_asset::{Geometry, Indices, Mat4, Positions, TriMesh, Vec3};

use crate::mesh::contract::MeshLoadError;

/// Validate and finalize a parsed [`TriMesh`] shared by every loader.
///
/// Runs the geometry checks lifted from the STL path so STL, OBJ, and glTF all enforce
/// the same invariants, then derives geometry normals and normalizes positions to F32:
///
/// * no triangles, an out-of-range index, a non-finite position, or a point-sized
///   bounding box → [`MeshLoadError::Degenerate`];
/// * normals are geometry-derived (source facet/vertex normals are not relied upon);
/// * a vertex touched only by zero-area triangles gets a NaN normal, which is scrubbed to zero;
/// * OBJ's `Positions::F64` is downcast to `Positions::F32` (the renderer invariant).
///
/// Call this **after** any per-format transform baking, so the AABB and normals reflect
/// world-space positions.
pub(crate) fn validate(trimesh: &mut TriMesh) -> Result<(), MeshLoadError> {
    if trimesh.triangle_count() == 0 {
        return Err(MeshLoadError::Degenerate);
    }
    // Reject non-finite positions (F32 or F64) up front so AABB / normal math can't be
    // poisoned.
    if !positions_all_finite(&trimesh.positions) {
        return Err(MeshLoadError::Degenerate);
    }
    // Reject out-of-range indices before compute_normals, which indexes the position
    // buffer directly and would panic on malformed geometry.
    let position_count = trimesh.positions.len() as u32;
    if trimesh
        .indices
        .to_u32()
        .is_some_and(|indices| indices.iter().any(|&i| i >= position_count))
    {
        return Err(MeshLoadError::Degenerate);
    }
    // Reject a point-sized bounding box (all three extents ~0). A flat plate or line —
    // zero extent on one or two axes — is valid geometry and must parse.
    let size = trimesh.compute_aabb().size();
    if size.x.abs() < f32::EPSILON && size.y.abs() < f32::EPSILON && size.z.abs() < f32::EPSILON {
        return Err(MeshLoadError::Degenerate);
    }
    // Geometry-derived normals; source normals are not relied upon.
    trimesh.compute_normals();
    // `compute_normals` normalizes each per-vertex face-normal sum. A vertex touched
    // only by zero-area (collinear) triangles has a zero sum and normalizes to NaN. Such
    // geometry is allowed to parse, so replace any non-finite normal with zero — the
    // normal is genuinely undefined there, but the mesh must never carry NaN downstream.
    if let Some(normals) = trimesh.normals.as_mut() {
        for n in normals {
            if !n.x.is_finite() || !n.y.is_finite() || !n.z.is_finite() {
                *n = Vec3::new(0.0, 0.0, 0.0);
            }
        }
    }
    // Normalize OBJ F64 positions to F32 (STL/glTF are already F32).
    if let Positions::F64(f64s) = &trimesh.positions {
        trimesh.positions = Positions::F32(
            f64s.iter()
                .map(|v| Vec3::new(v.x as f32, v.y as f32, v.z as f32))
                .collect(),
        );
    }
    Ok(())
}

fn positions_all_finite(positions: &Positions) -> bool {
    match positions {
        Positions::F32(vs) => vs
            .iter()
            .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()),
        Positions::F64(vs) => vs
            .iter()
            .all(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite()),
    }
}

/// Flatten a parsed `three-d-asset` [`Model`](three_d_asset::Model) into a single
/// [`TriMesh`], shared by the OBJ and glTF loaders.
///
/// `three-d-asset`'s `Scene → Model` conversion accumulates each glTF node chain's world
/// transform onto [`Primitive::transformation`](three_d_asset::Primitive) while leaving
/// positions in local space, so each geometry is baked here (local → world) as it is
/// folded into one mesh with per-primitive index offsets. Non-triangle geometry (e.g.
/// point clouds) is dropped — Polymodel renders triangle meshes only. All positions are
/// unified to F32 (OBJ arrives as F64); [`validate`] recomputes normals afterwards.
pub(crate) fn flatten_model(model: three_d_asset::Model) -> TriMesh {
    let mut positions: Vec<Vec3> = Vec::new();
    let mut indices: Vec<u32> = Vec::new();
    for primitive in model.geometries {
        let Geometry::Triangles(mesh) = primitive.geometry else {
            continue;
        };
        let base = positions.len() as u32;
        for p in mesh.positions.to_f32() {
            positions.push(transform_point(primitive.transformation, p));
        }
        match mesh.indices {
            Indices::U32(values) => indices.extend(values.into_iter().map(|i| i + base)),
            Indices::U16(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)),
            Indices::U8(values) => indices.extend(values.into_iter().map(|i| i as u32 + base)),
            // Non-indexed geometry: synthesize a sequential index per vertex.
            Indices::None => indices.extend((0..mesh.positions.len() as u32).map(|i| i + base)),
        }
    }
    TriMesh {
        positions: Positions::F32(positions),
        indices: Indices::U32(indices),
        normals: None,
        tangents: None,
        uvs: None,
        colors: None,
    }
}

/// Apply an affine node transform (`Mat4`, cgmath column-major) to a point.
///
/// glTF node transforms are affine, so the homogeneous row is `(0, 0, 0, 1)` and the
/// translation lives in the fourth column (`m.w`); there is no perspective divide.
fn transform_point(m: Mat4, p: Vec3) -> Vec3 {
    Vec3::new(
        p.x * m.x.x + p.y * m.y.x + p.z * m.z.x + m.w.x,
        p.x * m.x.y + p.y * m.y.y + p.z * m.z.y + m.w.y,
        p.x * m.x.z + p.y * m.y.z + p.z * m.z.z + m.w.z,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use three_d_asset::{Indices, SquareMatrix};

    /// Build a `TriMesh` from explicit F32 positions and a flat index list.
    fn trimesh(positions: &[[f32; 3]], indices: &[u32]) -> TriMesh {
        TriMesh {
            positions: Positions::F32(
                positions
                    .iter()
                    .map(|p| Vec3::new(p[0], p[1], p[2]))
                    .collect(),
            ),
            indices: Indices::U32(indices.to_vec()),
            normals: None,
            tangents: None,
            uvs: None,
            colors: None,
        }
    }

    #[test]
    fn validate_rejects_zero_triangles() {
        let mut m = trimesh(&[[0.0, 0.0, 0.0]], &[]);
        assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn validate_rejects_non_finite_position() {
        let mut m = trimesh(
            &[[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
            &[0, 1, 2],
        );
        assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn validate_rejects_out_of_range_indices() {
        // Index 99 is beyond the 3 positions; compute_normals would panic, so validate
        // must surface a clean load error instead.
        let mut m = trimesh(
            &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
            &[0, 1, 99],
        );
        assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn validate_rejects_point_sized_aabb() {
        let mut m = trimesh(&[[5.0, 5.0, 5.0]; 3], &[0, 1, 2]);
        assert!(matches!(validate(&mut m), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn validate_derives_normals_and_normalizes_f64() {
        let mut m = TriMesh {
            positions: Positions::F64(
                [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
                    .iter()
                    .map(|p| three_d_asset::Vector3::new(p[0], p[1], p[2]))
                    .collect(),
            ),
            indices: Indices::U32(vec![0, 1, 2]),
            normals: None,
            tangents: None,
            uvs: None,
            colors: None,
        };
        validate(&mut m).expect("a valid triangle parses");
        assert!(matches!(m.positions, Positions::F32(_)), "F64 -> F32");
        let normals = m.normals.expect("normals populated");
        assert_eq!(normals.len(), 3);
        assert!(
            normals
                .iter()
                .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite())
        );
    }

    #[test]
    fn flatten_bakes_translation_into_positions() {
        // Two identity-transform triangles -> 6 positions, 2 triangles, no offset drift.
        let model = three_d_asset::Model {
            name: "test".into(),
            materials: Vec::new(),
            geometries: vec![
                three_d_asset::Primitive {
                    name: "a".into(),
                    transformation: Mat4::identity(),
                    animations: Vec::new(),
                    geometry: Geometry::Triangles(trimesh(
                        &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
                        &[0, 1, 2],
                    )),
                    material_index: None,
                },
                three_d_asset::Primitive {
                    name: "b".into(),
                    transformation: Mat4::identity(),
                    animations: Vec::new(),
                    geometry: Geometry::Triangles(trimesh(
                        &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
                        &[0, 1, 2],
                    )),
                    material_index: None,
                },
            ],
        };
        let flat = flatten_model(model);
        assert_eq!(flat.positions.len(), 6);
        assert_eq!(flat.indices.to_u32().unwrap(), vec![0, 1, 2, 3, 4, 5]);
    }
}
