//! Leaf protocol crate for the Polymodel renderer worker split.
//!
//! Owns the types shared between the Dioxus main thread and the renderer Web Worker:
//! [`MeshFormat`] / [`LengthUnit`] / [`Units`] (relocated from the mesh contract so the
//! app never depends on `three-d-asset`), the postcard message envelope
//! ([`RendererCommand`] / [`RendererEvent`]), and [`MeshStats`].
//!
//! The app depends on this crate (and only this crate); the worker and mesh crates depend
//! on it too. Heavy deps (`three-d-asset`, `stl_io`, `tobj`, `gltf`) live in the mesh and
//! worker crates, never here.

use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Shared mesh-format types (relocated from polymodel-mesh::contract)
// ---------------------------------------------------------------------------

/// Source format of a loaded mesh.
///
/// STL, OBJ, and glTF (GLB, embedded, or multi-file `.gltf` with external buffers) are
/// parsed in the worker. `Threemf` is reserved: 3MF cannot compile to `wasm32`, so it is
/// served by a remote parser (PM-49). There is no binary/ASCII sub-label for STL —
/// reliably telling them apart is hard, and both yield the same mesh.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
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.
    pub fn from_extension(path: impl AsRef<str>) -> Option<Self> {
        let path = path.as_ref();
        let ext = path.rsplit('.').next()?.to_ascii_lowercase();
        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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
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, Serialize, Deserialize)]
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,
        }
    }
}

// ---------------------------------------------------------------------------
// Mesh stats (the only mesh data crossing back from the worker)
// ---------------------------------------------------------------------------

/// Lightweight mesh statistics returned to the main thread after a successful load.
///
/// The full [`TriMesh`](three_d_asset::TriMesh) stays in the worker; only counts cross
/// back for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MeshStats {
    /// Number of unique vertices.
    pub vertices: u32,
    /// Number of triangles.
    pub triangles: u32,
}

// ---------------------------------------------------------------------------
// Renderer command (main → worker)
// ---------------------------------------------------------------------------

/// A command sent from the main thread to the renderer worker.
///
/// Serialized with postcard and posted as a `Uint8Array`. The worker deserializes and
/// dispatches. Input commands (`SetPointerState`, `PointerDown`, `PointerUp`) only update
/// worker-side accumulators — the worker's `requestAnimationFrame` loop consumes them
/// per-frame, never rendering from a single message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RendererCommand {
    /// Load a mesh from a URL. The worker discovers and fetches glTF companions itself.
    /// `format` + `primary_url` are both known from the route / `DemoModel` without any
    /// mesh parsing on the main thread.
    LoadMesh {
        format: MeshFormat,
        primary_url: String,
    },
    /// Render a mesh URL to a PNG preview image. This is intentionally handled by
    /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`.
    RenderPreviewImage {
        request_id: u32,
        format: MeshFormat,
        primary_url: String,
        width: u32,
        height: u32,
    },
    /// Resize the worker's `OffscreenCanvas` and viewport. `css_w`/`css_h` are CSS pixels,
    /// `dpr` is `window.devicePixelRatio`. The worker sets canvas pixel dimensions to
    /// `css_w * dpr` × `css_h * dpr`.
    Resize { css_w: f32, css_h: f32, dpr: f32 },
    /// Coalesced latest pointer state (producer-side throttled to display rate).
    /// `x`/`y` are canvas-relative CSS pixels; `buttons` is a bitmask matching
    /// `PointerEvent.buttons`; `wheel_delta` is the accumulated deltaY since the last post.
    SetPointerState {
        x: f32,
        y: f32,
        buttons: u16,
        modifiers: KeyModifiers,
        wheel_delta: f32,
    },
    /// Discrete pointer-down event (infrequent; posted as events).
    PointerDown { x: f32, y: f32 },
    /// Discrete pointer-up event (infrequent; posted as events).
    PointerUp { x: f32, y: f32 },
    /// Tear down the renderer and stop the frame loop. The worker closes itself.
    Dispose,
    /// Deliberately panic — used by e2e tests to verify crash surfacing.
    DebugCrash,
}

/// Keyboard modifier flags accompanying a pointer state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct KeyModifiers {
    pub shift: bool,
    pub ctrl: bool,
    pub alt: bool,
    pub meta: bool,
}

// ---------------------------------------------------------------------------
// Renderer event (worker → main)
// ---------------------------------------------------------------------------

/// An event sent from the renderer worker to the main thread.
///
/// Serialized with postcard and posted as a `Uint8Array`. The main thread deserializes and
/// maps to [`crate::viewer::ViewerStatus`] / `context_acquisitions`.
///
/// The `"__worker_ready"` string handshake is posted as a bare JS string (not postcard)
/// before the first `RendererEvent` — see the worker `main.rs`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RendererEvent {
    /// The WebGL2 context was created on the `OffscreenCanvas`. Emitted once per worker
    /// lifetime (retained across `LoadMesh` / model switches). The main thread surfaces
    /// a `context_acquisitions` signal from this.
    ContextAcquired,
    /// A mesh was loaded and the scene was built successfully.
    MeshLoaded { stats: MeshStats },
    /// A mesh failed to load or parse. The worker retains the last successfully-rendered
    /// scene (does not clear) so the error overlay shows over the stale mesh.
    MeshError { message: String },
    /// A preview render completed successfully. `png` is raw PNG bytes.
    PreviewImageRendered { request_id: u32, png: Vec<u8> },
    /// A preview render failed. Preview failures are non-fatal and do not affect
    /// the interactive viewer scene.
    PreviewImageError { request_id: u32, message: String },
    /// The renderer is ready to render (context + initial viewport set up).
    RendererReady,
    /// A renderer error. `fatal: true` means the worker is unusable (context loss,
    /// unhandled panic) — the main thread surfaces `ViewerStatus::RendererError`.
    Error { fatal: bool, message: String },
}

// ---------------------------------------------------------------------------
// Postcard ser/deser helpers
// ---------------------------------------------------------------------------

/// Serialize a [`RendererCommand`] to postcard bytes.
pub fn serialize_command(cmd: &RendererCommand) -> Result<Vec<u8>, postcard::Error> {
    postcard::to_allocvec(cmd)
}

/// Serialize a [`RendererEvent`] to postcard bytes.
pub fn serialize_event(evt: &RendererEvent) -> Result<Vec<u8>, postcard::Error> {
    postcard::to_allocvec(evt)
}

/// Deserialize a [`RendererCommand`] from postcard bytes.
pub fn deserialize_command(bytes: &[u8]) -> Result<RendererCommand, postcard::Error> {
    postcard::from_bytes(bytes)
}

/// Deserialize a [`RendererEvent`] from postcard bytes.
pub fn deserialize_event(bytes: &[u8]) -> Result<RendererEvent, postcard::Error> {
    postcard::from_bytes(bytes)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn from_extension_maps_known_formats() {
        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)
        );
    }

    #[test]
    fn from_extension_case_insensitive() {
        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_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);
        assert_eq!(MeshFormat::from_extension("cube."), None);
        assert_eq!(MeshFormat::from_extension("."), None);
        assert_eq!(MeshFormat::from_extension("archive.tar.gz"), None);
    }

    fn roundtrip_command(cmd: &RendererCommand) -> RendererCommand {
        deserialize_command(&serialize_command(cmd).unwrap()).unwrap()
    }

    fn roundtrip_event(evt: &RendererEvent) -> RendererEvent {
        deserialize_event(&serialize_event(evt).unwrap()).unwrap()
    }

    #[test]
    fn all_commands_roundtrip() {
        let commands = vec![
            RendererCommand::LoadMesh {
                format: MeshFormat::Stl,
                primary_url: "/models/cube.stl".into(),
            },
            RendererCommand::LoadMesh {
                format: MeshFormat::Obj,
                primary_url: "/models/cube.obj".into(),
            },
            RendererCommand::LoadMesh {
                format: MeshFormat::Gltf,
                primary_url: "/models/cube.glb".into(),
            },
            RendererCommand::LoadMesh {
                format: MeshFormat::Threemf,
                primary_url: "/models/cube.3mf".into(),
            },
            RendererCommand::RenderPreviewImage {
                request_id: 7,
                format: MeshFormat::Stl,
                primary_url: "blob:http://localhost/preview".into(),
                width: 1024,
                height: 1024,
            },
            RendererCommand::Resize {
                css_w: 800.0,
                css_h: 600.0,
                dpr: 2.0,
            },
            RendererCommand::SetPointerState {
                x: 100.0,
                y: 200.0,
                buttons: 1,
                modifiers: KeyModifiers {
                    shift: true,
                    ..Default::default()
                },
                wheel_delta: 42.0,
            },
            RendererCommand::PointerDown { x: 10.0, y: 20.0 },
            RendererCommand::PointerUp { x: 15.0, y: 25.0 },
            RendererCommand::Dispose,
            RendererCommand::DebugCrash,
        ];
        for cmd in &commands {
            assert_eq!(&roundtrip_command(cmd), cmd, "failed on {cmd:?}");
        }
    }

    #[test]
    fn all_events_roundtrip() {
        let events = vec![
            RendererEvent::ContextAcquired,
            RendererEvent::MeshLoaded {
                stats: MeshStats {
                    vertices: 1234,
                    triangles: 4321,
                },
            },
            RendererEvent::MeshError {
                message: "failed to parse: bad bytes".into(),
            },
            RendererEvent::PreviewImageRendered {
                request_id: 7,
                png: vec![137, 80, 78, 71],
            },
            RendererEvent::PreviewImageError {
                request_id: 7,
                message: "preview failed".into(),
            },
            RendererEvent::RendererReady,
            RendererEvent::Error {
                fatal: true,
                message: "context lost".into(),
            },
            RendererEvent::Error {
                fatal: false,
                message: "non-fatal warning".into(),
            },
        ];
        for evt in &events {
            assert_eq!(&roundtrip_event(evt), evt, "failed on {evt:?}");
        }
    }

    #[test]
    fn deserialize_garbage_returns_error() {
        assert!(deserialize_command(&[0xFF, 0xFE, 0xFD]).is_err());
        assert!(deserialize_event(&[0xFF, 0xFE, 0xFD]).is_err());
        assert!(deserialize_command(&[]).is_err());
        assert!(deserialize_event(&[]).is_err());
    }
}
