//! Persistent animation authoring data and generated import-manifest contract.

use bevy::prelude::*;
use serde::{Deserialize, Serialize};

use crate::EditorAssetRef;

pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 3;
pub const ANIMATION_ARTIFACT_DIR: &str = "assets/animations/generated";
pub const ANIMATION_CLIP_SUB_ASSET_PREFIX: &str = "animation:clip:";
pub const ANIMATION_SKELETON_SUB_ASSET_PREFIX: &str = "animation:skeleton:";
pub const COMPONENT_SKINNED_MESH_RENDERER: &str = "shared::animation::SkinnedMeshRenderer";
pub const COMPONENT_ANIMATION_CONTROLLER_DESC: &str = "shared::animation::AnimationControllerDesc";

const DEFAULT_CROSSFADE_SECONDS: f32 = 0.2;
const DEFAULT_PLAYBACK_SPEED: f32 = 1.0;

pub fn animation_clip_sub_asset_id(source_index: usize, label: &str) -> String {
    animation_sub_asset_id(ANIMATION_CLIP_SUB_ASSET_PREFIX, source_index, label)
}

pub fn animation_skeleton_sub_asset_id(source_index: usize, label: &str) -> String {
    animation_sub_asset_id(ANIMATION_SKELETON_SUB_ASSET_PREFIX, source_index, label)
}

pub fn animation_clip_source_index(sub_asset_id: &str) -> Option<usize> {
    animation_source_index(sub_asset_id, ANIMATION_CLIP_SUB_ASSET_PREFIX)
}

pub fn animation_skeleton_source_index(sub_asset_id: &str) -> Option<usize> {
    animation_source_index(sub_asset_id, ANIMATION_SKELETON_SUB_ASSET_PREFIX)
}

fn animation_sub_asset_id(prefix: &str, source_index: usize, label: &str) -> String {
    let slug = stable_slug(label);
    if slug.is_empty() {
        format!("{prefix}{source_index}:unnamed")
    } else {
        format!("{prefix}{source_index}:{slug}")
    }
}

fn animation_source_index(sub_asset_id: &str, prefix: &str) -> Option<usize> {
    sub_asset_id
        .strip_prefix(prefix)?
        .split_once(':')?
        .0
        .parse()
        .ok()
}

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

/// Exact identity of an imported skeleton's ordered joint paths and bind-pose structure.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimationSkeletonSignature(pub String);

impl AnimationSkeletonSignature {
    pub fn new(signature: impl Into<String>) -> Self {
        Self(signature.into())
    }

    pub fn is_empty(&self) -> bool {
        self.0.trim().is_empty()
    }
}

/// Authoring renderer for rigged geometry that must retain its imported joint hierarchy.
///
/// Unlike [`crate::StaticMeshRenderer`], this component never flattens source primitives into
/// independent mesh slots. Hydration instantiates the selected model scene so Bevy can preserve
/// its `SkinnedMesh`, joint entities, inverse bind poses, and animation player bindings.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SkinnedMeshRenderer {
    /// Stable asset-registry UUID. Empty only for legacy or unresolved references.
    #[serde(default)]
    pub asset_id: String,
    /// Asset path relative to `assets/`, e.g. `models/character.glb`.
    pub path: String,
    /// Scene index whose hierarchy owns the skinned primitives and joints.
    #[serde(default)]
    pub scene_index: usize,
    /// Stable material slots for every draw binding in the imported animated hierarchy.
    #[serde(default)]
    pub materials: crate::RendererMaterialSet,
}

impl SkinnedMeshRenderer {
    pub fn new(path: impl Into<String>) -> Self {
        Self {
            asset_id: String::new(),
            path: path.into(),
            scene_index: 0,
            materials: crate::RendererMaterialSet::default(),
        }
    }

    pub fn with_asset_id(mut self, asset_id: impl Into<String>) -> Self {
        self.asset_id = asset_id.into();
        self
    }

    /// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
    pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
        format!("{path}#Scene{scene_index}")
    }
}

impl Default for SkinnedMeshRenderer {
    fn default() -> Self {
        Self::new(String::new())
    }
}

/// Authored playback window inside an imported clip.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimationPlaybackRange {
    #[serde(default)]
    pub start_seconds: f32,
    /// `None` plays to the imported clip duration.
    #[serde(default)]
    pub end_seconds: Option<f32>,
}

impl Default for AnimationPlaybackRange {
    fn default() -> Self {
        Self {
            start_seconds: 0.0,
            end_seconds: None,
        }
    }
}

/// One normalized event marker imported with an animation clip.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimationEventDesc {
    pub id: String,
    pub time_seconds: f32,
    #[serde(default)]
    pub payload: Option<String>,
}

/// One stable, gameplay-addressable state in the v1 single-layer controller.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimationStateDesc {
    /// Stable runtime key. Display labels are not gameplay identifiers.
    pub id: String,
    #[serde(default)]
    pub label: String,
    pub clip: EditorAssetRef,
    #[serde(default = "default_true")]
    pub looping: bool,
    #[serde(default = "default_playback_speed")]
    pub speed: f32,
    #[serde(default)]
    pub range: AnimationPlaybackRange,
}

impl Default for AnimationStateDesc {
    fn default() -> Self {
        Self {
            id: String::new(),
            label: String::new(),
            clip: EditorAssetRef::default(),
            looping: true,
            speed: DEFAULT_PLAYBACK_SPEED,
            range: AnimationPlaybackRange::default(),
        }
    }
}

/// Scene-authored single-layer animation controller for a sibling [`SkinnedMeshRenderer`].
///
/// Bevy graph, player, transition, and instantiated-world state are derived at runtime.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimationControllerDesc {
    #[serde(default)]
    pub skeleton: Option<EditorAssetRef>,
    #[serde(default)]
    pub states: Vec<AnimationStateDesc>,
    /// Stable state ID selected when the hydrated model becomes ready.
    #[serde(default)]
    pub default_state: String,
    #[serde(default = "default_crossfade_seconds")]
    pub default_crossfade_seconds: f32,
}

impl Default for AnimationControllerDesc {
    fn default() -> Self {
        Self {
            skeleton: None,
            states: Vec::new(),
            default_state: String::new(),
            default_crossfade_seconds: DEFAULT_CROSSFADE_SECONDS,
        }
    }
}

impl AnimationControllerDesc {
    pub fn state(&self, id: &str) -> Option<&AnimationStateDesc> {
        self.states.iter().find(|state| state.id == id)
    }

    pub fn state_mut(&mut self, id: &str) -> Option<&mut AnimationStateDesc> {
        self.states.iter_mut().find(|state| state.id == id)
    }
}

fn default_true() -> bool {
    true
}

fn default_playback_speed() -> f32 {
    DEFAULT_PLAYBACK_SPEED
}

fn default_crossfade_seconds() -> f32 {
    DEFAULT_CROSSFADE_SECONDS
}

/// Versioned generated artifact for one imported model source.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnimationManifest {
    pub schema_version: u32,
    pub asset_id: String,
    pub label: String,
    /// Explicit imported clip shown as the asset's edit-mode rest presentation.
    ///
    /// This is a stable clip sub-asset ID. `None` deliberately preserves the imported node pose;
    /// importers and placement code must never guess the first clip.
    #[serde(default)]
    pub default_animation_clip_id: Option<String>,
    pub source: AnimationManifestSource,
    /// Whether this source format can hydrate the extracted animation data at runtime.
    pub runtime_supported: bool,
    #[serde(default)]
    pub skeletons: Vec<AnimationSkeletonRecord>,
    #[serde(default)]
    pub clips: Vec<AnimationClipRecord>,
    #[serde(default)]
    pub diagnostics: Vec<AnimationImportDiagnostic>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationManifestSource {
    pub path: String,
    pub format: String,
    pub fingerprint: AnimationSourceFingerprint,
    #[serde(default)]
    pub dependencies: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationSourceFingerprint {
    pub byte_len: u64,
    pub modified_unix_secs: u64,
    /// BLAKE3 hash of the source file bytes.
    pub content_hash: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationSkeletonRecord {
    /// Stable imported sub-asset ID used by [`EditorAssetRef`].
    pub id: String,
    pub label: String,
    pub source_index: usize,
    pub signature: AnimationSkeletonSignature,
    /// Normalized root-to-joint target paths in source joint order.
    pub joint_paths: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnimationClipRecord {
    /// Stable imported sub-asset ID used by [`EditorAssetRef`].
    pub id: String,
    pub label: String,
    pub source_index: usize,
    pub duration_seconds: f32,
    #[serde(default)]
    pub target_skeleton_signature: Option<AnimationSkeletonSignature>,
    #[serde(default)]
    pub events: Vec<AnimationEventDesc>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnimationDiagnosticSeverity {
    Info,
    Warning,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationImportDiagnostic {
    pub severity: AnimationDiagnosticSeverity,
    pub code: String,
    pub message: String,
    pub repair: String,
}

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

    #[test]
    fn controller_defaults_are_single_layer_and_non_destructive() {
        let controller = AnimationControllerDesc::default();
        assert!(controller.skeleton.is_none());
        assert!(controller.states.is_empty());
        assert!(controller.default_state.is_empty());
        assert_eq!(controller.default_crossfade_seconds, 0.2);
    }

    #[test]
    fn authored_controller_round_trips_stable_refs_and_playback_settings() {
        let controller = AnimationControllerDesc {
            skeleton: Some(EditorAssetRef::new(
                "model-id",
                animation_skeleton_sub_asset_id(0, "Rig"),
                "Rig",
            )),
            states: vec![AnimationStateDesc {
                id: "locomotion.idle".into(),
                label: "Idle".into(),
                clip: EditorAssetRef::new(
                    "model-id",
                    animation_clip_sub_asset_id(2, "Idle"),
                    "Idle",
                ),
                looping: true,
                speed: 0.75,
                range: AnimationPlaybackRange {
                    start_seconds: 0.1,
                    end_seconds: Some(1.2),
                },
            }],
            default_state: "locomotion.idle".into(),
            default_crossfade_seconds: 0.15,
        };

        let encoded = ron::to_string(&controller).unwrap();
        let decoded: AnimationControllerDesc = ron::from_str(&encoded).unwrap();
        assert_eq!(decoded, controller);
        assert_eq!(decoded.state("locomotion.idle"), decoded.states.first());
        assert!(decoded.state("missing").is_none());
    }

    #[test]
    fn subasset_ids_encode_source_indices_deterministically() {
        let clip = animation_clip_sub_asset_id(7, "Idle Loop");
        let skeleton = animation_skeleton_sub_asset_id(3, "Hero Rig");
        assert_eq!(clip, "animation:clip:7:idle_loop");
        assert_eq!(skeleton, "animation:skeleton:3:hero_rig");
        assert_eq!(animation_clip_source_index(&clip), Some(7));
        assert_eq!(animation_skeleton_source_index(&skeleton), Some(3));
        assert_eq!(animation_clip_source_index(&skeleton), None);
    }

    #[test]
    fn schema_v2_manifest_defaults_to_imported_rest_pose() {
        let legacy = r#"(
            schema_version: 2,
            asset_id: "model-id",
            label: "Legacy",
            source: (
                path: "assets/models/legacy.glb",
                format: "glb",
                fingerprint: (byte_len: 1, modified_unix_secs: 0, content_hash: "hash"),
                dependencies: [],
            ),
            runtime_supported: true,
            skeletons: [],
            clips: [],
            diagnostics: [],
        )"#;

        let manifest: AnimationManifest = ron::from_str(legacy).unwrap();
        assert!(manifest.default_animation_clip_id.is_none());
    }
}
