//! Generated animation artifacts for imported model sources.

use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;

use serde::Deserialize;
use shared::{
    animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationClipRecord,
    AnimationDiagnosticSeverity, AnimationEventDesc, AnimationImportDiagnostic, AnimationManifest,
    AnimationManifestSource, AnimationSkeletonRecord, AnimationSkeletonSignature,
    AnimationSourceFingerprint, ANIMATION_ARTIFACT_DIR, ANIMATION_MANIFEST_SCHEMA_VERSION,
};

use crate::asset_db::AssetRecord;

pub fn animation_manifest_path(asset_id: &str) -> String {
    format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron")
}

pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result<AnimationManifest, String> {
    let mut manifest = build_animation_manifest(record)?;
    manifest.source.dependencies.sort();
    manifest.source.dependencies.dedup();

    let path = animation_manifest_path(&record.id.as_string());
    record.import_settings.animation_manifest_path = Some(path.clone());
    record
        .dependencies
        .extend(manifest.source.dependencies.iter().cloned());
    record.dependencies.sort();
    record.dependencies.dedup();

    let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
        .map_err(|error| format!("could not serialize animation manifest: {error}"))?;
    if let Some(parent) = Path::new(&path).parent() {
        fs::create_dir_all(parent)
            .map_err(|error| format!("could not create {}: {error}", parent.display()))?;
    }
    if fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
        fs::write(&path, text)
            .map_err(|error| format!("could not write animation manifest {path}: {error}"))?;
        bevy::log::info!(
            "Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}",
            record.path,
            path,
            manifest.skeletons.len(),
            manifest.clips.len(),
            manifest.runtime_supported
        );
    }

    Ok(manifest)
}

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

pub fn build_animation_manifest(record: &AssetRecord) -> Result<AnimationManifest, String> {
    let bytes = fs::read(&record.path)
        .map_err(|error| format!("could not read {}: {error}", record.path))?;
    let fingerprint = source_fingerprint(&record.path, &bytes)?;
    let format = source_format(&record.path)?;
    match format.as_str() {
        "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes),
        "fbx" => build_fbx_manifest(record, format, fingerprint, &bytes),
        _ => Err(format!("unsupported animation source format `{format}`")),
    }
}

#[derive(Debug, Clone)]
struct SkeletonCandidate {
    compatible_nodes: BTreeSet<usize>,
    signature: AnimationSkeletonSignature,
}

fn build_gltf_manifest(
    record: &AssetRecord,
    format: String,
    fingerprint: AnimationSourceFingerprint,
    bytes: &[u8],
) -> Result<AnimationManifest, String> {
    let gltf = gltf::Gltf::from_slice(bytes)
        .map_err(|error| format!("could not parse glTF {}: {error}", record.path))?;
    let base = Path::new(&record.path).parent();
    let buffers = gltf::import_buffers(&gltf.document, base, gltf.blob.clone())
        .map_err(|error| format!("could not load glTF buffers for {}: {error}", record.path))?;

    let mut dependencies = gltf
        .document
        .buffers()
        .filter_map(|buffer| match buffer.source() {
            gltf::buffer::Source::Uri(uri) if !uri.starts_with("data:") => {
                Some(resolve_dependency(&record.path, uri))
            }
            _ => None,
        })
        .collect::<Vec<_>>();
    dependencies.sort();
    dependencies.dedup();

    let node_identity_names = gltf
        .document
        .nodes()
        .map(|node| bevy_node_segment(node.name(), node.index()))
        .collect::<Vec<_>>();
    let readable_node_names = gltf
        .document
        .nodes()
        .map(|node| normalized_node_segment(node.name(), node.index()))
        .collect::<Vec<_>>();
    let mut parents = vec![None; node_identity_names.len()];
    for node in gltf.document.nodes() {
        for child in node.children() {
            parents[child.index()] = Some(node.index());
        }
    }

    let mut skeletons = Vec::new();
    let mut candidates = Vec::new();
    for skin in gltf.document.skins() {
        let source_index = skin.index();
        let label = skin
            .name()
            .filter(|name| !name.trim().is_empty())
            .map(str::to_string)
            .unwrap_or_else(|| format!("Skeleton {source_index}"));
        let joint_indices = skin.joints().map(|joint| joint.index()).collect::<Vec<_>>();
        let joint_identity_paths = joint_indices
            .iter()
            .map(|index| node_path_segments(*index, &node_identity_names, &parents))
            .collect::<Vec<_>>();
        let joint_paths = joint_indices
            .iter()
            .map(|index| node_path(*index, &readable_node_names, &parents))
            .collect::<Vec<_>>();
        let bind_poses = skin
            .reader(|buffer| Some(buffers[buffer.index()].0.as_slice()))
            .read_inverse_bind_matrices()
            .map(|matrices| matrices.map(gltf_matrix_bytes).collect::<Vec<_>>())
            .unwrap_or_else(|| vec![identity_matrix_bytes(); joint_paths.len()]);
        let signature = skeleton_signature(&joint_identity_paths, &bind_poses);
        let mut compatible_nodes = joint_indices.into_iter().collect::<BTreeSet<_>>();
        if let Some(root) = skin.skeleton() {
            compatible_nodes.insert(root.index());
        }
        candidates.push(SkeletonCandidate {
            compatible_nodes,
            signature: signature.clone(),
        });
        skeletons.push(AnimationSkeletonRecord {
            id: animation_skeleton_sub_asset_id(source_index, &label),
            label,
            source_index,
            signature,
            joint_paths,
        });
    }

    let mut diagnostics = Vec::new();
    let mut clips = Vec::new();
    let mut animation_roots = BTreeSet::new();
    for animation in gltf.document.animations() {
        let source_index = animation.index();
        let label = animation
            .name()
            .filter(|name| !name.trim().is_empty())
            .map(str::to_string)
            .unwrap_or_else(|| format!("Animation {source_index}"));
        let mut duration_seconds = 0.0_f32;
        let mut target_nodes = BTreeSet::new();
        for channel in animation.channels() {
            let target_index = channel.target().node().index();
            target_nodes.insert(target_index);
            animation_roots.insert(top_level_node(target_index, &parents));
            if let Some(inputs) = channel
                .reader(|buffer| Some(buffers[buffer.index()].0.as_slice()))
                .read_inputs()
            {
                for input in inputs {
                    if input.is_finite() {
                        duration_seconds = duration_seconds.max(input);
                    }
                }
            }
        }
        let target_skeleton_signature = matching_skeleton_signature(&target_nodes, &candidates);
        if !candidates.is_empty() && target_skeleton_signature.is_none() {
            diagnostics.push(AnimationImportDiagnostic {
                severity: AnimationDiagnosticSeverity::Warning,
                code: "animation.clip_skeleton_unresolved".into(),
                message: format!(
                    "clip `{label}` does not target a uniquely identifiable imported skeleton"
                ),
                repair: "Export the clip with channels targeting one skeleton, or split unrelated rigs into separate glTF assets.".into(),
            });
        }
        let events = gltf_animation_events(&animation, duration_seconds, &label, &mut diagnostics);
        clips.push(AnimationClipRecord {
            id: animation_clip_sub_asset_id(source_index, &label),
            label,
            source_index,
            duration_seconds,
            target_skeleton_signature,
            events,
        });
    }

    let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
    validate_default_animation_clip(
        default_animation_clip_id.as_deref(),
        &clips,
        &mut diagnostics,
    );

    let runtime_supported = animation_roots.len() <= 1;
    if !runtime_supported {
        let roots = animation_roots
            .iter()
            .map(|index| format!("`{}` (node {index})", node_identity_names[*index]))
            .collect::<Vec<_>>()
            .join(", ");
        diagnostics.push(AnimationImportDiagnostic {
            severity: AnimationDiagnosticSeverity::Error,
            code: "animation.multiple_roots_unsupported".into(),
            message: format!(
                "animation channels target {} distinct top-level roots ({roots}); Bevy requires a separate AnimationPlayer for each root",
                animation_roots.len()
            ),
            repair: "Split the source into one animated rig per glTF asset, or export every clip under one common top-level root, then reimport.".into(),
        });
    }

    Ok(AnimationManifest {
        schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
        asset_id: record.id.as_string(),
        label: record.label.clone(),
        default_animation_clip_id,
        source: AnimationManifestSource {
            path: record.path.clone(),
            format,
            fingerprint,
            dependencies,
        },
        runtime_supported,
        skeletons,
        clips,
        diagnostics,
    })
}

fn build_fbx_manifest(
    record: &AssetRecord,
    format: String,
    fingerprint: AnimationSourceFingerprint,
    bytes: &[u8],
) -> Result<AnimationManifest, String> {
    let scene = ufbx::load_memory(
        bytes,
        ufbx::LoadOpts {
            target_unit_meters: 1.0,
            target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
            ..Default::default()
        },
    )
    .map_err(|error| format!("could not parse FBX {}: {error:?}", record.path))?;

    let mut skeletons = Vec::new();
    for (source_index, skin) in scene.skin_deformers.as_ref().iter().enumerate() {
        let label = if skin.element.name.is_empty() {
            format!("Skeleton {source_index}")
        } else {
            skin.element.name.to_string()
        };
        let mut joint_identity_paths = Vec::new();
        let mut joint_paths = Vec::new();
        let mut bind_poses = Vec::new();
        for cluster in skin.clusters.as_ref() {
            let Some(bone) = cluster.bone_node.as_ref() else {
                continue;
            };
            let segments = fbx_node_path_segments(bone.as_ref());
            joint_paths.push(segments.join("/"));
            joint_identity_paths.push(segments);
            bind_poses.push(fbx_matrix_bytes(&cluster.bind_to_world));
        }
        let signature = skeleton_signature(&joint_identity_paths, &bind_poses);
        skeletons.push(AnimationSkeletonRecord {
            id: animation_skeleton_sub_asset_id(source_index, &label),
            label,
            source_index,
            signature,
            joint_paths,
        });
    }

    let fallback_signature = (skeletons.len() == 1).then(|| skeletons[0].signature.clone());
    let clips = scene
        .anim_stacks
        .as_ref()
        .iter()
        .enumerate()
        .map(|(source_index, stack)| {
            let label = if stack.element.name.is_empty() {
                format!("Animation {source_index}")
            } else {
                stack.element.name.to_string()
            };
            AnimationClipRecord {
                id: animation_clip_sub_asset_id(source_index, &label),
                label,
                source_index,
                duration_seconds: (stack.time_end - stack.time_begin).max(0.0) as f32,
                target_skeleton_signature: fallback_signature.clone(),
                events: Vec::new(),
            }
        })
        .collect::<Vec<_>>();

    let (runtime_supported, mut diagnostics) = fbx_runtime_support(
        scene.anim_stacks.as_ref().len(),
        scene.skin_deformers.as_ref().len(),
    );
    let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
    validate_default_animation_clip(
        default_animation_clip_id.as_deref(),
        &clips,
        &mut diagnostics,
    );

    Ok(AnimationManifest {
        schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
        asset_id: record.id.as_string(),
        label: record.label.clone(),
        default_animation_clip_id,
        source: AnimationManifestSource {
            path: record.path.clone(),
            format,
            fingerprint,
            dependencies: Vec::new(),
        },
        runtime_supported,
        skeletons,
        clips,
        diagnostics,
    })
}

fn validate_default_animation_clip(
    default_clip_id: Option<&str>,
    clips: &[AnimationClipRecord],
    diagnostics: &mut Vec<AnimationImportDiagnostic>,
) {
    let Some(default_clip_id) = default_clip_id.filter(|id| !id.trim().is_empty()) else {
        return;
    };
    if clips.iter().any(|clip| clip.id == default_clip_id) {
        return;
    }
    diagnostics.push(AnimationImportDiagnostic {
        severity: AnimationDiagnosticSeverity::Error,
        code: "animation.default_clip_missing".into(),
        message: format!(
            "configured default animation clip `{default_clip_id}` no longer exists in the imported source"
        ),
        repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(),
    });
}

fn fbx_runtime_support(
    animation_count: usize,
    skin_count: usize,
) -> (bool, Vec<AnimationImportDiagnostic>) {
    let mut diagnostics = Vec::new();
    if animation_count > 0 || skin_count > 0 {
        diagnostics.push(AnimationImportDiagnostic {
            severity: AnimationDiagnosticSeverity::Error,
            code: "animation.fbx_runtime_unsupported".into(),
            message: format!(
                "FBX contains {animation_count} animation stack(s) and {skin_count} skin deformer(s), but Blacksite's current FBX loader cannot build a runtime skeleton or clips"
            ),
            repair: "Export the animated source as glTF 2.0 (.glb or .gltf). Keep FBX only for static mesh import until hierarchy, SkinnedMesh, and AnimationClip conversion are implemented.".into(),
        });
    }
    (false, diagnostics)
}

#[derive(Debug, Default, Deserialize)]
struct GltfAnimationExtras {
    #[serde(default)]
    blacksite_events: Vec<GltfAnimationEvent>,
}

#[derive(Debug, Deserialize)]
struct GltfAnimationEvent {
    id: String,
    time_seconds: f32,
    #[serde(default)]
    payload: Option<String>,
}

fn gltf_animation_events(
    animation: &gltf::Animation<'_>,
    duration_seconds: f32,
    clip_label: &str,
    diagnostics: &mut Vec<AnimationImportDiagnostic>,
) -> Vec<AnimationEventDesc> {
    let Some(raw) = animation.extras().as_ref() else {
        return Vec::new();
    };
    let extras = match serde_json::from_str::<GltfAnimationExtras>(raw.get()) {
        Ok(extras) => extras,
        Err(error) => {
            diagnostics.push(AnimationImportDiagnostic {
                severity: AnimationDiagnosticSeverity::Warning,
                code: "animation.events_invalid".into(),
                message: format!("clip `{clip_label}` has invalid animation event metadata: {error}"),
                repair: "Use an animation extras object with `blacksite_events` entries containing `id`, `time_seconds`, and optional string `payload`.".into(),
            });
            return Vec::new();
        }
    };

    let mut events = Vec::new();
    for event in extras.blacksite_events {
        let valid_time = event.time_seconds.is_finite()
            && event.time_seconds >= 0.0
            && event.time_seconds <= duration_seconds;
        if event.id.trim().is_empty() || !valid_time {
            diagnostics.push(AnimationImportDiagnostic {
                severity: AnimationDiagnosticSeverity::Warning,
                code: "animation.event_out_of_range".into(),
                message: format!(
                    "clip `{clip_label}` contains an event with an empty ID or time outside 0..={duration_seconds:.3}s"
                ),
                repair: "Give every event a stable non-empty ID and place it within the imported clip duration.".into(),
            });
            continue;
        }
        events.push(AnimationEventDesc {
            id: event.id,
            time_seconds: event.time_seconds,
            payload: event.payload,
        });
    }
    events.sort_by(|left, right| {
        left.time_seconds
            .total_cmp(&right.time_seconds)
            .then(left.id.cmp(&right.id))
            .then(left.payload.cmp(&right.payload))
    });
    events
}

fn matching_skeleton_signature(
    target_nodes: &BTreeSet<usize>,
    candidates: &[SkeletonCandidate],
) -> Option<AnimationSkeletonSignature> {
    let scored = candidates
        .iter()
        .map(|candidate| {
            (
                candidate
                    .compatible_nodes
                    .intersection(target_nodes)
                    .count(),
                candidate,
            )
        })
        .filter(|(score, _)| *score > 0)
        .collect::<Vec<_>>();
    let best_score = scored.iter().map(|(score, _)| *score).max()?;
    let mut best = scored
        .into_iter()
        .filter(|(score, _)| *score == best_score)
        .map(|(_, candidate)| candidate);
    let candidate = best.next()?;
    best.all(|other| other.signature == candidate.signature)
        .then(|| candidate.signature.clone())
}

fn node_path(index: usize, names: &[String], parents: &[Option<usize>]) -> String {
    node_path_segments(index, names, parents).join("/")
}

fn node_path_segments(index: usize, names: &[String], parents: &[Option<usize>]) -> Vec<String> {
    let mut segments = Vec::new();
    let mut current = Some(index);
    while let Some(node_index) = current {
        segments.push(names[node_index].clone());
        current = parents[node_index];
    }
    segments.reverse();
    segments
}

fn top_level_node(index: usize, parents: &[Option<usize>]) -> usize {
    let mut current = index;
    while let Some(parent) = parents[current] {
        current = parent;
    }
    current
}

fn bevy_node_segment(name: Option<&str>, index: usize) -> String {
    name.map(str::to_string)
        .unwrap_or_else(|| format!("GltfNode{index}"))
}

fn normalized_node_segment(name: Option<&str>, index: usize) -> String {
    name.map(str::trim)
        .filter(|name| !name.is_empty())
        .map(|name| name.replace(['/', '\\'], "_"))
        .unwrap_or_else(|| format!("Node{index}"))
}

fn fbx_node_path_segments(node: &ufbx::Node) -> Vec<String> {
    let mut segments = Vec::new();
    let mut current = Some(node);
    while let Some(node) = current {
        segments.push(normalized_node_segment(
            (!node.element.name.is_empty()).then(|| node.element.name.as_ref()),
            node.element.element_id as usize,
        ));
        current = node.parent.as_ref().map(|parent| parent.as_ref());
    }
    segments.reverse();
    segments
}

fn skeleton_signature(
    joint_path_segments: &[Vec<String>],
    bind_pose_bytes: &[Vec<u8>],
) -> AnimationSkeletonSignature {
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"blacksite-animation-skeleton-v2\0");
    hasher.update(&(joint_path_segments.len() as u64).to_le_bytes());
    for path in joint_path_segments {
        hasher.update(&(path.len() as u64).to_le_bytes());
        for segment in path {
            let bytes = segment.as_bytes();
            hasher.update(&(bytes.len() as u64).to_le_bytes());
            hasher.update(bytes);
        }
    }
    hasher.update(&(bind_pose_bytes.len() as u64).to_le_bytes());
    for bytes in bind_pose_bytes {
        hasher.update(&(bytes.len() as u64).to_le_bytes());
        hasher.update(bytes);
    }
    AnimationSkeletonSignature::new(hasher.finalize().to_hex().to_string())
}

fn gltf_matrix_bytes(matrix: [[f32; 4]; 4]) -> Vec<u8> {
    matrix
        .into_iter()
        .flatten()
        .flat_map(f32::to_le_bytes)
        .collect()
}

fn identity_matrix_bytes() -> Vec<u8> {
    gltf_matrix_bytes([
        [1.0, 0.0, 0.0, 0.0],
        [0.0, 1.0, 0.0, 0.0],
        [0.0, 0.0, 1.0, 0.0],
        [0.0, 0.0, 0.0, 1.0],
    ])
}

fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec<u8> {
    [
        matrix.m00, matrix.m10, matrix.m20, matrix.m01, matrix.m11, matrix.m21, matrix.m02,
        matrix.m12, matrix.m22, matrix.m03, matrix.m13, matrix.m23,
    ]
    .into_iter()
    .flat_map(f64::to_le_bytes)
    .collect()
}

fn source_fingerprint(path: &str, bytes: &[u8]) -> Result<AnimationSourceFingerprint, String> {
    let metadata = fs::metadata(path).map_err(|error| format!("could not stat {path}: {error}"))?;
    let modified_unix_secs = metadata
        .modified()
        .ok()
        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
        .map(|duration| duration.as_secs())
        .unwrap_or_default();
    Ok(AnimationSourceFingerprint {
        byte_len: metadata.len(),
        modified_unix_secs,
        content_hash: blake3::hash(bytes).to_hex().to_string(),
    })
}

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

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::asset_db::{AssetId, ImportSettings};
    use uuid::Uuid;

    fn fixture_record(path: &Path) -> AssetRecord {
        AssetRecord {
            id: AssetId(Uuid::nil()),
            path: path.to_string_lossy().replace('\\', "/"),
            label: "Animated Hero".into(),
            kind_tag: "Model".into(),
            import_settings: ImportSettings::default(),
            dependencies: Vec::new(),
        }
    }

    fn write_animated_gltf(root: &Path) -> std::path::PathBuf {
        fs::create_dir_all(root).unwrap();
        let mut buffer = Vec::new();
        for value in [0.0_f32, 1.25] {
            buffer.extend_from_slice(&value.to_le_bytes());
        }
        for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] {
            buffer.extend_from_slice(&value.to_le_bytes());
        }
        fs::write(root.join("animated.bin"), buffer).unwrap();
        let gltf = r#"{
          "asset":{"version":"2.0"},
          "scene":0,
          "scenes":[{"nodes":[0]}],
          "nodes":[{"name":"Armature","children":[1]},{"name":"Hip"}],
          "skins":[{"name":"Hero Rig","skeleton":0,"joints":[1]}],
          "buffers":[{"uri":"animated.bin","byteLength":40}],
          "bufferViews":[
            {"buffer":0,"byteOffset":0,"byteLength":8},
            {"buffer":0,"byteOffset":8,"byteLength":32}
          ],
          "accessors":[
            {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.25]},
            {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"}
          ],
          "animations":[{
            "name":"Idle Loop",
            "samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}],
            "channels":[{"sampler":0,"target":{"node":1,"path":"rotation"}}],
            "extras":{"blacksite_events":[{"id":"footstep.left","time_seconds":0.5,"payload":"stone"}]}
          }]
        }"#;
        let path = root.join("animated.gltf");
        fs::write(&path, gltf).unwrap();
        path
    }

    fn write_multi_root_animated_gltf(root: &Path) -> std::path::PathBuf {
        fs::create_dir_all(root).unwrap();
        let mut buffer = Vec::new();
        for value in [0.0_f32, 1.0] {
            buffer.extend_from_slice(&value.to_le_bytes());
        }
        for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.38268343, 0.0, 0.9238795] {
            buffer.extend_from_slice(&value.to_le_bytes());
        }
        fs::write(root.join("multi-root.bin"), buffer).unwrap();
        let gltf = r#"{
          "asset":{"version":"2.0"},
          "scene":0,
          "scenes":[{"nodes":[0,2]}],
          "nodes":[
            {"name":"Character A","children":[1]},
            {"name":"Joint A"},
            {"name":"Character B","children":[3]},
            {"name":"Joint B"}
          ],
          "buffers":[{"uri":"multi-root.bin","byteLength":40}],
          "bufferViews":[
            {"buffer":0,"byteOffset":0,"byteLength":8},
            {"buffer":0,"byteOffset":8,"byteLength":32}
          ],
          "accessors":[
            {"bufferView":0,"componentType":5126,"count":2,"type":"SCALAR","min":[0.0],"max":[1.0]},
            {"bufferView":1,"componentType":5126,"count":2,"type":"VEC4"}
          ],
          "animations":[{
            "name":"Two Players Required",
            "samplers":[
              {"input":0,"output":1,"interpolation":"LINEAR"},
              {"input":0,"output":1,"interpolation":"LINEAR"}
            ],
            "channels":[
              {"sampler":0,"target":{"node":1,"path":"rotation"}},
              {"sampler":1,"target":{"node":3,"path":"rotation"}}
            ]
          }]
        }"#;
        let path = root.join("multi-root.gltf");
        fs::write(&path, gltf).unwrap();
        path
    }

    #[test]
    fn animation_manifest_path_uses_registry_uuid() {
        assert_eq!(
            animation_manifest_path("abc"),
            "assets/animations/generated/abc.animation.ron"
        );
    }

    #[test]
    fn gltf_extraction_is_deterministic_and_preserves_duration_events_and_signature() {
        let root = std::env::temp_dir().join(format!("blacksite-animation-{}", Uuid::new_v4()));
        let path = write_animated_gltf(&root);
        let record = fixture_record(&path);

        let first = build_animation_manifest(&record).unwrap();
        let second = build_animation_manifest(&record).unwrap();

        assert_eq!(first, second);
        assert_eq!(first.schema_version, ANIMATION_MANIFEST_SCHEMA_VERSION);
        assert!(first.default_animation_clip_id.is_none());
        assert!(first.runtime_supported);
        assert_eq!(first.source.dependencies.len(), 1);
        assert_eq!(first.skeletons.len(), 1);
        assert_eq!(first.clips.len(), 1);
        assert_eq!(first.skeletons[0].id, "animation:skeleton:0:hero_rig");
        assert!(!first.skeletons[0].signature.is_empty());
        assert_eq!(first.clips[0].id, "animation:clip:0:idle_loop");
        assert_eq!(first.clips[0].duration_seconds, 1.25);
        assert_eq!(
            first.clips[0].target_skeleton_signature,
            Some(first.skeletons[0].signature.clone())
        );
        assert_eq!(first.clips[0].events.len(), 1);
        assert_eq!(first.clips[0].events[0].id, "footstep.left");
        assert_eq!(first.clips[0].events[0].time_seconds, 0.5);
        assert_eq!(first.diagnostics, Vec::new());

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

    #[test]
    fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() {
        let root =
            std::env::temp_dir().join(format!("blacksite-animation-default-{}", Uuid::new_v4()));
        let path = write_animated_gltf(&root);
        let mut record = fixture_record(&path);
        record.import_settings.default_animation_clip_id =
            Some("animation:clip:0:idle_loop".into());

        let manifest = build_animation_manifest(&record).unwrap();
        assert_eq!(
            manifest.default_animation_clip_id.as_deref(),
            Some("animation:clip:0:idle_loop")
        );
        assert!(manifest.diagnostics.is_empty());

        record.import_settings.default_animation_clip_id = Some("animation:clip:99:removed".into());
        let stale = build_animation_manifest(&record).unwrap();
        assert!(stale.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "animation.default_clip_missing"
                && diagnostic.severity == AnimationDiagnosticSeverity::Error
        }));

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

    #[test]
    fn exact_signature_changes_with_joint_path_or_bind_pose() {
        let identity = identity_matrix_bytes();
        let first = skeleton_signature(
            &[vec!["Armature".into(), "Hip".into()]],
            std::slice::from_ref(&identity),
        );
        let renamed = skeleton_signature(
            &[vec!["Armature".into(), "Pelvis".into()]],
            std::slice::from_ref(&identity),
        );
        let mut changed_pose = identity;
        changed_pose[0] ^= 1;
        let rebound = skeleton_signature(&[vec!["Armature".into(), "Hip".into()]], &[changed_pose]);
        assert_ne!(first, renamed);
        assert_ne!(first, rebound);
    }

    #[test]
    fn exact_signature_preserves_bevy_node_bytes_and_segment_boundaries() {
        let identity = identity_matrix_bytes();
        let slash_in_name = vec![vec!["Armature".into(), "Hip/Joint".into()]];
        let slash_as_boundary = vec![vec!["Armature/Hip".into(), "Joint".into()]];

        assert_eq!(slash_in_name[0].join("/"), slash_as_boundary[0].join("/"));
        assert_ne!(
            skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)),
            skeleton_signature(&slash_as_boundary, std::slice::from_ref(&identity))
        );

        let backslash = vec![vec!["Armature".into(), "Hip\\Joint".into()]];
        assert_eq!(
            normalized_node_segment(Some("Hip/Joint"), 1),
            normalized_node_segment(Some("Hip\\Joint"), 1)
        );
        assert_ne!(
            skeleton_signature(&slash_in_name, std::slice::from_ref(&identity)),
            skeleton_signature(&backslash, std::slice::from_ref(&identity))
        );
        assert_eq!(bevy_node_segment(Some("  /Joint  "), 7), "  /Joint  ");
        assert_eq!(bevy_node_segment(None, 7), "GltfNode7");
    }

    #[test]
    fn gltf_with_multiple_animation_roots_is_runtime_unsupported() {
        let root =
            std::env::temp_dir().join(format!("blacksite-animation-multi-root-{}", Uuid::new_v4()));
        let path = write_multi_root_animated_gltf(&root);
        let manifest = build_animation_manifest(&fixture_record(&path)).unwrap();

        assert!(!manifest.runtime_supported);
        let diagnostic = manifest
            .diagnostics
            .iter()
            .find(|diagnostic| diagnostic.code == "animation.multiple_roots_unsupported")
            .expect("multi-root glTF should include an actionable blocking diagnostic");
        assert_eq!(diagnostic.severity, AnimationDiagnosticSeverity::Error);
        assert!(diagnostic.message.contains("2 distinct top-level roots"));
        assert!(diagnostic.repair.contains("one common top-level root"));

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

    #[test]
    fn animated_or_skinned_fbx_is_runtime_unsupported_with_repair_guidance() {
        let (supported, diagnostics) = fbx_runtime_support(2, 1);
        assert!(!supported);
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].severity, AnimationDiagnosticSeverity::Error);
        assert_eq!(diagnostics[0].code, "animation.fbx_runtime_unsupported");
        assert!(diagnostics[0].repair.contains("glTF 2.0"));

        let (supported, diagnostics) = fbx_runtime_support(0, 0);
        assert!(!supported);
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn committed_robot_fixture_exposes_a_skin_and_multiple_named_states() {
        let path =
            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models/robot_expressive.glb");
        let manifest = build_animation_manifest(&fixture_record(&path)).unwrap();

        assert!(manifest.runtime_supported);
        assert_eq!(manifest.skeletons.len(), 2);
        assert_eq!(manifest.skeletons[0].id, "animation:skeleton:0:skeleton_0");
        assert_eq!(manifest.skeletons[1].id, "animation:skeleton:1:skeleton_1");
        assert!(manifest
            .skeletons
            .iter()
            .all(|skeleton| skeleton.joint_paths.len() == 43));
        let signature = &manifest.skeletons[0].signature;
        assert!(manifest
            .skeletons
            .iter()
            .all(|skeleton| &skeleton.signature == signature));
        assert_eq!(manifest.clips.len(), 14);
        for expected in ["Idle", "Walking", "Running", "Jump", "Wave"] {
            assert!(
                manifest.clips.iter().any(|clip| clip.label == expected),
                "missing expected fixture clip {expected}"
            );
        }
        assert!(manifest
            .clips
            .iter()
            .all(|clip| clip.duration_seconds.is_finite() && clip.duration_seconds > 0.0));
        assert!(manifest
            .clips
            .iter()
            .all(|clip| { clip.target_skeleton_signature.as_ref() == Some(signature) }));
        assert!(manifest.diagnostics.is_empty());
    }
}
