// glTF 2.0 file parser — reads .gltf/.glb files and converts to engine-ready format.
//
// Extracts vertices, normals, UVs, materials (base color texture + factor),
// and animations (translation/rotation/scale channels).
//
// No GPU dependency in this module — pure parsing + conversion.
// CODESPEC §4.1: all allocation happens at import time (cold path).

use crate::animation::{AnimationClip, AnimationTrack, Keyframe};
use crate::texture::{self, TextureData};
use crate::tween::TWEEN_LINEAR;

// --- Vertex layout ---

/// glTF vertex: position + normal + UV. 32 bytes, Pod-safe.
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GltfVertex {
    pub position: [f32; 3],
    pub normal: [f32; 3],
    pub tex_coords: [f32; 2],
}

// --- Material ---

/// Parsed PBR material from glTF — all 5 texture slots.
/// DreamSurface uses this to create per-material GPU bind groups at import time.
#[derive(Debug, Clone)]
pub struct GltfMaterial {
    pub name: String,
    // Factors (scalar values applied when texture is absent)
    pub base_color_factor: [f32; 4],
    pub metallic_factor: f32,
    pub roughness_factor: f32,
    pub emissive_factor: [f32; 3],
    pub normal_scale: f32,
    pub occlusion_strength: f32,
    pub alpha_mode: dreamwell_engine::material::AlphaMode,
    pub double_sided: bool,
    // Texture data (decoded at import time, uploaded by DreamSurface)
    pub base_color_texture: Option<TextureData>,
    pub normal_texture: Option<TextureData>,
    pub roughness_metalness_texture: Option<TextureData>,
    pub occlusion_texture: Option<TextureData>,
    pub emissive_texture: Option<TextureData>,
}

impl Default for GltfMaterial {
    fn default() -> Self {
        Self {
            name: "default".into(),
            base_color_factor: [1.0, 1.0, 1.0, 1.0],
            metallic_factor: 0.0,
            roughness_factor: 1.0,
            emissive_factor: [0.0, 0.0, 0.0],
            normal_scale: 1.0,
            occlusion_strength: 1.0,
            alpha_mode: dreamwell_engine::material::AlphaMode::Opaque,
            double_sided: false,
            base_color_texture: None,
            normal_texture: None,
            roughness_metalness_texture: None,
            occlusion_texture: None,
            emissive_texture: None,
        }
    }
}

// --- Parsed mesh ---

/// A single mesh primitive with its material index.
#[derive(Debug, Clone)]
pub struct GltfPrimitive {
    pub vertices: Vec<GltfVertex>,
    pub indices: Vec<u32>,
    pub material_index: Option<usize>,
}

/// A named mesh containing one or more primitives.
#[derive(Debug, Clone)]
pub struct GltfMesh {
    pub name: String,
    pub primitives: Vec<GltfPrimitive>,
}

// --- Node ---

/// A scene node with transform and optional mesh reference.
#[derive(Debug, Clone)]
pub struct GltfNode {
    pub name: String,
    pub mesh_index: Option<usize>,
    pub translation: [f32; 3],
    pub rotation: [f32; 4], // quaternion [x, y, z, w]
    pub scale: [f32; 3],
    pub children: Vec<usize>,
}

// --- Animation ---

/// Parsed animation from glTF.
#[derive(Debug, Clone)]
pub struct GltfAnimation {
    pub name: String,
    pub channels: Vec<GltfChannel>,
}

/// A single animation channel targeting a node property.
#[derive(Debug, Clone)]
pub struct GltfChannel {
    pub node_index: usize,
    pub property: GltfAnimProperty,
    pub times: Vec<f32>,       // seconds
    pub values: Vec<[f32; 4]>, // padded to vec4
}

/// Animated property type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GltfAnimProperty {
    Translation,
    Rotation,
    Scale,
}

// --- Import result ---

/// Complete result of parsing a glTF file.
pub struct GltfImportResult {
    pub meshes: Vec<GltfMesh>,
    pub materials: Vec<GltfMaterial>,
    pub nodes: Vec<GltfNode>,
    pub animations: Vec<GltfAnimation>,
    pub bounding_sphere: [f32; 4], // [cx, cy, cz, radius]
    pub stats: GltfStats,
}

#[derive(Debug, Clone)]
pub struct GltfStats {
    pub mesh_count: usize,
    pub primitive_count: usize,
    pub vertex_count: usize,
    pub triangle_count: usize,
    pub material_count: usize,
    pub animation_count: usize,
    pub node_count: usize,
    pub texture_count: usize,
}

// --- Error type ---

#[derive(Debug)]
pub enum GltfError {
    ParseFailed(String),
    NoMeshes,
    BufferLoadFailed(String),
    UnsupportedPrimitive(String),
    /// ZIP archive error (invalid, too large, missing glTF inside).
    ArchiveError(String),
    /// File exceeds configurable size limit.
    FileTooLarge {
        size_bytes: u64,
        limit_bytes: u64,
    },
}

impl std::fmt::Display for GltfError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ParseFailed(msg) => write!(f, "gltf_parse_failed:{}", msg),
            Self::NoMeshes => write!(f, "gltf_no_meshes:file contains no mesh data"),
            Self::BufferLoadFailed(msg) => write!(f, "gltf_buffer_load:{}", msg),
            Self::UnsupportedPrimitive(msg) => write!(f, "gltf_unsupported_primitive:{}", msg),
            Self::ArchiveError(msg) => write!(f, "gltf_archive_error:{}", msg),
            Self::FileTooLarge {
                size_bytes,
                limit_bytes,
            } => write!(f, "gltf_file_too_large:{}B exceeds {}B limit", size_bytes, limit_bytes),
        }
    }
}

impl std::error::Error for GltfError {}

// --- Parser ---

/// Parse a glTF/GLB file from raw bytes.
///
/// For .glb files, buffer data is embedded. For .gltf files, external buffer
/// data must be provided via `external_buffers` keyed by URI.
pub fn parse_gltf(
    data: &[u8],
    external_buffers: &std::collections::HashMap<String, Vec<u8>>,
) -> Result<GltfImportResult, GltfError> {
    let gltf = gltf::Gltf::from_slice(data).map_err(|e| GltfError::ParseFailed(e.to_string()))?;

    // Load buffer data
    let buffers = load_buffers(&gltf, data, external_buffers)?;

    // Parse materials
    let materials = parse_materials(&gltf, &buffers);

    // Parse meshes
    let meshes = parse_meshes(&gltf, &buffers)?;

    if meshes.is_empty() {
        return Err(GltfError::NoMeshes);
    }

    // Parse nodes
    let nodes = parse_nodes(&gltf);

    // Parse animations
    let animations = parse_animations(&gltf, &buffers);

    // Compute bounding sphere from all vertices
    let bounding_sphere = compute_bounding_sphere(&meshes);

    // Compute stats
    let mut total_verts = 0;
    let mut total_tris = 0;
    let mut prim_count = 0;
    let mut tex_count = 0;
    for mesh in &meshes {
        for prim in &mesh.primitives {
            total_verts += prim.vertices.len();
            total_tris += prim.indices.len() / 3;
            prim_count += 1;
        }
    }
    // Count ALL PBR texture slots across all materials (DreamSurface)
    for mat in &materials {
        if mat.base_color_texture.is_some() {
            tex_count += 1;
        }
        if mat.normal_texture.is_some() {
            tex_count += 1;
        }
        if mat.roughness_metalness_texture.is_some() {
            tex_count += 1;
        }
        if mat.occlusion_texture.is_some() {
            tex_count += 1;
        }
        if mat.emissive_texture.is_some() {
            tex_count += 1;
        }
    }

    let stats = GltfStats {
        mesh_count: meshes.len(),
        primitive_count: prim_count,
        vertex_count: total_verts,
        triangle_count: total_tris,
        material_count: materials.len(),
        animation_count: animations.len(),
        node_count: nodes.len(),
        texture_count: tex_count,
    };

    Ok(GltfImportResult {
        meshes,
        materials,
        nodes,
        animations,
        bounding_sphere,
        stats,
    })
}

/// Load a .glb file from raw bytes (convenience — no external buffers needed).
pub fn load_glb(data: &[u8]) -> Result<GltfImportResult, GltfError> {
    parse_gltf(data, &std::collections::HashMap::new())
}

/// Load a .gltf file from disk (reads external .bin buffers relative to file path).
pub fn load_gltf_from_path(path: &std::path::Path) -> Result<GltfImportResult, GltfError> {
    let data = std::fs::read(path).map_err(|e| GltfError::BufferLoadFailed(format!("{}: {}", path.display(), e)))?;

    let parent = path.parent().unwrap_or(std::path::Path::new("."));

    // Pre-load external buffers referenced by URI
    let gltf_peek = gltf::Gltf::from_slice(&data).map_err(|e| GltfError::ParseFailed(e.to_string()))?;

    let mut external_buffers = std::collections::HashMap::new();
    for buffer in gltf_peek.buffers() {
        if let gltf::buffer::Source::Uri(uri) = buffer.source() {
            if !uri.starts_with("data:") {
                let buf_path = parent.join(uri);
                let buf_data = std::fs::read(&buf_path)
                    .map_err(|e| GltfError::BufferLoadFailed(format!("{}: {}", buf_path.display(), e)))?;
                external_buffers.insert(uri.to_string(), buf_data);
            }
        }
    }

    parse_gltf(&data, &external_buffers)
}

/// Import pipeline configuration.
#[derive(Debug, Clone)]
pub struct GltfImportConfig {
    /// Maximum file size in bytes (default: 8 GB).
    pub max_file_size: u64,
    /// Maximum total extracted size from ZIP (default: 8 GB).
    pub max_extracted_size: u64,
    /// Maximum number of files in ZIP archive (default: 1000).
    pub max_archive_entries: usize,
}

impl Default for GltfImportConfig {
    fn default() -> Self {
        Self {
            max_file_size: 8 * 1024 * 1024 * 1024,      // 8 GB
            max_extracted_size: 8 * 1024 * 1024 * 1024, // 8 GB
            max_archive_entries: 1000,
        }
    }
}

/// Import pipeline step tracking for loading modal progress.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportStage {
    /// Validating archive (size check, entry scan).
    Validating,
    /// Extracting ZIP contents to memory.
    Extracting,
    /// Parsing glTF JSON + binary buffers.
    Parsing,
    /// Decoding textures (PNG/JPEG).
    DecodingTextures,
    /// Computing bounding volumes + validation.
    Finalizing,
    /// Import complete.
    Done,
}

impl ImportStage {
    /// Human-readable label for loading modal.
    pub fn label(self) -> &'static str {
        match self {
            Self::Validating => "Validating archive...",
            Self::Extracting => "Extracting files...",
            Self::Parsing => "Parsing glTF...",
            Self::DecodingTextures => "Decoding textures...",
            Self::Finalizing => "Finalizing import...",
            Self::Done => "Import complete",
        }
    }
}

/// Extended import result with timing and stage data.
pub struct GltfImportReport {
    pub result: GltfImportResult,
    /// Time spent in each import stage (seconds).
    pub stage_times: Vec<(ImportStage, f32)>,
    /// Source file path.
    pub source_path: String,
    /// Whether the source was a ZIP archive.
    pub from_zip: bool,
    /// Total import time (seconds).
    pub total_time_secs: f32,
}

/// Load a glTF model from a ZIP archive.
///
/// The ZIP must contain exactly one `.gltf` or `.glb` file at any nesting level.
/// External buffers (`.bin`) and textures are resolved from within the archive.
///
/// Security: validates archive size, entry count, and path traversal before extraction.
pub fn load_gltf_from_zip(
    zip_path: &std::path::Path,
    config: &GltfImportConfig,
) -> Result<GltfImportReport, GltfError> {
    use std::io::Read;
    use std::time::Instant;

    let start = Instant::now();
    let mut stage_times = Vec::new();

    // ── Stage 1: Validate archive ──────────────────────────────────
    let stage_start = Instant::now();

    let file_size = std::fs::metadata(zip_path)
        .map_err(|e| GltfError::ArchiveError(format!("cannot read {}: {}", zip_path.display(), e)))?
        .len();

    if file_size > config.max_file_size {
        return Err(GltfError::FileTooLarge {
            size_bytes: file_size,
            limit_bytes: config.max_file_size,
        });
    }

    let zip_data = std::fs::read(zip_path).map_err(|e| GltfError::ArchiveError(format!("read failed: {}", e)))?;

    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&zip_data))
        .map_err(|e| GltfError::ArchiveError(format!("invalid ZIP: {}", e)))?;

    if archive.len() > config.max_archive_entries {
        return Err(GltfError::ArchiveError(format!(
            "archive has {} entries (limit {})",
            archive.len(),
            config.max_archive_entries
        )));
    }

    stage_times.push((ImportStage::Validating, stage_start.elapsed().as_secs_f32()));

    // ── Stage 2: Extract and find glTF entry ───────────────────────
    let stage_start = Instant::now();

    let mut gltf_entry_name: Option<String> = None;
    let mut extracted: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
    let mut total_extracted: u64 = 0;

    for i in 0..archive.len() {
        let mut entry = archive
            .by_index(i)
            .map_err(|e| GltfError::ArchiveError(format!("entry {}: {}", i, e)))?;

        let name = entry.name().to_string();

        // Security: reject path traversal
        if name.contains("..") || name.starts_with('/') || name.starts_with('\\') {
            continue;
        }

        // Skip directories
        if entry.is_dir() {
            continue;
        }

        // Size check
        let entry_size = entry.size();
        total_extracted += entry_size;
        if total_extracted > config.max_extracted_size {
            return Err(GltfError::FileTooLarge {
                size_bytes: total_extracted,
                limit_bytes: config.max_extracted_size,
            });
        }

        // Read entry contents
        let mut buf = Vec::with_capacity(entry_size as usize);
        entry
            .read_to_end(&mut buf)
            .map_err(|e| GltfError::ArchiveError(format!("extract {}: {}", name, e)))?;

        // Track glTF/GLB entry
        let lower = name.to_lowercase();
        if lower.ends_with(".gltf") || lower.ends_with(".glb") {
            if gltf_entry_name.is_none() {
                gltf_entry_name = Some(name.clone());
            }
        }

        extracted.insert(name, buf);
    }

    let gltf_name =
        gltf_entry_name.ok_or_else(|| GltfError::ArchiveError("no .gltf or .glb file found in archive".into()))?;

    stage_times.push((ImportStage::Extracting, stage_start.elapsed().as_secs_f32()));

    // ── Stage 3: Parse glTF ────────────────────────────────────────
    let stage_start = Instant::now();

    let gltf_data = extracted.get(&gltf_name).unwrap();

    // Build external buffers map: resolve URIs relative to the glTF entry's parent dir
    let gltf_parent = std::path::Path::new(&gltf_name)
        .parent()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();

    let mut external_buffers = std::collections::HashMap::new();
    for (name, data) in &extracted {
        if name == &gltf_name {
            continue;
        }
        // Compute relative path from the glTF entry's directory
        let relative = if !gltf_parent.is_empty() && name.starts_with(&gltf_parent) {
            name[gltf_parent.len()..]
                .trim_start_matches('/')
                .trim_start_matches('\\')
                .to_string()
        } else {
            name.clone()
        };
        external_buffers.insert(relative, data.clone());
    }

    let result = parse_gltf(gltf_data, &external_buffers)?;

    stage_times.push((ImportStage::Parsing, stage_start.elapsed().as_secs_f32()));
    stage_times.push((ImportStage::DecodingTextures, 0.0)); // Textures decoded inside parse_gltf
    stage_times.push((ImportStage::Finalizing, 0.0));
    stage_times.push((ImportStage::Done, 0.0));

    let total_time = start.elapsed().as_secs_f32();

    log::info!(
        "glTF import from ZIP: {} — {} meshes, {} verts, {} tris, {} materials, {} textures ({:.2}s)",
        zip_path.display(),
        result.stats.mesh_count,
        result.stats.vertex_count,
        result.stats.triangle_count,
        result.stats.material_count,
        result.stats.texture_count,
        total_time,
    );

    Ok(GltfImportReport {
        result,
        stage_times,
        source_path: zip_path.display().to_string(),
        from_zip: true,
        total_time_secs: total_time,
    })
}

/// Load a glTF model from any supported file (auto-detects .gltf, .glb, .zip).
///
/// This is the canonical entry point for the Dreamwell import pipeline.
/// Delegates to `load_gltf_from_path` for .gltf/.glb, `load_gltf_from_zip` for .zip.
pub fn import_gltf(path: &std::path::Path, config: &GltfImportConfig) -> Result<GltfImportReport, GltfError> {
    use std::time::Instant;

    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|s| s.to_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        "zip" => load_gltf_from_zip(path, config),
        "gltf" | "glb" => {
            let start = Instant::now();
            let result = load_gltf_from_path(path)?;
            let total = start.elapsed().as_secs_f32();
            Ok(GltfImportReport {
                stage_times: vec![(ImportStage::Parsing, total), (ImportStage::Done, 0.0)],
                source_path: path.display().to_string(),
                from_zip: false,
                total_time_secs: total,
                result,
            })
        }
        other => Err(GltfError::ParseFailed(format!("unsupported extension: .{}", other))),
    }
}

/// Convert glTF animations to engine AnimationClips.
pub fn convert_animations(gltf_anims: &[GltfAnimation]) -> Vec<AnimationClip> {
    gltf_anims
        .iter()
        .map(|anim| {
            let mut tracks = Vec::new();
            let mut max_time_ms = 0u32;

            for channel in &anim.channels {
                let property = match channel.property {
                    GltfAnimProperty::Translation => "translation",
                    GltfAnimProperty::Rotation => "rotation",
                    GltfAnimProperty::Scale => "scale",
                };

                let keyframes: Vec<Keyframe> = channel
                    .times
                    .iter()
                    .zip(channel.values.iter())
                    .map(|(&t, &v)| {
                        let time_ms = (t * 1000.0) as u32;
                        if time_ms > max_time_ms {
                            max_time_ms = time_ms;
                        }
                        Keyframe { time_ms, value: v }
                    })
                    .collect();

                tracks.push(AnimationTrack {
                    property: format!("node[{}].{}", channel.node_index, property),
                    keyframes,
                    tween_mode: TWEEN_LINEAR,
                });
            }

            AnimationClip {
                name: anim.name.clone(),
                duration_ms: max_time_ms,
                tracks,
                looping: true,
            }
        })
        .collect()
}

// --- Internal helpers ---

fn load_buffers(
    gltf: &gltf::Gltf,
    raw_data: &[u8],
    external_buffers: &std::collections::HashMap<String, Vec<u8>>,
) -> Result<Vec<Vec<u8>>, GltfError> {
    let mut buffers = Vec::new();

    for buffer in gltf.buffers() {
        match buffer.source() {
            gltf::buffer::Source::Bin => {
                // GLB embedded binary chunk
                if let Some(blob) = gltf.blob.as_ref() {
                    buffers.push(blob.clone());
                } else {
                    // Try to extract from raw data (glb format: 12-byte header + JSON chunk + BIN chunk)
                    let bin = extract_glb_bin(raw_data, buffer.length());
                    buffers.push(bin.unwrap_or_default());
                }
            }
            gltf::buffer::Source::Uri(uri) => {
                if let Some(rest) = uri.strip_prefix("data:") {
                    // Data URI
                    if let Some(base64_start) = rest.find(";base64,") {
                        let b64 = &rest[base64_start + 8..];
                        let decoded = base64_decode(b64);
                        buffers.push(decoded);
                    } else {
                        buffers.push(Vec::new());
                    }
                } else if let Some(buf) = external_buffers.get(uri) {
                    buffers.push(buf.clone());
                } else {
                    return Err(GltfError::BufferLoadFailed(format!(
                        "external buffer '{}' not provided",
                        uri
                    )));
                }
            }
        }
    }

    Ok(buffers)
}

fn extract_glb_bin(data: &[u8], _expected_len: usize) -> Option<Vec<u8>> {
    // GLB header: magic(4) + version(4) + length(4) = 12 bytes
    // JSON chunk: length(4) + type(4) + data(length)
    // BIN chunk: length(4) + type(4) + data(length)
    if data.len() < 12 {
        return None;
    }
    let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
    if magic != 0x46546C67 {
        // 'glTF'
        return None;
    }
    // Skip JSON chunk
    let json_len = u32::from_le_bytes([data[12], data[13], data[14], data[15]]) as usize;
    let bin_start = 12 + 8 + json_len; // header + json_chunk_header + json_data
    if bin_start + 8 > data.len() {
        return None;
    }
    let bin_len = u32::from_le_bytes([
        data[bin_start],
        data[bin_start + 1],
        data[bin_start + 2],
        data[bin_start + 3],
    ]) as usize;
    let bin_data_start = bin_start + 8;
    let bin_data_end = bin_data_start + bin_len;
    if bin_data_end > data.len() {
        return None;
    }
    Some(data[bin_data_start..bin_data_end].to_vec())
}

fn base64_decode(input: &str) -> Vec<u8> {
    const TABLE: [u8; 128] = {
        let mut t = [255u8; 128];
        let mut i = 0u8;
        while i < 26 {
            t[(b'A' + i) as usize] = i;
            t[(b'a' + i) as usize] = i + 26;
            i += 1;
        }
        let mut d = 0u8;
        while d < 10 {
            t[(b'0' + d) as usize] = d + 52;
            d += 1;
        }
        t[b'+' as usize] = 62;
        t[b'/' as usize] = 63;
        t
    };

    let bytes: Vec<u8> = input
        .bytes()
        .filter(|&b| b != b'=' && b != b'\n' && b != b'\r')
        .collect();
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
    for chunk in bytes.chunks(4) {
        let mut buf = [0u8; 4];
        for (i, &b) in chunk.iter().enumerate() {
            buf[i] = if (b as usize) < 128 { TABLE[b as usize] } else { 0 };
        }
        out.push((buf[0] << 2) | (buf[1] >> 4));
        if chunk.len() > 2 {
            out.push((buf[1] << 4) | (buf[2] >> 2));
        }
        if chunk.len() > 3 {
            out.push((buf[2] << 6) | buf[3]);
        }
    }
    out
}

/// Extract texture data from a glTF texture info (View or data URI).
/// Shared helper for all 5 PBR texture slots.
fn extract_texture(tex: &gltf::Texture<'_>, buffers: &[Vec<u8>]) -> Option<TextureData> {
    let source = tex.source();
    match source.source() {
        gltf::image::Source::View { view, mime_type: _ } => {
            let buf_idx = view.buffer().index();
            if buf_idx < buffers.len() {
                let start = view.offset();
                let end = start + view.length();
                if end <= buffers[buf_idx].len() {
                    let img_data = &buffers[buf_idx][start..end];
                    texture::decode_image(img_data).ok()
                } else {
                    None
                }
            } else {
                None
            }
        }
        gltf::image::Source::Uri { uri, mime_type: _ } => {
            if let Some(rest) = uri.strip_prefix("data:") {
                if let Some(base64_start) = rest.find(";base64,") {
                    let b64 = &rest[base64_start + 8..];
                    let decoded = base64_decode(b64);
                    texture::decode_image(&decoded).ok()
                } else {
                    None
                }
            } else {
                None // External URI textures not loaded here
            }
        }
    }
}

fn parse_materials(gltf: &gltf::Gltf, buffers: &[Vec<u8>]) -> Vec<GltfMaterial> {
    gltf.materials()
        .map(|mat| {
            let pbr = mat.pbr_metallic_roughness();
            let base_color_factor = pbr.base_color_factor();
            let metallic_factor = pbr.metallic_factor();
            let roughness_factor = pbr.roughness_factor();

            // All 5 PBR texture slots (DreamSurface)
            let base_color_texture = pbr
                .base_color_texture()
                .and_then(|info| extract_texture(&info.texture(), buffers));
            let normal_texture = mat
                .normal_texture()
                .and_then(|info| extract_texture(&info.texture(), buffers));
            let roughness_metalness_texture = pbr
                .metallic_roughness_texture()
                .and_then(|info| extract_texture(&info.texture(), buffers));
            let occlusion_texture = mat
                .occlusion_texture()
                .and_then(|info| extract_texture(&info.texture(), buffers));
            let emissive_texture = mat
                .emissive_texture()
                .and_then(|info| extract_texture(&info.texture(), buffers));

            // Material factors + metadata
            let emissive_factor = mat.emissive_factor();
            let normal_scale = mat.normal_texture().map(|n| n.scale()).unwrap_or(1.0);
            let occlusion_strength = mat.occlusion_texture().map(|o| o.strength()).unwrap_or(1.0);

            let alpha_mode = match mat.alpha_mode() {
                gltf::material::AlphaMode::Opaque => dreamwell_engine::material::AlphaMode::Opaque,
                gltf::material::AlphaMode::Mask => dreamwell_engine::material::AlphaMode::Mask {
                    cutoff: mat.alpha_cutoff().unwrap_or(0.5),
                },
                gltf::material::AlphaMode::Blend => dreamwell_engine::material::AlphaMode::Blend,
            };

            let tex_count = [
                &base_color_texture,
                &normal_texture,
                &roughness_metalness_texture,
                &occlusion_texture,
                &emissive_texture,
            ]
            .iter()
            .filter(|t| t.is_some())
            .count();
            if tex_count > 0 {
                log::info!(
                    "glTF material '{}': {} textures (bc={} n={} rm={} ao={} em={})",
                    mat.name().unwrap_or("unnamed"),
                    tex_count,
                    base_color_texture.is_some(),
                    normal_texture.is_some(),
                    roughness_metalness_texture.is_some(),
                    occlusion_texture.is_some(),
                    emissive_texture.is_some(),
                );
            }

            GltfMaterial {
                name: mat.name().unwrap_or("unnamed").to_string(),
                base_color_factor,
                metallic_factor,
                roughness_factor,
                emissive_factor,
                normal_scale,
                occlusion_strength,
                alpha_mode,
                double_sided: mat.double_sided(),
                base_color_texture,
                normal_texture,
                roughness_metalness_texture,
                occlusion_texture,
                emissive_texture,
            }
        })
        .collect()
}

fn parse_meshes(gltf: &gltf::Gltf, buffers: &[Vec<u8>]) -> Result<Vec<GltfMesh>, GltfError> {
    let mut meshes = Vec::new();

    for mesh in gltf.meshes() {
        let name = mesh.name().unwrap_or("unnamed").to_string();
        let mut primitives = Vec::new();

        for prim in mesh.primitives() {
            if prim.mode() != gltf::mesh::Mode::Triangles {
                continue; // Skip non-triangle primitives
            }

            let reader = prim.reader(|buffer| buffers.get(buffer.index()).map(|data| data.as_slice()));

            // Read positions (required)
            let positions: Vec<[f32; 3]> = match reader.read_positions() {
                Some(iter) => iter.collect(),
                None => continue,
            };

            // Read normals (optional — generate flat normals if missing)
            let normals: Vec<[f32; 3]> = match reader.read_normals() {
                Some(iter) => iter.collect(),
                None => vec![[0.0, 1.0, 0.0]; positions.len()],
            };

            // Read tex coords (optional)
            let tex_coords: Vec<[f32; 2]> = match reader.read_tex_coords(0) {
                Some(iter) => iter.into_f32().collect(),
                None => vec![[0.0, 0.0]; positions.len()],
            };

            // Read indices (optional — generate sequential if missing)
            let indices: Vec<u32> = match reader.read_indices() {
                Some(iter) => iter.into_u32().collect(),
                None => (0..positions.len() as u32).collect(),
            };

            // Build vertex array
            let vertices: Vec<GltfVertex> = positions
                .iter()
                .enumerate()
                .map(|(i, &pos)| GltfVertex {
                    position: pos,
                    normal: normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]),
                    tex_coords: tex_coords.get(i).copied().unwrap_or([0.0, 0.0]),
                })
                .collect();

            let material_index = prim.material().index();

            primitives.push(GltfPrimitive {
                vertices,
                indices,
                material_index,
            });
        }

        meshes.push(GltfMesh { name, primitives });
    }

    Ok(meshes)
}

fn parse_nodes(gltf: &gltf::Gltf) -> Vec<GltfNode> {
    gltf.nodes()
        .map(|node| {
            let (translation, rotation, scale) = node.transform().decomposed();
            GltfNode {
                name: node.name().unwrap_or("node").to_string(),
                mesh_index: node.mesh().map(|m| m.index()),
                translation,
                rotation,
                scale,
                children: node.children().map(|c| c.index()).collect(),
            }
        })
        .collect()
}

fn parse_animations(gltf: &gltf::Gltf, buffers: &[Vec<u8>]) -> Vec<GltfAnimation> {
    gltf.animations()
        .map(|anim| {
            let name = anim.name().unwrap_or("animation").to_string();
            let mut channels = Vec::new();

            for channel in anim.channels() {
                let node_index = channel.target().node().index();

                let property = match channel.target().property() {
                    gltf::animation::Property::Translation => GltfAnimProperty::Translation,
                    gltf::animation::Property::Rotation => GltfAnimProperty::Rotation,
                    gltf::animation::Property::Scale => GltfAnimProperty::Scale,
                    _ => continue, // Skip morph targets / weights for now
                };

                let reader = channel.reader(|buffer| buffers.get(buffer.index()).map(|data| data.as_slice()));

                let times: Vec<f32> = match reader.read_inputs() {
                    Some(iter) => iter.collect(),
                    None => continue,
                };

                let values: Vec<[f32; 4]> = match reader.read_outputs() {
                    Some(outputs) => match outputs {
                        gltf::animation::util::ReadOutputs::Translations(iter) => {
                            iter.map(|[x, y, z]| [x, y, z, 0.0]).collect()
                        }
                        gltf::animation::util::ReadOutputs::Rotations(iter) => iter.into_f32().collect(),
                        gltf::animation::util::ReadOutputs::Scales(iter) => {
                            iter.map(|[x, y, z]| [x, y, z, 0.0]).collect()
                        }
                        _ => continue,
                    },
                    None => continue,
                };

                channels.push(GltfChannel {
                    node_index,
                    property,
                    times,
                    values,
                });
            }

            GltfAnimation { name, channels }
        })
        .collect()
}

fn compute_bounding_sphere(meshes: &[GltfMesh]) -> [f32; 4] {
    let mut min = [f32::MAX; 3];
    let mut max = [f32::MIN; 3];
    let mut has_verts = false;

    for mesh in meshes {
        for prim in &mesh.primitives {
            for v in &prim.vertices {
                has_verts = true;
                for i in 0..3 {
                    min[i] = min[i].min(v.position[i]);
                    max[i] = max[i].max(v.position[i]);
                }
            }
        }
    }

    if !has_verts {
        return [0.0, 0.0, 0.0, 1.0];
    }

    let center = [
        (min[0] + max[0]) * 0.5,
        (min[1] + max[1]) * 0.5,
        (min[2] + max[2]) * 0.5,
    ];

    let dx = max[0] - min[0];
    let dy = max[1] - min[1];
    let dz = max[2] - min[2];
    let radius = (dx * dx + dy * dy + dz * dz).sqrt() * 0.5;

    [center[0], center[1], center[2], radius]
}

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

    #[test]
    fn gltf_vertex_size() {
        assert_eq!(std::mem::size_of::<GltfVertex>(), 32);
    }

    #[test]
    fn gltf_vertex_is_pod() {
        let v = GltfVertex {
            position: [1.0, 2.0, 3.0],
            normal: [0.0, 1.0, 0.0],
            tex_coords: [0.5, 0.5],
        };
        let bytes = bytemuck::bytes_of(&v);
        assert_eq!(bytes.len(), 32);
    }

    #[test]
    fn default_material() {
        let mat = GltfMaterial::default();
        assert_eq!(mat.base_color_factor, [1.0, 1.0, 1.0, 1.0]);
        assert!(mat.base_color_texture.is_none());
    }

    #[test]
    fn bounding_sphere_empty() {
        let bs = compute_bounding_sphere(&[]);
        assert_eq!(bs, [0.0, 0.0, 0.0, 1.0]);
    }

    #[test]
    fn bounding_sphere_single_vertex() {
        let meshes = vec![GltfMesh {
            name: "test".into(),
            primitives: vec![GltfPrimitive {
                vertices: vec![GltfVertex {
                    position: [1.0, 2.0, 3.0],
                    normal: [0.0, 1.0, 0.0],
                    tex_coords: [0.0, 0.0],
                }],
                indices: vec![0],
                material_index: None,
            }],
        }];
        let bs = compute_bounding_sphere(&meshes);
        assert_eq!(bs[0], 1.0);
        assert_eq!(bs[1], 2.0);
        assert_eq!(bs[2], 3.0);
        assert_eq!(bs[3], 0.0); // single point, zero radius
    }

    #[test]
    fn bounding_sphere_two_vertices() {
        let meshes = vec![GltfMesh {
            name: "test".into(),
            primitives: vec![GltfPrimitive {
                vertices: vec![
                    GltfVertex {
                        position: [0.0, 0.0, 0.0],
                        normal: [0.0, 1.0, 0.0],
                        tex_coords: [0.0, 0.0],
                    },
                    GltfVertex {
                        position: [2.0, 0.0, 0.0],
                        normal: [0.0, 1.0, 0.0],
                        tex_coords: [1.0, 0.0],
                    },
                ],
                indices: vec![0, 1, 0],
                material_index: None,
            }],
        }];
        let bs = compute_bounding_sphere(&meshes);
        assert!((bs[0] - 1.0).abs() < 1e-6); // center x
        assert_eq!(bs[1], 0.0);
        assert!((bs[3] - 1.0).abs() < 1e-6); // radius
    }

    #[test]
    fn base64_decode_basic() {
        let encoded = "SGVsbG8=";
        let decoded = base64_decode(encoded);
        assert_eq!(decoded, b"Hello");
    }

    #[test]
    fn parse_invalid_gltf() {
        let result = parse_gltf(&[0, 1, 2, 3], &std::collections::HashMap::new());
        assert!(result.is_err());
    }

    #[test]
    fn convert_empty_animations() {
        let clips = convert_animations(&[]);
        assert!(clips.is_empty());
    }

    #[test]
    fn convert_animation_with_channels() {
        let anims = vec![GltfAnimation {
            name: "walk".into(),
            channels: vec![GltfChannel {
                node_index: 0,
                property: GltfAnimProperty::Translation,
                times: vec![0.0, 1.0],
                values: vec![[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0]],
            }],
        }];
        let clips = convert_animations(&anims);
        assert_eq!(clips.len(), 1);
        assert_eq!(clips[0].name, "walk");
        assert_eq!(clips[0].duration_ms, 1000);
        assert_eq!(clips[0].tracks.len(), 1);
        assert_eq!(clips[0].tracks[0].keyframes.len(), 2);
    }

    #[test]
    fn gltf_anim_property_eq() {
        assert_eq!(GltfAnimProperty::Translation, GltfAnimProperty::Translation);
        assert_ne!(GltfAnimProperty::Translation, GltfAnimProperty::Rotation);
    }

    #[test]
    fn gltf_node_default_transform() {
        let node = GltfNode {
            name: "test".into(),
            mesh_index: None,
            translation: [0.0; 3],
            rotation: [0.0, 0.0, 0.0, 1.0],
            scale: [1.0; 3],
            children: vec![],
        };
        assert_eq!(node.translation, [0.0; 3]);
        assert_eq!(node.scale, [1.0; 3]);
    }

    #[test]
    fn gltf_stats_debug() {
        let stats = GltfStats {
            mesh_count: 1,
            primitive_count: 2,
            vertex_count: 100,
            triangle_count: 50,
            material_count: 1,
            animation_count: 0,
            node_count: 3,
            texture_count: 1,
        };
        let debug = format!("{:?}", stats);
        assert!(debug.contains("mesh_count: 1"));
    }

    // ── ZIP import pipeline tests ──────────────────────────────────

    #[test]
    fn import_config_defaults() {
        let config = GltfImportConfig::default();
        assert_eq!(config.max_file_size, 8 * 1024 * 1024 * 1024);
        assert_eq!(config.max_archive_entries, 1000);
    }

    #[test]
    fn import_stage_labels() {
        assert_eq!(ImportStage::Validating.label(), "Validating archive...");
        assert_eq!(ImportStage::Done.label(), "Import complete");
    }

    #[test]
    fn import_gltf_rejects_unknown_extension() {
        let path = std::path::Path::new("test.fbx");
        let config = GltfImportConfig::default();
        let result = import_gltf(path, &config);
        assert!(result.is_err());
        if let Err(GltfError::ParseFailed(msg)) = result {
            assert!(msg.contains("unsupported extension"));
        }
    }

    #[test]
    fn gltf_error_display_archive() {
        let e = GltfError::ArchiveError("test error".into());
        assert_eq!(format!("{}", e), "gltf_archive_error:test error");
    }

    #[test]
    fn gltf_error_display_too_large() {
        let e = GltfError::FileTooLarge {
            size_bytes: 100,
            limit_bytes: 50,
        };
        assert!(format!("{}", e).contains("100B exceeds 50B"));
    }

    #[test]
    fn import_zip_nonexistent_file() {
        let config = GltfImportConfig::default();
        let result = load_gltf_from_zip(std::path::Path::new("nonexistent.zip"), &config);
        assert!(result.is_err());
    }

    #[test]
    fn gltf_material_default_values() {
        let mat = GltfMaterial::default();
        assert_eq!(mat.base_color_factor, [1.0, 1.0, 1.0, 1.0]);
        assert_eq!(mat.metallic_factor, 0.0);
        assert_eq!(mat.roughness_factor, 1.0);
        assert!(mat.base_color_texture.is_none());
        assert_eq!(mat.name, "default");
    }

    #[test]
    fn gltf_import_result_stats_consistency() {
        // Verify GltfStats fields are coherent
        let stats = GltfStats {
            mesh_count: 3,
            primitive_count: 5,
            vertex_count: 1000,
            triangle_count: 500,
            material_count: 2,
            animation_count: 1,
            node_count: 4,
            texture_count: 1,
        };
        assert!(stats.primitive_count >= stats.mesh_count);
        assert!(stats.vertex_count >= stats.triangle_count);
        assert!(stats.node_count > 0);
    }

    #[test]
    fn import_report_timing_fields() {
        // Verify GltfImportReport can be constructed with valid timing data
        let result = GltfImportResult {
            meshes: vec![],
            materials: vec![],
            nodes: vec![],
            animations: vec![],
            bounding_sphere: [0.0, 0.0, 0.0, 1.0],
            stats: GltfStats {
                mesh_count: 0,
                primitive_count: 0,
                vertex_count: 0,
                triangle_count: 0,
                material_count: 0,
                animation_count: 0,
                node_count: 0,
                texture_count: 0,
            },
        };
        let report = GltfImportReport {
            result,
            stage_times: vec![
                (ImportStage::Validating, 0.001),
                (ImportStage::Extracting, 0.05),
                (ImportStage::Parsing, 0.1),
                (ImportStage::Done, 0.0),
            ],
            source_path: "test.zip".into(),
            from_zip: true,
            total_time_secs: 0.151,
        };
        assert!(report.from_zip);
        assert_eq!(report.stage_times.len(), 4);
        assert!(report.total_time_secs > 0.0);
    }

    #[test]
    fn import_config_size_limit_enforcement() {
        let config = GltfImportConfig {
            max_file_size: 100, // 100 bytes — tiny limit for testing
            max_extracted_size: 100,
            max_archive_entries: 10,
        };
        // Any real file will exceed 100 bytes
        let temp_dir = std::env::temp_dir();
        let test_file = temp_dir.join("dreamwell_size_test.zip");
        // Create a dummy file > 100 bytes
        std::fs::write(&test_file, vec![0u8; 200]).ok();
        if test_file.exists() {
            let result = load_gltf_from_zip(&test_file, &config);
            assert!(matches!(result, Err(GltfError::FileTooLarge { .. })));
            let _ = std::fs::remove_file(&test_file);
        }
    }

    #[test]
    fn all_import_stages_have_labels() {
        let stages = [
            ImportStage::Validating,
            ImportStage::Extracting,
            ImportStage::Parsing,
            ImportStage::DecodingTextures,
            ImportStage::Finalizing,
            ImportStage::Done,
        ];
        for stage in stages {
            assert!(!stage.label().is_empty());
        }
    }

    #[test]
    fn import_zip_library_files() {
        // Test against actual glTF library files if available
        let gltf_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join("data")
            .join("dreamwell")
            .join("glTF");

        if !gltf_dir.exists() {
            // Skip if test data not available (CI environment)
            return;
        }

        let config = GltfImportConfig::default();

        // Find first .zip file in the library
        if let Ok(entries) = std::fs::read_dir(&gltf_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) == Some("zip") {
                    // Only test small files in unit tests (< 10 MB)
                    if let Ok(meta) = std::fs::metadata(&path) {
                        if meta.len() > 10 * 1024 * 1024 {
                            continue;
                        }
                    }
                    match load_gltf_from_zip(&path, &config) {
                        Ok(report) => {
                            assert!(report.from_zip);
                            assert!(report.total_time_secs >= 0.0);
                            assert!(report.result.stats.mesh_count > 0);
                            assert!(report.result.stats.vertex_count > 0);
                            log::info!(
                                "ZIP import OK: {} — {} meshes, {} verts, {:.3}s",
                                path.display(),
                                report.result.stats.mesh_count,
                                report.result.stats.vertex_count,
                                report.total_time_secs
                            );
                        }
                        Err(e) => {
                            // Some ZIP files may have incompatible glTF content — log but don't fail
                            log::warn!("ZIP import skipped: {} — {}", path.display(), e);
                        }
                    }
                    break; // Test only the first small zip
                }
            }
        }
    }
}
