//! Static mesh renderer hydration from normalized authoring entries.

use avian3d::prelude::ColliderConstructor;
use bevy::light::{NotShadowCaster, NotShadowReceiver};
use bevy::prelude::*;
use bevy::render::render_resource::Face;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};

use crate::{
    asset_server_path, authoring_component_active, standard_material_asset_path,
    AuthoringComponentStates, ColliderDesc, ColliderShapeDesc, EditorAssetRef,
    HydratedRendererMaterialBinding, InspectorOrder, LevelObject, MaterialAlphaMode, MaterialDesc,
    MaterialOverride, MaterialRef, StaticMeshRenderer, StaticMeshRendererEntry,
    COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_STATIC_MESH_RENDERER,
};

use super::materials::material_from_desc;

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

/// Runtime marker for mesh children generated from [`StaticMeshRenderer`].
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
#[reflect(Component, Default, Debug)]
pub struct HydratedStaticMeshPart;

#[derive(Debug, Clone)]
struct ResolvedStaticMeshPart {
    source_path: String,
    mesh_label: String,
    material_label: Option<String>,
}

#[derive(Debug, Clone)]
struct ResolvedStaticMeshMaterial {
    source_path: String,
    material_label: String,
}

#[derive(Debug, Clone)]
pub(crate) struct ResolvedRendererDraw {
    pub mesh_label: String,
    pub source_node: Option<String>,
}

/// Cache for generated static mesh artifact lookups.
#[derive(Resource, Default)]
pub struct StaticMeshArtifactCache {
    parts: HashMap<(String, String), ResolvedStaticMeshPart>,
    materials: HashMap<(String, String), ResolvedStaticMeshMaterial>,
    draw_bindings: HashMap<(String, String), ResolvedRendererDraw>,
    loaded_assets: HashSet<String>,
    failed_assets: HashSet<String>,
    project_materials: HashMap<MaterialRef, Handle<StandardMaterial>>,
}

impl StaticMeshArtifactCache {
    pub(crate) fn resolve_draw_binding(
        &mut self,
        asset_id: &str,
        slot_id: &crate::ComponentInstanceId,
    ) -> Option<ResolvedRendererDraw> {
        let key = (asset_id.to_string(), slot_id.0.clone());
        if let Some(binding) = self.draw_bindings.get(&key) {
            return Some(binding.clone());
        }
        if !self.loaded_assets.contains(asset_id) && !self.failed_assets.contains(asset_id) {
            self.load_artifact(asset_id);
        }
        self.draw_bindings.get(&key).cloned()
    }

    fn resolve(&mut self, mesh: &EditorAssetRef) -> Option<ResolvedStaticMeshPart> {
        if !mesh.is_resolved() {
            return None;
        }
        let key = (mesh.asset_id.clone(), mesh.sub_asset_id.clone());
        if let Some(part) = self.parts.get(&key) {
            return Some(part.clone());
        }
        if !self.loaded_assets.contains(&mesh.asset_id)
            && !self.failed_assets.contains(&mesh.asset_id)
        {
            self.load_artifact(&mesh.asset_id);
        }
        self.parts.get(&key).cloned()
    }

    fn resolve_material(
        &mut self,
        material: &EditorAssetRef,
    ) -> Option<ResolvedStaticMeshMaterial> {
        if !material.is_resolved() {
            return None;
        }
        let key = (material.asset_id.clone(), material.sub_asset_id.clone());
        if let Some(material) = self.materials.get(&key) {
            return Some(material.clone());
        }
        if !self.loaded_assets.contains(&material.asset_id)
            && !self.failed_assets.contains(&material.asset_id)
        {
            self.load_artifact(&material.asset_id);
        }
        self.materials.get(&key).cloned()
    }

    fn load_artifact(&mut self, asset_id: &str) {
        let path = static_mesh_manifest_path(asset_id);
        match std::fs::read_to_string(&path)
            .map_err(|err| format!("could not read {path}: {err}"))
            .and_then(|text| {
                ron::from_str::<StaticMeshResolveManifest>(&text)
                    .map_err(|err| format!("could not parse {path}: {err}"))
            }) {
            Ok(manifest) => {
                for part in manifest.parts {
                    let part_id = if part.id.trim().is_empty() {
                        part_id_from_label(&part.mesh_label)
                    } else {
                        part.id.clone()
                    };
                    let slot_id = format!("slot:{part_id}");
                    self.draw_bindings.insert(
                        (asset_id.to_string(), slot_id),
                        ResolvedRendererDraw {
                            mesh_label: part.mesh_label.clone(),
                            source_node: part.source_node.clone(),
                        },
                    );
                    if let Some(material_label) = part.material_label.clone() {
                        let material_id = part
                            .material_id
                            .clone()
                            .filter(|id| !id.trim().is_empty())
                            .unwrap_or_else(|| material_id_from_label(&material_label));
                        self.materials
                            .entry((asset_id.to_string(), material_id))
                            .or_insert(ResolvedStaticMeshMaterial {
                                source_path: manifest.source.path.clone(),
                                material_label,
                            });
                    }
                    if part.skinned
                        || manifest.metadata.animation_count > 0
                        || manifest.metadata.skin_count > 0
                    {
                        warn!(
                            "Static mesh artifact part {} requires an animated/skinned hierarchy and was excluded from StaticMeshRenderer hydration",
                            part.mesh_label
                        );
                        continue;
                    }
                    self.parts.insert(
                        (asset_id.to_string(), part_id),
                        ResolvedStaticMeshPart {
                            source_path: manifest.source.path.clone(),
                            mesh_label: part.mesh_label.clone(),
                            material_label: part.material_label.clone(),
                        },
                    );
                }
                self.loaded_assets.insert(asset_id.to_string());
            }
            Err(error) => {
                warn!("Static mesh artifact lookup failed for asset {asset_id}: {error}");
                self.failed_assets.insert(asset_id.to_string());
            }
        }
    }
}

#[derive(Debug, Deserialize)]
struct StaticMeshResolveManifest {
    source: StaticMeshResolveSource,
    #[serde(default)]
    metadata: StaticMeshResolveMetadata,
    #[serde(default)]
    parts: Vec<StaticMeshResolvePart>,
}

#[derive(Debug, Default, Deserialize)]
struct StaticMeshResolveMetadata {
    #[serde(default)]
    animation_count: usize,
    #[serde(default)]
    skin_count: usize,
}

#[derive(Debug, Deserialize)]
struct StaticMeshResolveSource {
    path: String,
}

#[derive(Debug, Deserialize)]
struct StaticMeshResolvePart {
    #[serde(default)]
    id: String,
    mesh_label: String,
    #[serde(default)]
    material_id: Option<String>,
    #[serde(default)]
    material_label: Option<String>,
    #[serde(default)]
    source_node: Option<String>,
    #[serde(default)]
    skinned: bool,
}

#[allow(clippy::type_complexity)]
pub fn hydrate_static_mesh_renderers(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut artifact_cache: ResMut<StaticMeshArtifactCache>,
    renderers: Query<
        (
            Entity,
            &StaticMeshRenderer,
            Option<&MaterialDesc>,
            Option<&MaterialOverride>,
            Option<&ColliderDesc>,
            Option<&AuthoringComponentStates>,
            Option<&InspectorOrder>,
        ),
        (
            With<LevelObject>,
            Or<(
                Added<StaticMeshRenderer>,
                Changed<StaticMeshRenderer>,
                Changed<MaterialDesc>,
                Changed<MaterialOverride>,
                Changed<ColliderDesc>,
                Changed<AuthoringComponentStates>,
                Changed<InspectorOrder>,
            )>,
        ),
    >,
    children: Query<&Children>,
    generated_parts: Query<(), With<HydratedStaticMeshPart>>,
) {
    for (entity, renderer, parent_material, material_override, collider, states, order) in
        &renderers
    {
        despawn_static_mesh_parts(&mut commands, entity, &children, &generated_parts);
        if !authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER) {
            continue;
        }
        let parent_material = parent_material
            .filter(|_| authoring_component_active(states, order, COMPONENT_MATERIAL_DESC));
        let collider =
            collider.filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC));
        spawn_static_mesh_parts(
            &mut commands,
            &asset_server,
            &mut materials,
            &mut artifact_cache,
            entity,
            renderer,
            parent_material,
            material_override,
            collider,
        );
    }
}

#[expect(
    clippy::too_many_arguments,
    reason = "hydration keeps Bevy asset stores and authored mesh inputs explicit"
)]
pub fn spawn_static_mesh_parts(
    commands: &mut Commands,
    asset_server: &AssetServer,
    materials: &mut Assets<StandardMaterial>,
    artifact_cache: &mut StaticMeshArtifactCache,
    parent: Entity,
    renderer: &StaticMeshRenderer,
    parent_material: Option<&MaterialDesc>,
    material_override: Option<&MaterialOverride>,
    collider: Option<&ColliderDesc>,
) {
    let mut spawned = 0usize;
    for (index, entry) in renderer.slots.iter().enumerate() {
        if !entry.visible {
            continue;
        }
        let Some(resolved) = artifact_cache.resolve(&entry.mesh) else {
            warn!(
                "Static mesh renderer slot skipped: unresolved mesh asset ref asset_id='{}' part='{}' label='{}'",
                entry.mesh.asset_id, entry.mesh.sub_asset_id, entry.mesh.label
            );
            continue;
        };

        let source_path = asset_server_path(&resolved.source_path);
        let mesh_handle: Handle<Mesh> =
            asset_server.load(labeled_asset_path(&source_path, &resolved.mesh_label));
        let material_handle = material_for_entry(
            asset_server,
            materials,
            artifact_cache,
            renderer,
            entry,
            &resolved,
            parent_material,
            material_override,
        );
        let name = if entry.name.trim().is_empty() {
            format!("Static Mesh Part {index}")
        } else {
            entry.name.clone()
        };

        let mut part = commands.spawn((
            HydratedStaticMeshPart,
            Name::new(name),
            Mesh3d(mesh_handle),
            MeshMaterial3d(material_handle),
            HydratedRendererMaterialBinding {
                owner: parent,
                slot_id: effective_material_slot_id(entry),
                effective_material: effective_material_ref(renderer, entry).cloned(),
            },
            entry.local_transform,
            Visibility::Visible,
            ChildOf(parent),
        ));

        if !entry.cast_shadows {
            part.insert(NotShadowCaster);
        }
        if !entry.receive_shadows {
            part.insert(NotShadowReceiver);
        }
        if should_attach_static_mesh_collider(collider, &entry.mesh) {
            part.insert(ColliderConstructor::TrimeshFromMesh);
        }
        spawned += 1;
    }

    debug!(
        "Hydrated static mesh renderer: parent={parent:?} slots={} spawned={spawned}",
        renderer.slots.len()
    );
}

pub fn despawn_static_mesh_parts(
    commands: &mut Commands,
    entity: Entity,
    children: &Query<&Children>,
    generated_parts: &Query<(), With<HydratedStaticMeshPart>>,
) {
    let Ok(child_list) = children.get(entity) else {
        return;
    };
    for child in child_list.iter() {
        if generated_parts.get(child).is_ok() {
            commands.entity(child).despawn();
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn material_for_entry(
    asset_server: &AssetServer,
    materials: &mut Assets<StandardMaterial>,
    artifact_cache: &mut StaticMeshArtifactCache,
    renderer: &StaticMeshRenderer,
    entry: &StaticMeshRendererEntry,
    resolved: &ResolvedStaticMeshPart,
    parent_material: Option<&MaterialDesc>,
    material_override: Option<&MaterialOverride>,
) -> Handle<StandardMaterial> {
    if let Some(parent_material) = parent_material {
        return materials.add(material_from_desc(asset_server, parent_material));
    }

    if let Some(override_material) = material_override.and_then(|overrides| {
        overrides
            .slots
            .iter()
            .find(|slot| slot.slot_id == entry.id)
            .map(|slot| &slot.material)
    }) {
        return materials.add(material_from_desc(asset_server, override_material));
    }

    if let Some(material_ref) = effective_material_ref(renderer, entry) {
        if let Some(handle) =
            resolve_shared_material(asset_server, materials, artifact_cache, material_ref)
        {
            return handle;
        }
        if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) {
            let source_path = asset_server_path(&resolved_material.source_path);
            return asset_server.load(standard_material_asset_path(
                &source_path,
                &resolved_material.material_label,
            ));
        }
        debug!(
            "Static mesh material ref unresolved: asset_id='{}' material='{}' label='{}'",
            material_ref.0.asset_id, material_ref.0.sub_asset_id, material_ref.0.label
        );
    }

    // Read-only compatibility path for schema-v1/v2 scenes. The explicit project upgrader moves
    // this reference into `RendererMaterialSet` and clears the legacy draw-part field.
    if let Some(material_ref) = entry.material.as_ref() {
        let material_ref = MaterialRef::new(material_ref.clone());
        if let Some(handle) =
            resolve_shared_material(asset_server, materials, artifact_cache, &material_ref)
        {
            return handle;
        }
        if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) {
            let source_path = asset_server_path(&resolved_material.source_path);
            return asset_server.load(standard_material_asset_path(
                &source_path,
                &resolved_material.material_label,
            ));
        }
    }

    if let Some(label) = resolved.material_label.as_deref() {
        let source_path = asset_server_path(&resolved.source_path);
        return asset_server.load(standard_material_asset_path(&source_path, label));
    }

    materials.add(error_material())
}

fn effective_material_slot_id(entry: &StaticMeshRendererEntry) -> crate::ComponentInstanceId {
    if entry.material_slot_id.0.trim().is_empty() {
        entry.id.clone()
    } else {
        entry.material_slot_id.clone()
    }
}

fn effective_material_ref<'a>(
    renderer: &'a StaticMeshRenderer,
    entry: &'a StaticMeshRendererEntry,
) -> Option<&'a MaterialRef> {
    let slot_id = effective_material_slot_id(entry);
    renderer.materials.effective_material(&slot_id)
}

fn resolve_shared_material(
    asset_server: &AssetServer,
    materials: &mut Assets<StandardMaterial>,
    cache: &mut StaticMeshArtifactCache,
    reference: &MaterialRef,
) -> Option<Handle<StandardMaterial>> {
    if let Some(handle) = cache.project_materials.get(reference) {
        return Some(handle.clone());
    }
    let path = reference.0.source_path.as_deref()?;
    let desc_and_state = crate::load_resolved_material_from_path(path).ok()?;
    let mut material = material_from_desc(asset_server, &desc_and_state.0);
    material.alpha_mode = match desc_and_state.1.alpha_mode {
        MaterialAlphaMode::Opaque => AlphaMode::Opaque,
        MaterialAlphaMode::Cutout => AlphaMode::Mask(desc_and_state.1.alpha_cutoff),
    };
    material.cull_mode = (!desc_and_state.1.double_sided).then_some(Face::Back);
    let handle = materials.add(material);
    cache
        .project_materials
        .insert(reference.clone(), handle.clone());
    Some(handle)
}

pub(crate) fn resolve_renderer_material_handle(
    asset_server: &AssetServer,
    materials: &mut Assets<StandardMaterial>,
    cache: &mut StaticMeshArtifactCache,
    reference: &MaterialRef,
) -> Option<Handle<StandardMaterial>> {
    if let Some(handle) = resolve_shared_material(asset_server, materials, cache, reference) {
        return Some(handle);
    }
    let resolved = cache.resolve_material(&reference.0)?;
    let source_path = asset_server_path(&resolved.source_path);
    Some(asset_server.load(standard_material_asset_path(
        &source_path,
        &resolved.material_label,
    )))
}

pub(crate) fn renderer_error_material(
    materials: &mut Assets<StandardMaterial>,
) -> Handle<StandardMaterial> {
    materials.add(error_material())
}

fn error_material() -> StandardMaterial {
    StandardMaterial {
        base_color: Color::srgb(1.0, 0.0, 1.0),
        perceptual_roughness: 0.35,
        metallic: 0.0,
        ..default()
    }
}

fn labeled_asset_path(path: &str, label: &str) -> String {
    format!("{path}#{label}")
}

fn should_attach_static_mesh_collider(
    collider: Option<&ColliderDesc>,
    mesh: &EditorAssetRef,
) -> bool {
    let Some(collider) = collider else {
        return false;
    };
    if !collider.enabled {
        return false;
    }
    let ColliderShapeDesc::StaticMesh { meshes, .. } = &collider.shape else {
        return false;
    };
    meshes.iter().any(|candidate| candidate == mesh)
}

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

pub(super) fn static_mesh_source_path(asset_id: &str) -> Result<String, String> {
    let path = static_mesh_manifest_path(asset_id);
    let text = std::fs::read_to_string(&path)
        .map_err(|error| format!("could not read {path}: {error}"))?;
    let manifest: StaticMeshResolveManifest =
        ron::from_str(&text).map_err(|error| format!("could not parse {path}: {error}"))?;
    if manifest.source.path.trim().is_empty() {
        return Err(format!("static mesh manifest {path} has no source path"));
    }
    Ok(manifest.source.path)
}

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

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

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()
}

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

    struct TestManifest(PathBuf);

    impl Drop for TestManifest {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(&self.0);
        }
    }

    fn write_manifest(asset_id: &str, animation_count: usize, skinned: bool) -> TestManifest {
        let path = PathBuf::from(static_mesh_manifest_path(asset_id));
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            &path,
            format!(
                r#"(
                    source: (path: "assets/models/test.glb"),
                    metadata: (animation_count: {animation_count}),
                    parts: [(
                        id: "mesh:test",
                        mesh_label: "Mesh0/Primitive0",
                        skinned: {skinned},
                    )],
                )"#
            ),
        )
        .unwrap();
        TestManifest(path)
    }

    #[test]
    fn cache_refuses_skin_bound_parts() {
        const ASSET_ID: &str = "7cd29974-1503-4084-b4a0-7483d0a57d7d";
        let _manifest = write_manifest(ASSET_ID, 0, true);
        let mut cache = StaticMeshArtifactCache::default();
        let reference = EditorAssetRef::new(ASSET_ID, "mesh:test", "Test");

        assert!(cache.resolve(&reference).is_none());
    }

    #[test]
    fn cache_refuses_geometry_from_animated_sources() {
        const ASSET_ID: &str = "7f6fcf6a-e978-4dc6-8738-16d52fc3ed03";
        let _manifest = write_manifest(ASSET_ID, 1, false);
        let mut cache = StaticMeshArtifactCache::default();
        let reference = EditorAssetRef::new(ASSET_ID, "mesh:test", "Test");

        assert!(cache.resolve(&reference).is_none());
    }
}
