//! Normalized static mesh artifacts generated from model source files.

use std::fs;
use std::path::Path;

use bevy::gltf::GltfAssetLabel;
use bevy::prelude::*;
use bevy_ufbx::label::FbxAssetLabel;
use bevy_ufbx::mesh::group_faces_by_material;
use bevy_ufbx::texture::external_texture_paths;
use bevy_ufbx::utils::convert_matrix;
use serde::{Deserialize, Serialize};

use shared::{
    AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelMaterialSelection,
    ModelPlacementMode,
};
use shared::{
    AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialSlot, MaterialSlotSet,
    ModelMaterialSlotSelection, OrphanedModelMaterialSelection, StaticMeshRenderer,
    StaticMeshRendererEntry,
};

use crate::fingerprint::{fingerprint_file, write_pretty_ron_if_changed};

pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4;
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshManifest {
    pub schema_version: u32,
    pub asset_id: String,
    pub label: String,
    pub source: StaticMeshSource,
    pub import: StaticMeshImportSnapshot,
    pub metadata: StaticMeshMetadata,
    pub parts: Vec<StaticMeshPart>,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshSource {
    pub path: String,
    pub format: String,
    pub fingerprint: StaticMeshSourceFingerprint,
    pub dependencies: Vec<String>,
}

pub type StaticMeshSourceFingerprint = AssetSourceFingerprint;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshImportSnapshot {
    pub scale: f32,
    pub generate_collider: bool,
    pub lod0_only: bool,
    pub placement_mode: ModelPlacementMode,
    pub hierarchy_mode: ModelHierarchyMode,
    #[serde(default, skip_serializing_if = "material_policy_is_default")]
    pub material_policy: MaterialImportPolicy,
    #[serde(default)]
    pub material_slots: Vec<shared::ModelMaterialSlotSelection>,
    #[serde(default)]
    pub orphaned_material_slots: Vec<shared::OrphanedModelMaterialSelection>,
}

fn material_policy_is_default(policy: &MaterialImportPolicy) -> bool {
    *policy == MaterialImportPolicy::SourceMaterials
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshMetadata {
    pub mesh_count: usize,
    pub material_count: usize,
    pub node_count: usize,
    pub animation_count: usize,
    pub skin_count: usize,
    pub light_count: usize,
    pub camera_count: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshPart {
    #[serde(default)]
    pub id: String,
    pub name: String,
    pub mesh_label: String,
    #[serde(default)]
    pub material_id: Option<String>,
    pub material_slot_name: String,
    pub material_label: Option<String>,
    pub local_transform: Transform,
    pub source_node: Option<String>,
    pub source_mesh: Option<String>,
    pub source_material: Option<String>,
    /// Whether the source primitive is bound to a skin and therefore must never become a static
    /// renderer slot.
    #[serde(default)]
    pub skinned: bool,
}

pub fn static_mesh_manifest_path(asset_id: &str) -> String {
    format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron")
}

pub fn part_id_from_label(label: &str) -> String {
    format!("mesh:{}", stable_sub_asset_slug(label))
}

pub fn material_id_from_label(label: &str) -> String {
    format!("material:{}", stable_sub_asset_slug(label))
}

fn gltf_draw_id(node_index: Option<usize>, mesh_index: usize, primitive_index: usize) -> String {
    let node = node_index
        .map(|index| index.to_string())
        .unwrap_or_else(|| "unbound".into());
    format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}")
}

fn fbx_draw_id(node_index: usize, material_index: usize) -> String {
    format!("draw:scene0:node{node_index}:material{material_index}")
}

fn stable_sub_asset_slug(label: &str) -> String {
    let mut slug = String::new();
    for ch in label.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
        } else if !slug.ends_with('_') {
            slug.push('_');
        }
    }
    slug.trim_matches('_').to_string()
}

pub fn refresh_static_mesh_artifact(
    record: &mut AssetRecord,
) -> Result<StaticMeshManifest, String> {
    let (manifest, path) = plan_static_mesh_artifact(record)?;

    if write_pretty_ron_if_changed(&path, &manifest)
        .map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))?
    {
        info!(
            "Static mesh manifest refreshed: source={} artifact={} parts={}",
            record.path,
            path,
            manifest.parts.len()
        );
    }

    Ok(manifest)
}

pub fn plan_static_mesh_artifact(
    record: &mut AssetRecord,
) -> Result<(StaticMeshManifest, String), String> {
    let mut manifest = build_static_mesh_manifest(record)?;
    reconcile_model_material_slots(&manifest, record.model_import_mut());
    manifest.import.material_policy = MaterialImportPolicy::SourceMaterials;
    manifest.import.material_slots = record.model_import().material_slots.clone();
    manifest.import.orphaned_material_slots = record.model_import().orphaned_material_slots.clone();
    let path = static_mesh_manifest_path(&record.id.as_string());
    record.model_import_mut().static_mesh_manifest_path = Some(path.clone());
    manifest.source.dependencies.sort();
    manifest.source.dependencies.dedup();
    record.dependencies = manifest.source.dependencies.clone();
    Ok((manifest, path))
}

pub fn load_static_mesh_manifest(path: &str) -> Result<StaticMeshManifest, String> {
    let text = fs::read_to_string(path).map_err(|err| format!("could not read {path}: {err}"))?;
    ron::from_str(&text).map_err(|err| format!("could not parse {path}: {err}"))
}

pub fn renderer_from_manifest(
    manifest: &StaticMeshManifest,
    _settings: &ImportSettings,
) -> StaticMeshRenderer {
    let parts: Vec<_> = manifest
        .parts
        .iter()
        .filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
        .map(|part| StaticMeshRendererEntry {
            id: ComponentInstanceId::new(part_effective_id(part)),
            name: part.name.clone(),
            mesh: EditorAssetRef::new(
                manifest.asset_id.clone(),
                part_effective_id(part),
                part.name.clone(),
            ),
            material_slot_id: ComponentInstanceId::new(material_slot_id(part)),
            local_transform: part.local_transform,
            visible: true,
            cast_shadows: true,
            receive_shadows: true,
        })
        .collect();
    let materials = MaterialSlotSet {
        slots: manifest
            .parts
            .iter()
            .filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
            .map(|part| MaterialSlot {
                id: ComponentInstanceId::new(material_slot_id(part)),
                name: part.material_slot_name.clone(),
                material: None,
            })
            .collect(),
        orphaned_assignments: Vec::new(),
    };

    StaticMeshRenderer {
        slots: parts,
        materials,
    }
}

/// Builds the shared material slots for a full imported hierarchy. Unlike static renderer
/// construction this intentionally includes skin-bound and rigid animated draw bindings.
pub fn renderer_materials_from_manifest(
    manifest: &StaticMeshManifest,
    _settings: &ImportSettings,
) -> MaterialSlotSet {
    MaterialSlotSet {
        slots: manifest
            .parts
            .iter()
            .map(|part| MaterialSlot {
                id: ComponentInstanceId::new(material_slot_id(part)),
                name: part.material_slot_name.clone(),
                material: None,
            })
            .collect(),
        orphaned_assignments: Vec::new(),
    }
}

pub fn material_selection<'a>(
    settings: &'a ImportSettings,
    slot_id: &str,
) -> &'a ModelMaterialSelection {
    settings
        .material_slots
        .iter()
        .find(|entry| entry.slot_id.0 == slot_id)
        .map(|entry| &entry.selection)
        .unwrap_or(&ModelMaterialSelection::Source)
}

fn reconcile_model_material_slots(manifest: &StaticMeshManifest, settings: &mut ImportSettings) {
    let names = manifest
        .parts
        .iter()
        .map(|part| (material_slot_id(part), part.material_slot_name.clone()))
        .collect::<std::collections::BTreeMap<_, _>>();
    if settings.material_policy == MaterialImportPolicy::AuthoringOverride {
        for slot_id in names.keys() {
            if !settings
                .material_slots
                .iter()
                .any(|selection| selection.slot_id.0 == *slot_id)
            {
                settings.material_slots.push(ModelMaterialSlotSelection {
                    slot_id: ComponentInstanceId::new(slot_id.clone()),
                    selection: ModelMaterialSelection::Default,
                });
            }
        }
    }
    settings.material_policy = MaterialImportPolicy::SourceMaterials;
    let mut retained = Vec::with_capacity(settings.material_slots.len());
    for selection in settings.material_slots.drain(..) {
        if names.contains_key(&selection.slot_id.0) {
            retained.push(selection);
        } else if let ModelMaterialSelection::Project(material) = selection.selection {
            settings
                .orphaned_material_slots
                .push(OrphanedModelMaterialSelection {
                    slot_id: selection.slot_id,
                    last_known_name: "Removed imported material slot".into(),
                    material,
                });
        }
    }
    retained.sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0));
    settings.material_slots = retained;
    settings
        .orphaned_material_slots
        .retain(|orphan| !names.contains_key(&orphan.slot_id.0));
    settings
        .orphaned_material_slots
        .sort_by(|left, right| left.slot_id.0.cmp(&right.slot_id.0));
}

fn material_slot_id(part: &StaticMeshPart) -> String {
    format!("slot:{}", part_effective_id(part))
}

fn part_effective_id(part: &StaticMeshPart) -> String {
    if part.id.trim().is_empty() {
        part_id_from_label(&part.mesh_label)
    } else {
        part.id.clone()
    }
}

fn build_static_mesh_manifest(record: &AssetRecord) -> Result<StaticMeshManifest, String> {
    let format = source_format(&record.path)?;
    let fingerprint = fingerprint_file(&record.path)?;
    let import = StaticMeshImportSnapshot {
        scale: record.model_import().scale,
        generate_collider: record.model_import().generate_collider,
        lod0_only: record.model_import().lod0_only,
        placement_mode: record.model_import().placement_mode,
        hierarchy_mode: record.model_import().hierarchy_mode,
        material_policy: MaterialImportPolicy::SourceMaterials,
        material_slots: record.model_import().material_slots.clone(),
        orphaned_material_slots: record.model_import().orphaned_material_slots.clone(),
    };

    let mut manifest = match format.as_str() {
        "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, import)?,
        "fbx" => build_fbx_manifest(record, format, fingerprint, import)?,
        _ => return Err(format!("unsupported static mesh source format `{format}`")),
    };

    if manifest.parts.is_empty() {
        manifest
            .warnings
            .push("No renderable static mesh parts were found.".into());
    }

    Ok(manifest)
}

fn build_gltf_manifest(
    record: &AssetRecord,
    format: String,
    fingerprint: StaticMeshSourceFingerprint,
    import: StaticMeshImportSnapshot,
) -> Result<StaticMeshManifest, String> {
    let gltf = gltf::Gltf::open(&record.path)
        .map_err(|err| format!("could not parse glTF {}: {err}", record.path))?;
    let mut parts = Vec::new();
    let mut dependencies = Vec::new();
    let mut warnings = Vec::new();

    for buffer in gltf.document.buffers() {
        if let gltf::buffer::Source::Uri(uri) = buffer.source() {
            dependencies.push(resolve_dependency(&record.path, uri));
        }
    }
    for image in gltf.document.images() {
        if let gltf::image::Source::Uri { uri, .. } = image.source() {
            dependencies.push(resolve_dependency(&record.path, uri));
        }
    }

    if let Some(scene) = gltf
        .document
        .default_scene()
        .or_else(|| gltf.document.scenes().next())
    {
        for node in scene.nodes() {
            collect_gltf_node_parts(node, Mat4::IDENTITY, "", &mut parts);
        }
    } else {
        for mesh in gltf.document.meshes() {
            collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts);
        }
    }

    if gltf.document.animations().count() > 0 {
        warnings.push(
            "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
                .into(),
        );
    }
    if gltf.document.skins().count() > 0 {
        warnings.push(
            "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
                .into(),
        );
    }

    parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name)));

    Ok(StaticMeshManifest {
        schema_version: STATIC_MESH_MANIFEST_SCHEMA,
        asset_id: record.id.as_string(),
        label: record.label.clone(),
        source: StaticMeshSource {
            path: record.path.clone(),
            format,
            fingerprint,
            dependencies,
        },
        import,
        metadata: StaticMeshMetadata {
            mesh_count: gltf.document.meshes().count(),
            material_count: gltf.document.materials().count(),
            node_count: gltf.document.nodes().count(),
            animation_count: gltf.document.animations().count(),
            skin_count: gltf.document.skins().count(),
            light_count: 0,
            camera_count: gltf.document.cameras().count(),
        },
        parts,
        warnings,
    })
}

fn collect_gltf_node_parts(
    node: gltf::Node<'_>,
    parent_transform: Mat4,
    parent_path: &str,
    parts: &mut Vec<StaticMeshPart>,
) {
    let local = Mat4::from_cols_array_2d(&node.transform().matrix());
    let world_transform = parent_transform * local;
    let segment = node
        .name()
        .map(str::to_string)
        .unwrap_or_else(|| format!("Node{}", node.index()));
    let node_path = if parent_path.is_empty() {
        segment
    } else {
        format!("{parent_path}/{segment}")
    };
    let skinned = node.skin().is_some();
    if let Some(mesh) = node.mesh() {
        collect_gltf_mesh_parts(
            node.name().map(str::to_string),
            Some(node.index()),
            Some(node_path.clone()),
            mesh,
            world_transform,
            skinned,
            parts,
        );
    }
    for child in node.children() {
        collect_gltf_node_parts(child, world_transform, &node_path, parts);
    }
}

fn collect_gltf_mesh_parts(
    node_name: Option<String>,
    node_index: Option<usize>,
    node_path: Option<String>,
    mesh: gltf::Mesh<'_>,
    transform: Mat4,
    skinned: bool,
    parts: &mut Vec<StaticMeshPart>,
) {
    let mesh_index = mesh.index();
    let mesh_name = mesh.name().map(str::to_string);
    for primitive in mesh.primitives() {
        let primitive_index = primitive.index();
        let mesh_label = GltfAssetLabel::Primitive {
            mesh: mesh_index,
            primitive: primitive_index,
        }
        .to_string();
        let material = primitive.material();
        let material_label = material
            .index()
            .map(|index| {
                GltfAssetLabel::Material {
                    index,
                    is_scale_inverted: false,
                }
                .to_string()
            })
            .or_else(|| Some(GltfAssetLabel::DefaultMaterial.to_string()));
        let material_name = material
            .name()
            .map(str::to_string)
            .unwrap_or_else(|| "Default Material".into());
        let name = node_name
            .clone()
            .or_else(|| mesh_name.clone())
            .unwrap_or_else(|| format!("Mesh {mesh_index}"));
        let source_node = node_path
            .clone()
            .or_else(|| node_index.map(|index| format!("Node{index}")));
        parts.push(StaticMeshPart {
            id: gltf_draw_id(node_index, mesh_index, primitive_index),
            name: format!("{name} / Primitive {primitive_index}"),
            material_id: material_label
                .as_ref()
                .map(|label| material_id_from_label(label)),
            mesh_label,
            material_slot_name: material_name.clone(),
            material_label,
            local_transform: Transform::from_matrix(transform),
            source_node,
            source_mesh: Some(format!("Mesh{mesh_index}")),
            source_material: Some(material_name),
            skinned,
        });
    }
}

fn build_fbx_manifest(
    record: &AssetRecord,
    format: String,
    fingerprint: StaticMeshSourceFingerprint,
    import: StaticMeshImportSnapshot,
) -> Result<StaticMeshManifest, String> {
    let bytes =
        fs::read(&record.path).map_err(|err| format!("could not read {}: {err}", record.path))?;
    let scene = ufbx::load_memory(
        &bytes,
        ufbx::LoadOpts {
            target_unit_meters: 1.0,
            target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
            filename: ufbx::StringOpt::Ref(&record.path),
            ..Default::default()
        },
    )
    .map_err(|err| format!("could not parse FBX {}: {err:?}", record.path))?;

    let mut parts = Vec::new();
    let mut warnings = Vec::new();
    for (node_index, node) in scene.nodes.as_ref().iter().enumerate() {
        let Some(mesh_ref) = node.mesh.as_ref() else {
            continue;
        };
        let mesh = mesh_ref.as_ref();
        if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() {
            continue;
        }
        let skinned = !mesh.skin_deformers.as_ref().is_empty();

        let mut groups: Vec<(usize, Vec<u32>)> =
            group_faces_by_material(mesh).into_iter().collect();
        groups.sort_by_key(|(material_index, _)| *material_index);
        for (material_index, indices) in groups {
            if indices.is_empty() {
                continue;
            }
            let material = mesh.materials.get(material_index).map(|mat| mat.as_ref());
            let material_label = material
                .and_then(|mat| fbx_material_label(&scene, mat.element.element_id))
                .or_else(|| Some(FbxAssetLabel::DefaultMaterial.to_string()));
            let material_name = material
                .map(|mat| mat.element.name.to_string())
                .filter(|name| !name.is_empty())
                .unwrap_or_else(|| "Default Material".into());
            let node_name = if node.element.name.is_empty() {
                format!("Node {node_index}")
            } else {
                node.element.name.to_string()
            };
            let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string();
            parts.push(StaticMeshPart {
                id: fbx_draw_id(node_index, material_index),
                name: format!("{node_name} / Material {material_index}"),
                material_id: material_label
                    .as_ref()
                    .map(|label| material_id_from_label(label)),
                mesh_label,
                material_slot_name: material_name.clone(),
                material_label,
                local_transform: Transform::from_matrix(convert_matrix(&node.geometry_to_world)),
                source_node: Some(format!("Node{node_index}")),
                source_mesh: Some(format!("Mesh{node_index}")),
                source_material: Some(material_name),
                skinned,
            });
        }
    }

    if !scene.anim_stacks.as_ref().is_empty() {
        warnings.push(
            "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
                .into(),
        );
    }
    if !scene.skin_deformers.as_ref().is_empty() {
        warnings.push(
            "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
                .into(),
        );
    }

    parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name)));

    Ok(StaticMeshManifest {
        schema_version: STATIC_MESH_MANIFEST_SCHEMA,
        asset_id: record.id.as_string(),
        label: record.label.clone(),
        source: StaticMeshSource {
            path: record.path.clone(),
            format,
            fingerprint,
            dependencies: fbx_dependencies(&record.path, &scene)?,
        },
        import,
        metadata: StaticMeshMetadata {
            mesh_count: scene.meshes.as_ref().len(),
            material_count: scene.materials.as_ref().len(),
            node_count: scene.nodes.as_ref().len(),
            animation_count: scene.anim_stacks.as_ref().len(),
            skin_count: scene.skin_deformers.as_ref().len(),
            light_count: scene.lights.as_ref().len(),
            camera_count: scene.cameras.as_ref().len(),
        },
        parts,
        warnings,
    })
}

fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option<String> {
    if element_id == 0 {
        return None;
    }
    scene
        .materials
        .as_ref()
        .iter()
        .position(|material| material.element.element_id == element_id)
        .map(|index| FbxAssetLabel::Material(index).to_string())
}

fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result<Vec<String>, String> {
    let path = Path::new(source_path);
    let parent = path.parent().unwrap_or_else(|| Path::new(""));
    external_texture_paths(scene)
        .map_err(|errors| {
            format!(
                "unsafe FBX texture reference(s) in {source_path}: {}",
                errors
                    .into_iter()
                    .map(|error| error.to_string())
                    .collect::<Vec<_>>()
                    .join("; ")
            )
        })
        .map(|paths| {
            paths
                .into_iter()
                .map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/"))
                .collect()
        })
}

fn source_format(path: &str) -> Result<String, String> {
    Path::new(path)
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .ok_or_else(|| format!("asset path `{path}` has no extension"))
}

fn resolve_dependency(source_path: &str, uri: &str) -> String {
    if uri.starts_with("data:") || uri.contains("://") {
        return uri.to_string();
    }
    Path::new(source_path)
        .parent()
        .unwrap_or_else(|| Path::new(""))
        .join(uri)
        .to_string_lossy()
        .replace('\\', "/")
}

#[cfg(test)]
mod tests {
    use super::*;
    use shared::{AssetId, MaterialRef};

    fn test_manifest() -> StaticMeshManifest {
        StaticMeshManifest {
            schema_version: STATIC_MESH_MANIFEST_SCHEMA,
            asset_id: "asset-1".into(),
            label: "Crate".into(),
            source: StaticMeshSource {
                path: "assets/models/crate.glb".into(),
                format: "glb".into(),
                fingerprint: StaticMeshSourceFingerprint {
                    byte_len: 42,
                    content_hash: "a".repeat(64),
                },
                dependencies: Vec::new(),
            },
            import: StaticMeshImportSnapshot {
                scale: 1.0,
                generate_collider: true,
                lod0_only: true,
                placement_mode: ModelPlacementMode::StaticAsset,
                hierarchy_mode: ModelHierarchyMode::SingleActor,
                material_policy: MaterialImportPolicy::SourceMaterials,
                material_slots: Vec::new(),
                orphaned_material_slots: Vec::new(),
            },
            metadata: StaticMeshMetadata {
                mesh_count: 1,
                material_count: 1,
                node_count: 1,
                animation_count: 0,
                skin_count: 0,
                light_count: 0,
                camera_count: 0,
            },
            parts: vec![StaticMeshPart {
                id: "mesh:mesh0_primitive0".into(),
                name: "Crate / Primitive 0".into(),
                mesh_label: "Mesh0/Primitive0".into(),
                material_id: Some("material:material0".into()),
                material_slot_name: "Wood".into(),
                material_label: Some("Material0".into()),
                local_transform: Transform::from_xyz(1.0, 2.0, 3.0),
                source_node: Some("Node0".into()),
                source_mesh: Some("Mesh0".into()),
                source_material: Some("Wood".into()),
                skinned: false,
            }],
            warnings: Vec::new(),
        }
    }

    #[test]
    fn static_mesh_manifest_path_uses_asset_id() {
        assert_eq!(
            static_mesh_manifest_path("abc"),
            "assets/meshes/generated/abc.static_mesh.ron"
        );
    }

    #[test]
    fn renderer_from_manifest_preserves_labels_and_collider_policy() {
        let manifest = test_manifest();
        let settings = ImportSettings {
            generate_collider: true,
            ..Default::default()
        };

        let renderer = renderer_from_manifest(&manifest, &settings);

        assert_eq!(renderer.slots.len(), 1);
        let entry = &renderer.slots[0];
        assert_eq!(entry.mesh.asset_id, manifest.asset_id);
        assert_eq!(entry.mesh.sub_asset_id, "mesh:mesh0_primitive0");
        assert!(renderer.materials.slots[0].material.is_none());
    }

    #[test]
    fn model_slot_selection_remains_asset_owned() {
        let manifest = test_manifest();
        let project = MaterialRef::new(
            EditorAssetRef::new("project-material", "", "Paint")
                .with_source_path("assets/Props/paint.material.ron"),
        );
        let mut settings = ImportSettings::default();
        settings
            .material_slots
            .push(shared::ModelMaterialSlotSelection {
                slot_id: ComponentInstanceId::new("slot:mesh:mesh0_primitive0"),
                selection: ModelMaterialSelection::Project(project.clone()),
            });

        let renderer = renderer_from_manifest(&manifest, &settings);
        assert!(renderer.materials.slots[0].material.is_none());
        assert_eq!(
            material_selection(&settings, "slot:mesh:mesh0_primitive0"),
            &ModelMaterialSelection::Project(project)
        );

        settings.material_slots[0].selection = ModelMaterialSelection::Default;
        let renderer = renderer_from_manifest(&manifest, &settings);
        assert!(renderer.materials.slots[0].material.is_none());
        assert_eq!(
            material_selection(&settings, "slot:mesh:mesh0_primitive0"),
            &ModelMaterialSelection::Default
        );
    }

    #[test]
    fn removed_project_selection_becomes_an_orphan() {
        let manifest = test_manifest();
        let material = MaterialRef::new(EditorAssetRef::new("paint", "", "Paint"));
        let mut settings = ImportSettings::default();
        settings
            .material_slots
            .push(shared::ModelMaterialSlotSelection {
                slot_id: ComponentInstanceId::new("slot:removed"),
                selection: ModelMaterialSelection::Project(material.clone()),
            });

        reconcile_model_material_slots(&manifest, &mut settings);

        assert!(settings.material_slots.is_empty());
        assert_eq!(settings.orphaned_material_slots.len(), 1);
        assert_eq!(settings.orphaned_material_slots[0].material, material);
    }

    #[test]
    fn schema_v3_manifest_without_hash_migrates_to_content_fingerprint() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-static-mesh-legacy-{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&root).unwrap();
        let path = root.join("legacy.static_mesh.ron");
        let expected = test_manifest();
        let canonical =
            ron::ser::to_string_pretty(&expected, ron::ser::PrettyConfig::default()).unwrap();
        let legacy = canonical
            .replacen("schema_version: 4", "schema_version: 3", 1)
            .lines()
            .filter(|line| !line.contains("content_hash:"))
            .collect::<Vec<_>>()
            .join("\n");
        fs::write(&path, legacy).unwrap();

        assert!(write_pretty_ron_if_changed(&path, &expected).unwrap());
        assert_eq!(
            load_static_mesh_manifest(&path.to_string_lossy()).unwrap(),
            expected
        );

        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn equivalent_static_manifest_preserves_existing_formatting_and_newline() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-static-mesh-semantic-{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&root).unwrap();
        let path = root.join("stable.static_mesh.ron");
        let manifest = test_manifest();
        let exact = format!(
            "{}\n\n",
            ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap()
        );
        fs::write(&path, &exact).unwrap();

        assert!(!write_pretty_ron_if_changed(&path, &manifest).unwrap());
        assert_eq!(fs::read_to_string(&path).unwrap(), exact);

        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn renderer_from_manifest_can_select_default_per_slot() {
        let manifest = test_manifest();
        let settings = ImportSettings {
            material_slots: vec![ModelMaterialSlotSelection {
                slot_id: ComponentInstanceId::new(format!(
                    "slot:{}",
                    part_effective_id(&manifest.parts[0])
                )),
                selection: ModelMaterialSelection::Default,
            }],
            ..Default::default()
        };

        let renderer = renderer_from_manifest(&manifest, &settings);

        assert!(renderer.materials.slots[0].material.is_none());
        assert_eq!(
            material_selection(&settings, &renderer.materials.slots[0].id.0),
            &ModelMaterialSelection::Default
        );
    }

    #[test]
    fn legacy_whole_model_policy_migrates_to_explicit_default_slots() {
        let manifest = test_manifest();
        let mut settings = ImportSettings {
            material_policy: MaterialImportPolicy::AuthoringOverride,
            ..Default::default()
        };

        reconcile_model_material_slots(&manifest, &mut settings);

        assert_eq!(
            settings.material_policy,
            MaterialImportPolicy::SourceMaterials
        );
        assert_eq!(settings.material_slots.len(), 1);
        assert!(matches!(
            settings.material_slots[0].selection,
            ModelMaterialSelection::Default
        ));
    }

    #[test]
    fn renderer_from_manifest_excludes_skinned_primitives() {
        let mut manifest = test_manifest();
        manifest.parts[0].skinned = true;

        let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());

        assert!(renderer.slots.is_empty());
    }

    #[test]
    fn renderer_from_manifest_excludes_node_animated_geometry() {
        let mut manifest = test_manifest();
        manifest.metadata.animation_count = 1;

        let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());

        assert!(renderer.slots.is_empty());
    }

    #[test]
    fn committed_rigged_fixture_never_builds_static_renderer_slots() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/models/robot_expressive.glb")
            .to_string_lossy()
            .into_owned();
        let record = AssetRecord {
            id: AssetId::new(),
            path,
            label: "Robot Expressive".into(),
            kind: shared::AssetKind::Model,
            source_fingerprint: None,
            import_settings: shared::AssetImportSettings::Model(ImportSettings::default()),
            dependencies: Vec::new(),
        };

        let manifest = build_static_mesh_manifest(&record).unwrap();
        let renderer = renderer_from_manifest(&manifest, record.model_import());

        assert!(manifest.metadata.skin_count > 0);
        assert!(manifest.metadata.animation_count > 0);
        assert!(manifest.parts.iter().any(|part| part.skinned));
        assert!(renderer.slots.is_empty());
    }

    #[test]
    fn committed_fbx_records_sibling_texture_dependencies() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/models/painted_wooden_chair_02_2k.fbx")
            .to_string_lossy()
            .into_owned();
        let record = AssetRecord {
            id: AssetId::new(),
            path,
            label: "Painted Chair".into(),
            kind: shared::AssetKind::Model,
            source_fingerprint: None,
            import_settings: shared::AssetImportSettings::Model(ImportSettings::default()),
            dependencies: Vec::new(),
        };

        let manifest = build_static_mesh_manifest(&record).unwrap();

        assert_eq!(manifest.source.dependencies.len(), 3);
        assert!(manifest
            .source
            .dependencies
            .iter()
            .all(|path| path.contains("assets/models/textures/painted_wooden_chair_02_")));
    }
}
