//! STL ingestion: parse in-memory bytes into a [`ModelMesh`] via `stl_io`.
//!
//! Both binary and ASCII STL are handled by `stl_io::read_stl`, which probes the
//! 80-byte header for a leading `"solid "` to pick its reader. A binary file whose
//! header happens to start with `"solid "` is mis-routed to the ASCII reader and fails;
//! on a parse error we neutralise the header in an owned copy and retry exactly once, so
//! `stl_io` re-probes and picks its binary reader. We deliberately do **not** try to
//! classify binary-vs-ASCII ourselves (size-based detection is an unreliable heuristic,
//! and `stl_io` exposes no public binary-only entry point).
//!
//! Normals are geometry-derived ([`three_d_asset::TriMesh::compute_normals`]); STL facet
//! normals are not relied upon. No filesystem or network access, so this is safe to call
//! from a `wasm32-unknown-unknown` build.

use std::io::Cursor;

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

use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units};

/// Parse STL bytes (binary or ASCII) into a [`ModelMesh`].
pub fn load_stl(bytes: &[u8]) -> Result<ModelMesh, MeshLoadError> {
    if bytes.is_empty() {
        return Err(MeshLoadError::Empty);
    }

    let mesh = stl_io::read_stl(&mut Cursor::new(bytes)).or_else(|_| {
        // `stl_io` mis-routes binaries whose 80-byte header starts with "solid " to its
        // ASCII reader. Neutralise the header so the probe routes to the binary reader
        // and retry exactly once.
        let mut buf = bytes.to_vec();
        if buf.len() >= 80 {
            buf[..80].fill(0);
        }
        stl_io::read_stl(&mut Cursor::new(buf))
    });

    build_model_mesh(mesh?)
}

/// Convert a parsed `stl_io` mesh into a validated [`ModelMesh`].
fn build_model_mesh(mesh: stl_io::IndexedMesh) -> Result<ModelMesh, MeshLoadError> {
    // No triangles → nothing to render.
    if mesh.faces.is_empty() {
        return Err(MeshLoadError::Degenerate);
    }
    // Reject non-finite positions up front so AABB / normal math can't be poisoned.
    if mesh
        .vertices
        .iter()
        .any(|v| !v.0.iter().copied().all(f32::is_finite))
    {
        return Err(MeshLoadError::Degenerate);
    }

    let positions = Positions::F32(
        mesh.vertices
            .iter()
            .map(|v| Vec3::new(v.0[0], v.0[1], v.0[2]))
            .collect(),
    );
    let indices = Indices::U32(
        mesh.faces
            .iter()
            .flat_map(|f| f.vertices)
            .map(|i| i as u32)
            .collect(),
    );

    let mut trimesh = TriMesh {
        positions,
        indices,
        normals: None,
        tangents: None,
        uvs: None,
        colors: None,
    };

    // 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; STL facet 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);
            }
        }
    }

    Ok(ModelMesh {
        trimesh,
        format: MeshFormat::Stl,
        units: Units::stl_assumed_mm(),
    })
}

#[cfg(test)]
mod tests {
    use super::load_stl;
    use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units};

    /// Build an STL [`stl_io::Triangle`] from three points with a zero facet normal
    /// (normals are geometry-derived, so the facet normal is irrelevant).
    fn tri(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Triangle {
        stl_io::Triangle {
            normal: stl_io::Normal::new([0.0, 0.0, 0.0]),
            vertices: [
                stl_io::Vertex::new(a),
                stl_io::Vertex::new(b),
                stl_io::Vertex::new(c),
            ],
        }
    }

    /// Encode triangles as a binary STL (all-zero 80-byte header, per the spec).
    fn binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> {
        let mut buf = Vec::new();
        stl_io::write_stl(&mut buf, triangles.iter()).expect("write_stl should encode");
        assert!(
            buf.len() >= 80,
            "binary STL should start with an 80-byte header"
        );
        buf
    }

    /// Encode triangles as a binary STL whose header starts with "solid " (the edge case
    /// that mis-routes `stl_io`'s ASCII probe).
    fn solid_header_binary_stl(triangles: &[stl_io::Triangle]) -> Vec<u8> {
        let mut buf = binary_stl(triangles);
        buf[..6].copy_from_slice(b"solid ");
        buf
    }

    fn tetrahedron_stl() -> Vec<u8> {
        let v = [
            [0.0, 0.0, 0.0],
            [1.0, 0.0, 0.0],
            [0.0, 1.0, 0.0],
            [0.0, 0.0, 1.0],
        ];
        binary_stl(&[
            tri(v[0], v[2], v[1]),
            tri(v[0], v[1], v[3]),
            tri(v[0], v[3], v[2]),
            tri(v[1], v[2], v[3]),
        ])
    }

    fn assert_approx(actual: f32, expected: f32) {
        assert!(
            (actual - expected).abs() < 1e-5,
            "expected ~{expected}, got {actual}"
        );
    }

    #[test]
    fn parses_binary_stl() {
        let model = load_stl(&tetrahedron_stl()).expect("binary STL should parse");
        assert_eq!(model.format, MeshFormat::Stl);
        assert_eq!(
            model.units,
            Units {
                source: LengthUnit::Unknown,
                assumed: Some(LengthUnit::Millimeter),
            }
        );
        assert_eq!(model.trimesh.triangle_count(), 4);
        assert!(
            model.trimesh.positions.len() >= 4,
            "tetrahedron should have at least its 4 unique vertices"
        );
        // Normals are geometry-derived and populated by compute_normals.
        let normals = model
            .trimesh
            .normals
            .as_ref()
            .expect("normals should be populated");
        assert_eq!(normals.len(), model.trimesh.positions.len());
    }

    #[test]
    fn parses_ascii_stl() {
        let ascii = b"\
solid polymodel
  facet normal 0 0 1
    outer loop
      vertex 0 0 0
      vertex 1 0 0
      vertex 0 1 0
    endloop
  endfacet
endsolid polymodel\n";
        let model = load_stl(ascii).expect("ASCII STL should parse");
        assert_eq!(model.format, MeshFormat::Stl);
        assert_eq!(model.trimesh.triangle_count(), 1);
        assert!(model.trimesh.normals.is_some());
    }

    #[test]
    fn parses_solid_header_binary_via_retry() {
        // Binary body with an "solid "-prefixed header: stl_io's ASCII probe mis-routes
        // the first attempt; the header-neutralised retry must recover it.
        let model = load_stl(&solid_header_binary_stl(&[tri(
            [0.0, 0.0, 0.0],
            [1.0, 0.0, 0.0],
            [0.0, 1.0, 0.0],
        )]))
        .expect("'solid '-header binary should parse via retry");
        assert_eq!(model.trimesh.triangle_count(), 1);
        assert!(
            model.trimesh.normals.is_some(),
            "geometry normals must be populated even on the retry path"
        );
    }

    #[test]
    fn parses_planar_mesh_is_not_degenerate() {
        // A flat triangle (zero extent on Y) is valid geometry, not degenerate.
        let model = load_stl(&binary_stl(&[tri(
            [10.0, 0.0, 0.0],
            [14.0, 0.0, 0.0],
            [10.0, 0.0, 4.0],
        )]))
        .expect("planar mesh should parse");
        assert_eq!(model.trimesh.triangle_count(), 1);
    }

    #[test]
    fn camera_fit_off_center() {
        // Off-centre triangle: AABB x[10,14] z[0,4], y={0} → centre (12,0,2), radius 2.
        let model = load_stl(&binary_stl(&[tri(
            [10.0, 0.0, 0.0],
            [14.0, 0.0, 0.0],
            [10.0, 0.0, 4.0],
        )]))
        .expect("off-centre mesh should parse");
        let fit = model.camera_fit();
        assert_approx(fit.center[0], 12.0);
        assert_approx(fit.center[1], 0.0);
        assert_approx(fit.center[2], 2.0);
        assert_approx(fit.radius, 2.0);
    }

    #[test]
    fn collinear_mesh_yields_finite_normals() {
        // A collinear (1D) triangle is zero-area: geometry normals are undefined and
        // would normalize to NaN. The mesh still parses (not degenerate), and its
        // normals must be finite.
        let model = load_stl(&binary_stl(&[tri(
            [0.0, 0.0, 0.0],
            [1.0, 0.0, 0.0],
            [2.0, 0.0, 0.0],
        )]))
        .expect("collinear mesh should parse (not degenerate)");
        let normals = model
            .trimesh
            .normals
            .as_ref()
            .expect("normals should be populated");
        assert!(
            normals
                .iter()
                .all(|n| { n.x.is_finite() && n.y.is_finite() && n.z.is_finite() }),
            "normals must be finite even for zero-area geometry"
        );
    }

    #[test]
    fn error_empty() {
        assert!(matches!(load_stl(&[]), Err(MeshLoadError::Empty)));
    }

    #[test]
    fn error_parse_garbage() {
        // Pure garbage fails both attempts (ASCII probe and binary reader).
        let garbage = vec![b'G'; 200];
        assert!(matches!(load_stl(&garbage), Err(MeshLoadError::Parse(_))));
    }

    #[test]
    fn error_parse_truncated() {
        // A valid binary STL truncated mid-body fails both attempts.
        let truncated = &tetrahedron_stl()[..90];
        assert!(matches!(load_stl(truncated), Err(MeshLoadError::Parse(_))));
    }

    #[test]
    fn error_zero_triangles() {
        // 80-byte header + a LE triangle count of zero is a valid but empty STL.
        let mut empty = vec![0u8; 84];
        empty[80..84].copy_from_slice(&0u32.to_le_bytes());
        assert!(matches!(load_stl(&empty), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn error_point_sized_aabb() {
        // A triangle collapsed to a single point has a point-sized AABB.
        let point = binary_stl(&[tri([5.0, 5.0, 5.0], [5.0, 5.0, 5.0], [5.0, 5.0, 5.0])]);
        assert!(matches!(load_stl(&point), Err(MeshLoadError::Degenerate)));
    }

    #[test]
    fn error_non_finite_position() {
        // An infinite coordinate would poison AABB / normal math; it is rejected.
        let inf = binary_stl(&[tri(
            [f32::INFINITY, 0.0, 0.0],
            [1.0, 0.0, 0.0],
            [0.0, 1.0, 0.0],
        )]);
        assert!(matches!(load_stl(&inf), Err(MeshLoadError::Degenerate)));
    }

    /// End-to-end check against the committed real-world fixture.
    #[test]
    fn local_body_chest_fixture_parses() {
        let path = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/public/models/body_f_chest-v4.stl"
        );
        let bytes = std::fs::read(path)
            .expect("local fixture public/models/body_f_chest-v4.stl should be committed");
        let model = load_stl(&bytes).expect("local STL fixture should parse");
        assert_eq!(model.format, MeshFormat::Stl);
        assert_eq!(
            model.units,
            Units {
                source: LengthUnit::Unknown,
                assumed: Some(LengthUnit::Millimeter),
            }
        );
        assert!(model.trimesh.normals.is_some());
        assert!(model.trimesh.triangle_count() > 0);
    }
}
