//! Runtime adapter for scene-authored animation controllers.

use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::Duration;

use bevy::animation::{
    prelude::{
        AnimationGraph, AnimationGraphHandle, AnimationNodeIndex, AnimationPlayer,
        AnimationTransitions,
    },
    RepeatAnimation,
};
use bevy::gltf::GltfAssetLabel;
use bevy::mesh::morph::MorphWeights;
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
use bevy::prelude::*;
use bevy::world_serialization::WorldInstanceReady;
use shared::{
    animation_clip_source_index, asset_server_path, AnimationControllerDesc, AnimationEventDesc,
    AnimationManifest, AnimationStateDesc, HydratedSkinnedMeshRoot, ANIMATION_ARTIFACT_DIR,
    ANIMATION_MANIFEST_SCHEMA_VERSION,
};
use sim::SimEnabled;

const MAX_RUNTIME_DIAGNOSTICS: usize = 64;

/// Gameplay request to transition one animated actor to a named authored state.
#[derive(Message, Debug, Clone, PartialEq)]
pub struct AnimationStateRequest {
    pub actor: Entity,
    pub state_id: String,
    /// `None` uses the controller's authored default crossfade.
    pub crossfade_seconds: Option<f32>,
}

impl AnimationStateRequest {
    pub fn new(actor: Entity, state_id: impl Into<String>) -> Self {
        Self {
            actor,
            state_id: state_id.into(),
            crossfade_seconds: None,
        }
    }

    pub fn with_crossfade(mut self, seconds: f32) -> Self {
        self.crossfade_seconds = Some(seconds);
        self
    }
}

/// One bounded runtime failure suitable for editor diagnostics and automated tests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnimationRuntimeDiagnostic {
    pub actor: Option<Entity>,
    pub code: &'static str,
    pub message: String,
}

/// Gameplay-facing notification emitted when authored playback crosses an imported event marker.
#[derive(Message, Debug, Clone, PartialEq)]
pub struct AnimationEventNotification {
    pub actor: Entity,
    pub state_id: String,
    pub event_id: String,
    pub time_seconds: f32,
    pub payload: Option<String>,
}

#[derive(Debug, Clone)]
struct AnimationStateRuntimeMetadata {
    duration_seconds: f32,
    events: Vec<AnimationEventDesc>,
}

/// Runtime animation failures. Invalid requests never disturb the active state.
#[derive(Resource, Debug, Default)]
pub struct AnimationRuntimeDiagnostics {
    entries: VecDeque<AnimationRuntimeDiagnostic>,
}

impl AnimationRuntimeDiagnostics {
    pub fn entries(&self) -> impl Iterator<Item = &AnimationRuntimeDiagnostic> {
        self.entries.iter()
    }

    pub fn latest(&self) -> Option<&AnimationRuntimeDiagnostic> {
        self.entries.back()
    }

    fn push(&mut self, actor: Option<Entity>, code: &'static str, message: impl Into<String>) {
        if self.entries.len() == MAX_RUNTIME_DIAGNOSTICS {
            self.entries.pop_front();
        }
        let message = message.into();
        warn!("Animation runtime [{code}]: {message}");
        self.entries.push_back(AnimationRuntimeDiagnostic {
            actor,
            code,
            message,
        });
    }
}

/// Transient graph and playback state derived from [`AnimationControllerDesc`].
#[derive(Component, Debug, Clone)]
pub struct AuthoredAnimationRuntime {
    pub player: Entity,
    pub graph: Handle<AnimationGraph>,
    pub state_nodes: HashMap<String, AnimationNodeIndex>,
    pub active_state: Option<String>,
    desired_state: Option<String>,
    requested_crossfade_seconds: Option<f32>,
    paused_by_sim: bool,
    applied_preview_revision: Option<u64>,
    metadata_by_state: HashMap<String, AnimationStateRuntimeMetadata>,
    last_event_time: Option<f32>,
}

#[derive(Debug, Clone)]
struct SkinnedPoseBaselineEntry {
    entity: Entity,
    transform: Option<Transform>,
    morph_weights: Option<MorphWeights>,
}

/// Imported rest-pose state captured before Blacksite starts authored animation playback.
///
/// This component is runtime-only. It is replaced whenever a new hydrated hierarchy becomes
/// ready, so renderer reloads cannot restore values onto an older scene generation.
#[derive(Component, Debug, Clone)]
pub struct SkinnedPoseBaseline {
    pub root: Entity,
    entries: Vec<SkinnedPoseBaselineEntry>,
}

impl SkinnedPoseBaseline {
    pub fn entry_count(&self) -> usize {
        self.entries.len()
    }
}

/// Editor-only transient preview request consumed by the shared runtime adapter.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationPreviewRequest {
    pub actor: Entity,
    pub state_id: String,
    pub seek_seconds: Option<f32>,
    pub playing: bool,
}

#[derive(Resource, Debug, Default)]
pub struct AnimationPreviewRuntime {
    pub request: Option<AnimationPreviewRequest>,
    revision: u64,
}

#[derive(Component, Clone)]
struct SuspendedAnimationTransitions(AnimationTransitions);

#[derive(Resource, Debug, Default)]
struct PendingAnimationRequests(HashMap<Entity, (String, Option<f32>)>);

/// Animation hydration, state transitions, preview, and PIE pause reconciliation.
pub struct GameAnimationPlugin;

impl Plugin for GameAnimationPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<AnimationRuntimeDiagnostics>()
            .init_resource::<AnimationPreviewRuntime>()
            .init_resource::<PendingAnimationRequests>()
            .add_message::<AnimationStateRequest>()
            .add_message::<AnimationEventNotification>()
            .add_observer(attach_controller_when_model_ready)
            .add_systems(
                Update,
                (
                    reconcile_changed_controllers,
                    process_animation_state_requests,
                    sync_authored_animation_playback,
                    cleanup_stale_animation_runtimes,
                )
                    .chain(),
            );
    }
}

#[allow(clippy::too_many_arguments)]
fn attach_controller_when_model_ready(
    ready: On<WorldInstanceReady>,
    mut commands: Commands,
    roots: Query<&HydratedSkinnedMeshRoot>,
    controllers: Query<&AnimationControllerDesc>,
    children: Query<&Children>,
    players: Query<Entity, With<AnimationPlayer>>,
    transforms: Query<&Transform>,
    morph_weights: Query<&MorphWeights>,
    skinned_meshes: Query<(Entity, &SkinnedMesh)>,
    inverse_bindposes: Option<Res<Assets<SkinnedMeshInverseBindposes>>>,
    asset_server: Res<AssetServer>,
    mut graphs: ResMut<Assets<AnimationGraph>>,
    mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
    mut pending: ResMut<PendingAnimationRequests>,
) {
    let Ok(root) = roots.get(ready.entity) else {
        return;
    };
    let subtree = std::iter::once(ready.entity)
        .chain(children.iter_descendants(ready.entity))
        .collect::<HashSet<_>>();
    let baseline = capture_pose_baseline(ready.entity, &subtree, &transforms, &morph_weights);
    commands.entity(root.owner).insert(baseline);
    validate_skinned_bindings(
        root.owner,
        &subtree,
        &skinned_meshes,
        inverse_bindposes.as_deref(),
        &mut diagnostics,
    );
    let Ok(controller) = controllers.get(root.owner) else {
        return;
    };
    let Some(player) = find_animation_player(ready.entity, &children, &players) else {
        diagnostics.push(
            Some(root.owner),
            "animation.player_missing",
            "The hydrated skinned mesh contains no AnimationPlayer. Verify that the selected glTF scene contains an animated hierarchy.",
        );
        return;
    };

    configure_runtime(
        &mut commands,
        root.owner,
        player,
        controller,
        &asset_server,
        &mut graphs,
        &mut diagnostics,
        pending.0.remove(&root.owner),
    );
}

fn capture_pose_baseline(
    root: Entity,
    subtree: &HashSet<Entity>,
    transforms: &Query<&Transform>,
    morph_weights: &Query<&MorphWeights>,
) -> SkinnedPoseBaseline {
    let mut entities = subtree.iter().copied().collect::<Vec<_>>();
    entities.sort_by_key(|entity| entity.to_bits());
    let entries = entities
        .into_iter()
        .filter_map(|entity| {
            let transform = transforms.get(entity).ok().copied();
            let morph_weights = morph_weights.get(entity).ok().cloned();
            (transform.is_some() || morph_weights.is_some()).then_some(SkinnedPoseBaselineEntry {
                entity,
                transform,
                morph_weights,
            })
        })
        .collect();
    SkinnedPoseBaseline { root, entries }
}

fn validate_skinned_bindings(
    actor: Entity,
    subtree: &HashSet<Entity>,
    skinned_meshes: &Query<(Entity, &SkinnedMesh)>,
    inverse_bindposes: Option<&Assets<SkinnedMeshInverseBindposes>>,
    diagnostics: &mut AnimationRuntimeDiagnostics,
) {
    for (mesh_entity, skin) in skinned_meshes.iter() {
        if !subtree.contains(&mesh_entity) {
            continue;
        }
        if let Some(inverse_bindposes) = inverse_bindposes {
            let Some(bindposes) = inverse_bindposes.get(&skin.inverse_bindposes) else {
                diagnostics.push(
                    Some(actor),
                    "animation.inverse_bindposes_missing",
                    format!(
                        "Skinned mesh {mesh_entity} has no loaded inverse-bind-pose asset; the imported hierarchy was preserved but cannot be validated."
                    ),
                );
                continue;
            };
            if skin.joints.len() != bindposes.len() {
                diagnostics.push(
                    Some(actor),
                    "animation.skin_binding_count_mismatch",
                    format!(
                        "Skinned mesh {mesh_entity} has {} joints but {} inverse bind poses. Re-export the rig and reimport the model.",
                        skin.joints.len(),
                        bindposes.len()
                    ),
                );
            }
        }
        if let Some(joint) = skin.joints.iter().find(|joint| !subtree.contains(joint)) {
            diagnostics.push(
                Some(actor),
                "animation.skin_joint_outside_hierarchy",
                format!(
                    "Skinned mesh {mesh_entity} references joint {joint} outside its hydrated hierarchy. Re-export the rig as one scene hierarchy."
                ),
            );
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn reconcile_changed_controllers(
    mut commands: Commands,
    controllers: Query<
        (Entity, &AnimationControllerDesc),
        Or<(
            Added<AnimationControllerDesc>,
            Changed<AnimationControllerDesc>,
        )>,
    >,
    roots: Query<(Entity, &HydratedSkinnedMeshRoot)>,
    children: Query<&Children>,
    players: Query<Entity, With<AnimationPlayer>>,
    asset_server: Res<AssetServer>,
    mut graphs: ResMut<Assets<AnimationGraph>>,
    mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
    mut pending: ResMut<PendingAnimationRequests>,
) {
    for (actor, controller) in &controllers {
        let Some((root_entity, _)) = roots.iter().find(|(_, root)| root.owner == actor) else {
            continue;
        };
        let Some(player) = find_animation_player(root_entity, &children, &players) else {
            continue;
        };
        configure_runtime(
            &mut commands,
            actor,
            player,
            controller,
            &asset_server,
            &mut graphs,
            &mut diagnostics,
            pending.0.remove(&actor),
        );
    }
}

fn find_animation_player(
    root: Entity,
    children: &Query<&Children>,
    players: &Query<Entity, With<AnimationPlayer>>,
) -> Option<Entity> {
    children
        .iter_descendants(root)
        .find(|entity| players.get(*entity).is_ok())
}

#[allow(clippy::too_many_arguments)]
fn configure_runtime(
    commands: &mut Commands,
    actor: Entity,
    player: Entity,
    controller: &AnimationControllerDesc,
    asset_server: &AssetServer,
    graphs: &mut Assets<AnimationGraph>,
    diagnostics: &mut AnimationRuntimeDiagnostics,
    pending: Option<(String, Option<f32>)>,
) {
    match build_animation_graph(controller, asset_server, graphs) {
        Ok((graph, state_nodes, metadata_by_state)) => {
            let (desired_state, requested_crossfade_seconds) = pending.unwrap_or_else(|| {
                (
                    controller.default_state.clone(),
                    Some(controller.default_crossfade_seconds),
                )
            });
            commands.queue(move |world: &mut World| {
                restore_pose_baseline(world, actor);
            });
            commands.entity(player).insert((
                AnimationGraphHandle(graph.clone()),
                AnimationTransitions::new(),
            ));
            commands
                .entity(player)
                .remove::<SuspendedAnimationTransitions>();
            commands.entity(actor).insert(AuthoredAnimationRuntime {
                player,
                graph,
                state_nodes,
                active_state: None,
                desired_state: (!desired_state.trim().is_empty()).then_some(desired_state),
                requested_crossfade_seconds,
                paused_by_sim: false,
                applied_preview_revision: None,
                metadata_by_state,
                last_event_time: None,
            });
        }
        Err(error) => {
            diagnostics.push(Some(actor), "animation.graph_invalid", error);
            clear_failed_runtime_configuration(commands, actor, player);
        }
    }
}

fn clear_failed_runtime_configuration(commands: &mut Commands, actor: Entity, player: Entity) {
    commands.queue(move |world: &mut World| {
        if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
            animation_player.stop_all();
        }
        restore_pose_baseline(world, actor);
        if let Ok(mut player_entity) = world.get_entity_mut(player) {
            player_entity
                .remove::<AnimationGraphHandle>()
                .remove::<AnimationTransitions>()
                .remove::<SuspendedAnimationTransitions>();
        }
        if let Ok(mut actor_entity) = world.get_entity_mut(actor) {
            actor_entity.remove::<AuthoredAnimationRuntime>();
        }
        if let Some(mut preview) = world.get_resource_mut::<AnimationPreviewRuntime>() {
            if preview
                .request
                .as_ref()
                .is_some_and(|request| request.actor == actor)
            {
                preview.request = None;
                preview.revision = preview.revision.wrapping_add(1);
            }
        }
    });
}

fn build_animation_graph(
    controller: &AnimationControllerDesc,
    asset_server: &AssetServer,
    graphs: &mut Assets<AnimationGraph>,
) -> Result<
    (
        Handle<AnimationGraph>,
        HashMap<String, AnimationNodeIndex>,
        HashMap<String, AnimationStateRuntimeMetadata>,
    ),
    String,
> {
    let mut graph = AnimationGraph::new();
    let mut state_nodes = HashMap::new();
    let mut metadata_by_state = HashMap::new();
    let assets_root = PathBuf::from(crate::launch::resolve_assets_directory());
    let mut manifests = HashMap::new();

    for state in &controller.states {
        if state.id.trim().is_empty() {
            return Err("Animation controller contains an empty state ID.".into());
        }
        if state_nodes.contains_key(&state.id) {
            return Err(format!(
                "Animation controller contains duplicate state ID `{}`.",
                state.id
            ));
        }
        if !state.speed.is_finite() || state.speed <= 0.0 {
            return Err(format!(
                "Animation state `{}` speed must be positive and finite.",
                state.id
            ));
        }
        let (index, source_path, metadata) =
            resolve_animation_state_source(state, &assets_root, &mut manifests)?;
        let path = asset_server_path(&source_path);
        if !matches!(
            std::path::Path::new(&path)
                .extension()
                .and_then(|extension| extension.to_str())
                .map(|extension| extension.to_ascii_lowercase())
                .as_deref(),
            Some("gltf" | "glb")
        ) {
            return Err(format!(
                "Animation state `{}` uses unsupported animated source `{source_path}`; animation v1 requires glTF/GLB.",
                state.id
            ));
        }
        let clip = asset_server.load(GltfAssetLabel::Animation(index).from_asset(path));
        let node = graph.add_clip(clip, 1.0, graph.root);
        state_nodes.insert(state.id.clone(), node);
        metadata_by_state.insert(state.id.clone(), metadata);
    }

    if state_nodes.is_empty() {
        return Err("Animation controller contains no states.".into());
    }
    Ok((graphs.add(graph), state_nodes, metadata_by_state))
}

fn resolve_animation_state_source(
    state: &AnimationStateDesc,
    assets_root: &Path,
    manifests: &mut HashMap<String, AnimationManifest>,
) -> Result<(usize, String, AnimationStateRuntimeMetadata), String> {
    let index = animation_clip_source_index(&state.clip.sub_asset_id).ok_or_else(|| {
        format!(
            "Animation state `{}` has invalid clip sub-asset ID `{}`.",
            state.id, state.clip.sub_asset_id
        )
    })?;
    if state.clip.asset_id.trim().is_empty() {
        return Err(format!(
            "Animation state `{}` clip `{}` has no stable parent asset ID.",
            state.id, state.clip.label
        ));
    }
    if !manifests.contains_key(&state.clip.asset_id) {
        let manifest = load_runtime_animation_manifest(assets_root, &state.clip.asset_id)?;
        manifests.insert(state.clip.asset_id.clone(), manifest);
    }
    let manifest = manifests
        .get(&state.clip.asset_id)
        .expect("animation manifest was inserted above");
    if manifest.asset_id != state.clip.asset_id {
        return Err(format!(
            "Animation manifest `{}` belongs to asset `{}` instead of `{}`.",
            runtime_animation_manifest_path(assets_root, &state.clip.asset_id).display(),
            manifest.asset_id,
            state.clip.asset_id
        ));
    }
    if !manifest.runtime_supported {
        return Err(format!(
            "Animation state `{}` uses a manifest that is not supported by the runtime; animation v1 requires glTF/GLB.",
            state.id
        ));
    }
    let clip = manifest
        .clips
        .iter()
        .find(|clip| clip.id == state.clip.sub_asset_id)
        .ok_or_else(|| {
            format!(
                "Animation state `{}` clip `{}` is missing from the generated manifest; reimport or reassign it.",
                state.id, state.clip.sub_asset_id
            )
        })?;
    if clip.source_index != index {
        return Err(format!(
            "Animation state `{}` clip ID encodes source index {index}, but the manifest records {}.",
            state.id, clip.source_index
        ));
    }
    if manifest.source.path.trim().is_empty() {
        return Err(format!(
            "Animation state `{}` manifest has no current source path.",
            state.id
        ));
    }
    Ok((
        index,
        manifest.source.path.clone(),
        AnimationStateRuntimeMetadata {
            duration_seconds: clip.duration_seconds,
            events: clip.events.clone(),
        },
    ))
}

fn runtime_animation_manifest_path(assets_root: &Path, asset_id: &str) -> PathBuf {
    let relative_dir = ANIMATION_ARTIFACT_DIR
        .strip_prefix("assets/")
        .unwrap_or(ANIMATION_ARTIFACT_DIR);
    assets_root
        .join(relative_dir)
        .join(format!("{asset_id}.animation.ron"))
}

fn load_runtime_animation_manifest(
    assets_root: &Path,
    asset_id: &str,
) -> Result<AnimationManifest, String> {
    let path = runtime_animation_manifest_path(assets_root, asset_id);
    let text = std::fs::read_to_string(&path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    let manifest: AnimationManifest = ron::from_str(&text)
        .map_err(|error| format!("could not parse {}: {error}", path.display()))?;
    if manifest.schema_version != ANIMATION_MANIFEST_SCHEMA_VERSION {
        return Err(format!(
            "animation manifest {} uses schema {} but runtime requires {}; reimport the source model",
            path.display(),
            manifest.schema_version,
            ANIMATION_MANIFEST_SCHEMA_VERSION
        ));
    }
    Ok(manifest)
}

fn process_animation_state_requests(
    mut requests: MessageReader<AnimationStateRequest>,
    controllers: Query<&AnimationControllerDesc>,
    mut runtimes: Query<&mut AuthoredAnimationRuntime>,
    mut pending: ResMut<PendingAnimationRequests>,
    mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
) {
    for request in requests.read() {
        let Ok(controller) = controllers.get(request.actor) else {
            diagnostics.push(
                Some(request.actor),
                "animation.controller_missing",
                "Animation state request targeted an actor without AnimationControllerDesc.",
            );
            continue;
        };
        if controller.state(&request.state_id).is_none() {
            diagnostics.push(
                Some(request.actor),
                "animation.state_missing",
                format!(
                    "Animation controller has no state `{}`; the active state was preserved.",
                    request.state_id
                ),
            );
            continue;
        }
        if request
            .crossfade_seconds
            .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
        {
            diagnostics.push(
                Some(request.actor),
                "animation.crossfade_invalid",
                "Animation state request crossfade must be finite and nonnegative; the active state was preserved.",
            );
            continue;
        }

        pending.0.insert(
            request.actor,
            (request.state_id.clone(), request.crossfade_seconds),
        );
        if let Ok(mut runtime) = runtimes.get_mut(request.actor) {
            runtime.desired_state = Some(request.state_id.clone());
            runtime.requested_crossfade_seconds = request.crossfade_seconds;
            pending.0.remove(&request.actor);
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn sync_authored_animation_playback(
    mut commands: Commands,
    sim_enabled: Res<SimEnabled>,
    preview: Res<AnimationPreviewRuntime>,
    controllers: Query<&AnimationControllerDesc>,
    mut runtimes: Query<(Entity, &mut AuthoredAnimationRuntime)>,
    mut players: Query<(
        &mut AnimationPlayer,
        Option<&mut AnimationTransitions>,
        Option<&SuspendedAnimationTransitions>,
    )>,
    mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
    mut event_notifications: MessageWriter<AnimationEventNotification>,
) {
    for (actor, mut runtime) in &mut runtimes {
        let Ok(controller) = controllers.get(actor) else {
            continue;
        };
        let Ok((mut player, mut live_transitions, suspended_transitions)) =
            players.get_mut(runtime.player)
        else {
            continue;
        };
        let preview_request = preview
            .request
            .as_ref()
            .filter(|request| request.actor == actor);
        let desired_state = preview_request
            .map(|request| request.state_id.as_str())
            .or(runtime.desired_state.as_deref())
            .or((!controller.default_state.trim().is_empty())
                .then_some(controller.default_state.as_str()))
            .map(str::to_owned);
        let sampling_edit_rest = !sim_enabled.0
            && preview_request.is_none()
            && runtime.active_state.is_none()
            && desired_state.is_some();
        let should_evaluate = sim_enabled.0 || preview_request.is_some() || sampling_edit_rest;
        if !should_evaluate {
            if runtime.active_state.is_some() {
                player.pause_all();
                runtime.paused_by_sim = true;
                if let Some(transitions) = live_transitions.as_deref() {
                    commands
                        .entity(runtime.player)
                        .remove::<AnimationTransitions>()
                        .insert(SuspendedAnimationTransitions(transitions.clone()));
                }
            }
            continue;
        }

        let detached_transitions = live_transitions.is_none();
        let mut restored_transitions = detached_transitions.then(|| {
            suspended_transitions
                .map(|transitions| transitions.0.clone())
                .unwrap_or_default()
        });

        let Some(desired_state) = desired_state else {
            continue;
        };
        let Some(state) = controller.state(&desired_state) else {
            diagnostics.push(
                Some(actor),
                "animation.state_missing",
                format!("Animation controller has no state `{desired_state}`."),
            );
            runtime.desired_state = None;
            continue;
        };
        let Some(&node) = runtime.state_nodes.get(&desired_state) else {
            diagnostics.push(
                Some(actor),
                "animation.state_unhydrated",
                format!("Animation state `{desired_state}` has no hydrated graph node."),
            );
            continue;
        };

        if runtime.active_state.as_deref() != Some(desired_state.as_str()) {
            let crossfade = if preview_request.is_some() || sampling_edit_rest {
                0.0
            } else {
                runtime
                    .requested_crossfade_seconds
                    .take()
                    .unwrap_or(controller.default_crossfade_seconds)
            };
            let transitions = live_transitions
                .as_deref_mut()
                .or(restored_transitions.as_mut())
                .expect("detached transitions are restored locally");
            let active = play_with_crossfade(transitions, &mut player, node, crossfade);
            configure_active_animation(active, state);
            runtime.active_state = Some(desired_state);
            runtime.paused_by_sim = false;
            runtime.last_event_time = None;
        }

        if let Some(request) = preview_request {
            if runtime.applied_preview_revision != Some(preview.revision) {
                if let Some(seek_seconds) = request.seek_seconds {
                    if seek_seconds.is_finite() {
                        if let Some(active) = player.animation_mut(node) {
                            active.set_seek_time(clamp_seek_to_state(seek_seconds, state));
                        }
                    } else {
                        diagnostics.push(
                            Some(actor),
                            "animation.preview_seek_invalid",
                            "Animation preview seek must be finite.",
                        );
                    }
                }
                runtime.applied_preview_revision = Some(preview.revision);
            }
        } else {
            runtime.applied_preview_revision = None;
        }

        let should_advance = preview_request
            .map(|request| request.playing)
            .unwrap_or(sim_enabled.0);
        if should_advance {
            player.resume_all();
            if preview_request.is_none() {
                if let (Some(metadata), Some(current)) = (
                    runtime.metadata_by_state.get(&state.id),
                    player.animation(node).map(|active| active.seek_time()),
                ) {
                    emit_crossed_animation_events(
                        &mut event_notifications,
                        actor,
                        state,
                        metadata,
                        runtime.last_event_time,
                        current,
                    );
                }
            }
            enforce_playback_range(&mut player, node, state);
            runtime.last_event_time = player.animation(node).map(|active| active.seek_time());
            runtime.paused_by_sim = false;
            if let Some(transitions) = restored_transitions.take() {
                commands
                    .entity(runtime.player)
                    .remove::<SuspendedAnimationTransitions>()
                    .insert(transitions);
            } else if live_transitions.is_some() && suspended_transitions.is_some() {
                commands
                    .entity(runtime.player)
                    .remove::<SuspendedAnimationTransitions>();
            }
        } else {
            enforce_playback_range(&mut player, node, state);
            runtime.last_event_time = player.animation(node).map(|active| active.seek_time());
            player.pause_all();
            runtime.paused_by_sim = true;
            let transitions = live_transitions
                .as_deref()
                .cloned()
                .or(restored_transitions.take())
                .unwrap_or_default();
            commands
                .entity(runtime.player)
                .remove::<AnimationTransitions>()
                .insert(SuspendedAnimationTransitions(transitions));
        }
    }
}

fn play_with_crossfade<'a>(
    transitions: &mut AnimationTransitions,
    player: &'a mut AnimationPlayer,
    node: AnimationNodeIndex,
    crossfade_seconds: f32,
) -> &'a mut bevy::animation::ActiveAnimation {
    let crossfade_seconds = if crossfade_seconds.is_finite() {
        crossfade_seconds.max(0.0)
    } else {
        0.0
    };
    if crossfade_seconds <= f32::EPSILON {
        player.stop_all();
        *transitions = AnimationTransitions::new();
        return transitions.play(player, node, Duration::ZERO);
    }
    if let Some(active) = transitions
        .get_main_animation()
        .and_then(|main_animation| player.animation_mut(main_animation))
    {
        active.resume();
    }
    transitions.play(player, node, Duration::from_secs_f32(crossfade_seconds))
}

fn configure_active_animation(
    active: &mut bevy::animation::ActiveAnimation,
    state: &AnimationStateDesc,
) {
    active
        .set_speed(state.speed)
        .set_seek_time(state.range.start_seconds.max(0.0));
    if state.looping
        && state.range.start_seconds <= f32::EPSILON
        && state.range.end_seconds.is_none()
    {
        active.repeat();
    } else {
        active.set_repeat(RepeatAnimation::Never);
    }
}

fn clamp_seek_to_state(seconds: f32, state: &AnimationStateDesc) -> f32 {
    let start = state.range.start_seconds.max(0.0);
    let end = state.range.end_seconds.unwrap_or(f32::MAX).max(start);
    seconds.clamp(start, end)
}

fn enforce_playback_range(
    player: &mut AnimationPlayer,
    node: AnimationNodeIndex,
    state: &AnimationStateDesc,
) {
    let Some(active) = player.animation_mut(node) else {
        return;
    };
    let start = state.range.start_seconds.max(0.0);
    if let Some(end) = state.range.end_seconds {
        let end = end.max(start);
        if active.seek_time() >= end {
            if state.looping {
                active.set_seek_time(start).resume();
            } else {
                active.set_seek_time(end).pause();
            }
        }
    } else if state.looping && start > f32::EPSILON && active.just_completed() {
        active.set_seek_time(start).resume();
    }
}

fn emit_crossed_animation_events(
    writer: &mut MessageWriter<AnimationEventNotification>,
    actor: Entity,
    state: &AnimationStateDesc,
    metadata: &AnimationStateRuntimeMetadata,
    previous: Option<f32>,
    current: f32,
) {
    for event in crossed_animation_events(state, metadata, previous, current) {
        writer.write(AnimationEventNotification {
            actor,
            state_id: state.id.clone(),
            event_id: event.id.clone(),
            time_seconds: event.time_seconds,
            payload: event.payload.clone(),
        });
    }
}

fn crossed_animation_events<'a>(
    state: &AnimationStateDesc,
    metadata: &'a AnimationStateRuntimeMetadata,
    previous: Option<f32>,
    current: f32,
) -> Vec<&'a AnimationEventDesc> {
    if metadata.events.is_empty() || !current.is_finite() {
        return Vec::new();
    }
    let start = state.range.start_seconds.max(0.0);
    let end = state
        .range
        .end_seconds
        .unwrap_or(metadata.duration_seconds)
        .max(start);
    let current = current.clamp(start, end);
    let previous = previous
        .unwrap_or(start - 0.000_001)
        .clamp(start - 0.000_001, end);
    let in_range = |event: &&AnimationEventDesc| {
        event.time_seconds.is_finite() && event.time_seconds >= start && event.time_seconds <= end
    };
    if current >= previous {
        return metadata
            .events
            .iter()
            .filter(in_range)
            .filter(|event| event.time_seconds > previous && event.time_seconds <= current)
            .collect();
    }
    if !state.looping {
        return Vec::new();
    }
    let mut crossed = metadata
        .events
        .iter()
        .filter(in_range)
        .filter(|event| event.time_seconds > previous)
        .collect::<Vec<_>>();
    crossed.extend(
        metadata
            .events
            .iter()
            .filter(in_range)
            .filter(|event| event.time_seconds <= current),
    );
    crossed
}

fn cleanup_stale_animation_runtimes(world: &mut World) {
    let runtimes = world
        .query::<(Entity, &AuthoredAnimationRuntime)>()
        .iter(world)
        .map(|(actor, runtime)| (actor, runtime.player))
        .collect::<Vec<_>>();
    let stale = runtimes
        .into_iter()
        .filter(|(actor, player)| {
            let controller_exists = world.get::<AnimationControllerDesc>(*actor).is_some();
            let player_exists = world.get::<AnimationPlayer>(*player).is_some();
            !controller_exists || !player_exists
        })
        .collect::<Vec<_>>();
    for (actor, player) in stale {
        if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
            animation_player.stop_all();
        }
        if let Ok(mut player_entity) = world.get_entity_mut(player) {
            player_entity.remove::<SuspendedAnimationTransitions>();
        }
        restore_pose_baseline(world, actor);
        if let Ok(mut actor_entity) = world.get_entity_mut(actor) {
            actor_entity.remove::<AuthoredAnimationRuntime>();
        }
    }
}

fn restore_pose_baseline(world: &mut World, actor: Entity) {
    let Some(baseline) = world.get::<SkinnedPoseBaseline>(actor).cloned() else {
        return;
    };
    for entry in baseline.entries {
        if let Some(transform) = entry.transform {
            if let Some(mut current) = world.get_mut::<Transform>(entry.entity) {
                *current = transform;
            }
        }
        if let Some(morph_weights) = entry.morph_weights {
            if let Some(mut current) = world.get_mut::<MorphWeights>(entry.entity) {
                *current = morph_weights;
            }
        }
    }
}

/// Enqueues a validated gameplay state transition.
pub fn request_animation_state(
    world: &mut World,
    request: AnimationStateRequest,
) -> Result<(), String> {
    let Some(controller) = world.get::<AnimationControllerDesc>(request.actor) else {
        return Err("actor has no AnimationControllerDesc".into());
    };
    if controller.state(&request.state_id).is_none() {
        return Err(format!(
            "animation state `{}` does not exist",
            request.state_id
        ));
    }
    if request
        .crossfade_seconds
        .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
    {
        return Err("animation crossfade must be finite and nonnegative".into());
    }
    world.write_message(request);
    Ok(())
}

/// Starts or scrubs a non-dirty editor preview through the runtime adapter.
pub fn set_animation_preview(
    world: &mut World,
    request: AnimationPreviewRequest,
) -> Result<(), String> {
    let Some(controller) = world.get::<AnimationControllerDesc>(request.actor) else {
        return Err("actor has no AnimationControllerDesc".into());
    };
    if controller.state(&request.state_id).is_none() {
        return Err(format!(
            "animation state `{}` does not exist",
            request.state_id
        ));
    }
    if request
        .seek_seconds
        .is_some_and(|seconds| !seconds.is_finite())
    {
        return Err("animation preview seek must be finite".into());
    }
    let mut preview = world.resource_mut::<AnimationPreviewRuntime>();
    preview.revision = preview.revision.wrapping_add(1);
    preview.request = Some(request);
    Ok(())
}

/// Stops the active editor preview without changing authored data or history.
pub fn stop_animation_preview(world: &mut World) {
    let actor = {
        let mut preview = world.resource_mut::<AnimationPreviewRuntime>();
        let actor = preview.request.take().map(|request| request.actor);
        preview.revision = preview.revision.wrapping_add(1);
        actor
    };
    let Some(actor) = actor else {
        return;
    };
    let player = world
        .get::<AuthoredAnimationRuntime>(actor)
        .map(|runtime| runtime.player);
    if let Some(player) = player {
        if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
            animation_player.stop_all();
        }
    }
    restore_pose_baseline(world, actor);
    let edit_rest_state = world
        .get::<AnimationControllerDesc>(actor)
        .map(|controller| controller.default_state.clone())
        .filter(|state| !state.trim().is_empty());
    if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
        runtime.active_state = None;
        runtime.desired_state = edit_rest_state;
        runtime.requested_crossfade_seconds = Some(0.0);
        runtime.paused_by_sim = false;
        runtime.applied_preview_revision = None;
        runtime.last_event_time = None;
    }
}

/// Clears transient animation playback at an editor/PIE mode boundary while retaining graphs.
pub fn reset_authored_animation_runtime(world: &mut World) {
    {
        let mut preview = world.resource_mut::<AnimationPreviewRuntime>();
        preview.request = None;
        preview.revision = preview.revision.wrapping_add(1);
    }
    let entries: Vec<(Entity, Entity)> = world
        .query::<(Entity, &AuthoredAnimationRuntime)>()
        .iter(world)
        .map(|(actor, runtime)| (actor, runtime.player))
        .collect();
    for (actor, player) in entries {
        if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
            animation_player.stop_all();
        }
        restore_pose_baseline(world, actor);
        let edit_rest_state = world
            .get::<AnimationControllerDesc>(actor)
            .map(|controller| controller.default_state.clone())
            .filter(|state| !state.trim().is_empty());
        if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
            runtime.active_state = None;
            runtime.desired_state = edit_rest_state;
            runtime.requested_crossfade_seconds = Some(0.0);
            runtime.paused_by_sim = false;
            runtime.applied_preview_revision = None;
            runtime.last_event_time = None;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::asset::{AssetApp, AssetPlugin};
    use shared::{
        AnimationClipRecord, AnimationManifestSource, AnimationPlaybackRange,
        AnimationSourceFingerprint, EditorAssetRef,
    };

    fn state(id: &str, index: usize) -> AnimationStateDesc {
        AnimationStateDesc {
            id: id.into(),
            label: id.into(),
            clip: EditorAssetRef::new("model-id", format!("animation:clip:{index}:{id}"), id)
                .with_source_path("assets/models/animated.glb"),
            looping: true,
            speed: 1.25,
            range: AnimationPlaybackRange {
                start_seconds: 0.2,
                end_seconds: Some(0.8),
            },
        }
    }

    fn runtime_test_app(sim_enabled: bool) -> (App, Entity, Entity, AnimationNodeIndex) {
        let mut app = App::new();
        app.insert_resource(SimEnabled(sim_enabled))
            .init_resource::<AnimationPreviewRuntime>()
            .init_resource::<AnimationRuntimeDiagnostics>()
            .add_message::<AnimationEventNotification>()
            .add_systems(Update, sync_authored_animation_playback);

        let idle_node = AnimationNodeIndex::new(0);
        let run_node = AnimationNodeIndex::new(1);
        let player = app
            .world_mut()
            .spawn((AnimationPlayer::default(), AnimationTransitions::new()))
            .id();
        let actor = app
            .world_mut()
            .spawn(AnimationControllerDesc {
                states: vec![state("idle", 0), state("run", 1)],
                default_state: "idle".into(),
                default_crossfade_seconds: 0.25,
                ..Default::default()
            })
            .id();
        app.world_mut()
            .entity_mut(actor)
            .insert(AuthoredAnimationRuntime {
                player,
                graph: Handle::default(),
                state_nodes: HashMap::from([("idle".into(), idle_node), ("run".into(), run_node)]),
                active_state: None,
                desired_state: Some("idle".into()),
                requested_crossfade_seconds: Some(0.25),
                paused_by_sim: false,
                applied_preview_revision: None,
                metadata_by_state: HashMap::from([
                    (
                        "idle".into(),
                        AnimationStateRuntimeMetadata {
                            duration_seconds: 1.0,
                            events: Vec::new(),
                        },
                    ),
                    (
                        "run".into(),
                        AnimationStateRuntimeMetadata {
                            duration_seconds: 1.0,
                            events: Vec::new(),
                        },
                    ),
                ]),
                last_event_time: None,
            });
        (app, actor, player, run_node)
    }

    #[test]
    fn seek_is_clamped_to_authored_range() {
        let state = state("idle", 0);
        assert_eq!(clamp_seek_to_state(-1.0, &state), 0.2);
        assert_eq!(clamp_seek_to_state(0.5, &state), 0.5);
        assert_eq!(clamp_seek_to_state(4.0, &state), 0.8);
    }

    #[test]
    fn request_constructor_preserves_optional_crossfade() {
        let actor = Entity::from_bits(42);
        let request = AnimationStateRequest::new(actor, "run").with_crossfade(0.35);
        assert_eq!(request.actor, actor);
        assert_eq!(request.state_id, "run");
        assert_eq!(request.crossfade_seconds, Some(0.35));
    }

    #[test]
    fn event_crossings_follow_playback_order_and_loop_range() {
        let state = state("run", 1);
        let metadata = AnimationStateRuntimeMetadata {
            duration_seconds: 1.0,
            events: vec![
                AnimationEventDesc {
                    id: "left".into(),
                    time_seconds: 0.25,
                    payload: None,
                },
                AnimationEventDesc {
                    id: "right".into(),
                    time_seconds: 0.75,
                    payload: Some("foot".into()),
                },
            ],
        };

        let first = crossed_animation_events(&state, &metadata, None, 0.5)
            .into_iter()
            .map(|event| event.id.as_str())
            .collect::<Vec<_>>();
        let wrapped = crossed_animation_events(&state, &metadata, Some(0.7), 0.3)
            .into_iter()
            .map(|event| event.id.as_str())
            .collect::<Vec<_>>();

        assert_eq!(first, vec!["left"]);
        assert_eq!(wrapped, vec!["right", "left"]);
    }

    #[test]
    fn runtime_source_resolution_uses_stable_manifest_after_move() {
        let root = std::env::temp_dir().join(format!(
            "blacksite-animation-runtime-{}",
            std::process::id()
        ));
        let mut state = state("idle", 0);
        state.clip.source_path = Some("assets/models/stale.glb".into());
        let manifest = AnimationManifest {
            schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
            asset_id: state.clip.asset_id.clone(),
            label: "Moved Model".into(),
            default_animation_clip_id: None,
            source: AnimationManifestSource {
                path: "assets/models/relocated.glb".into(),
                format: "glb".into(),
                fingerprint: AnimationSourceFingerprint {
                    byte_len: 1,
                    modified_unix_secs: 0,
                    content_hash: "fixture".into(),
                },
                dependencies: Vec::new(),
            },
            runtime_supported: true,
            skeletons: Vec::new(),
            clips: vec![AnimationClipRecord {
                id: state.clip.sub_asset_id.clone(),
                label: state.clip.label.clone(),
                source_index: 0,
                duration_seconds: 1.0,
                target_skeleton_signature: None,
                events: Vec::new(),
            }],
            diagnostics: Vec::new(),
        };
        let path = runtime_animation_manifest_path(&root, &state.clip.asset_id);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            &path,
            ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();

        let (index, source_path, metadata) =
            resolve_animation_state_source(&state, &root, &mut HashMap::new()).unwrap();

        assert_eq!(index, 0);
        assert_eq!(source_path, "assets/models/relocated.glb");
        assert_eq!(metadata.duration_seconds, 1.0);
        let _ = std::fs::remove_dir_all(root);
    }

    #[test]
    fn failed_runtime_reconfiguration_stops_and_clears_stale_playback() {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, AssetPlugin::default()))
            .init_asset::<AnimationGraph>()
            .insert_resource(SimEnabled(true))
            .add_plugins(GameAnimationPlugin);

        let actor = app.world_mut().spawn_empty().id();
        let root = app
            .world_mut()
            .spawn((HydratedSkinnedMeshRoot { owner: actor }, ChildOf(actor)))
            .id();
        let stale_node = AnimationNodeIndex::new(0);
        let mut animation_player = AnimationPlayer::default();
        animation_player.start(stale_node);
        let player = app
            .world_mut()
            .spawn((
                animation_player,
                AnimationGraphHandle::default(),
                AnimationTransitions::new(),
                ChildOf(root),
            ))
            .id();
        app.world_mut().entity_mut(actor).insert((
            AnimationControllerDesc::default(),
            AuthoredAnimationRuntime {
                player,
                graph: Handle::default(),
                state_nodes: HashMap::from([("stale".into(), stale_node)]),
                active_state: Some("stale".into()),
                desired_state: Some("stale".into()),
                requested_crossfade_seconds: None,
                paused_by_sim: false,
                applied_preview_revision: None,
                metadata_by_state: HashMap::new(),
                last_event_time: None,
            },
        ));
        app.world_mut()
            .resource_mut::<AnimationPreviewRuntime>()
            .request = Some(AnimationPreviewRequest {
            actor,
            state_id: "stale".into(),
            seek_seconds: None,
            playing: true,
        });

        app.update();

        assert!(app.world().get::<AuthoredAnimationRuntime>(actor).is_none());
        assert!(app.world().get::<AnimationGraphHandle>(player).is_none());
        assert!(app.world().get::<AnimationTransitions>(player).is_none());
        assert!(app
            .world()
            .get::<SuspendedAnimationTransitions>(player)
            .is_none());
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .is_some_and(|player| player.playing_animations().next().is_none()));
        assert!(app
            .world()
            .resource::<AnimationPreviewRuntime>()
            .request
            .is_none());
        assert_eq!(
            app.world()
                .resource::<AnimationRuntimeDiagnostics>()
                .latest()
                .map(|diagnostic| diagnostic.code),
            Some("animation.graph_invalid")
        );
    }

    #[test]
    fn zero_crossfade_discards_the_old_animation_immediately() {
        let old_node = AnimationNodeIndex::new(0);
        let new_node = AnimationNodeIndex::new(1);
        let mut player = AnimationPlayer::default();
        let mut transitions = AnimationTransitions::new();
        transitions.play(&mut player, old_node, Duration::ZERO);

        play_with_crossfade(&mut transitions, &mut player, new_node, 0.0);

        assert!(!player.is_playing_animation(old_node));
        assert!(player.is_playing_animation(new_node));
        assert_eq!(transitions.get_main_animation(), Some(new_node));
    }

    #[test]
    fn crossfade_resumes_a_main_animation_paused_by_simulation() {
        let old_node = AnimationNodeIndex::new(0);
        let new_node = AnimationNodeIndex::new(1);
        let mut player = AnimationPlayer::default();
        let mut transitions = AnimationTransitions::new();
        transitions.play(&mut player, old_node, Duration::ZERO);
        player.pause_all();

        play_with_crossfade(&mut transitions, &mut player, new_node, 0.5);

        assert!(!player
            .animation(old_node)
            .expect("old animation remains active during the fade")
            .is_paused());
        assert!(player.is_playing_animation(new_node));
        assert_eq!(transitions.get_main_animation(), Some(new_node));
    }

    #[test]
    fn playing_preview_applies_seek_only_once() {
        let (mut app, actor, player, _) = runtime_test_app(false);
        set_animation_preview(
            app.world_mut(),
            AnimationPreviewRequest {
                actor,
                state_id: "idle".into(),
                seek_seconds: Some(0.5),
                playing: true,
            },
        )
        .unwrap();

        app.update();
        let idle_node = AnimationNodeIndex::new(0);
        assert_eq!(
            app.world()
                .get::<AnimationPlayer>(player)
                .unwrap()
                .animation(idle_node)
                .unwrap()
                .seek_time(),
            0.5
        );

        app.world_mut()
            .get_mut::<AnimationPlayer>(player)
            .unwrap()
            .animation_mut(idle_node)
            .unwrap()
            .set_seek_time(0.65);
        app.update();

        assert_eq!(
            app.world()
                .get::<AnimationPlayer>(player)
                .unwrap()
                .animation(idle_node)
                .unwrap()
                .seek_time(),
            0.65
        );
    }

    #[test]
    fn simulation_pause_suspends_and_restores_crossfade_state() {
        let (mut app, actor, player, run_node) = runtime_test_app(true);
        app.update();
        {
            let mut runtime = app
                .world_mut()
                .get_mut::<AuthoredAnimationRuntime>(actor)
                .unwrap();
            runtime.desired_state = Some("run".into());
            runtime.requested_crossfade_seconds = Some(1.0);
        }
        app.update();
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .unwrap()
            .is_playing_animation(run_node));

        app.world_mut().resource_mut::<SimEnabled>().0 = false;
        app.update();
        assert!(app.world().get::<AnimationTransitions>(player).is_none());
        assert!(app
            .world()
            .get::<SuspendedAnimationTransitions>(player)
            .is_some());
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .unwrap()
            .all_paused());

        app.world_mut().resource_mut::<SimEnabled>().0 = true;
        app.update();
        assert!(app.world().get::<AnimationTransitions>(player).is_some());
        assert!(app
            .world()
            .get::<SuspendedAnimationTransitions>(player)
            .is_none());
        assert!(!app
            .world()
            .get::<AnimationPlayer>(player)
            .unwrap()
            .all_paused());
    }

    #[test]
    fn paused_preview_suspends_transition_advancement() {
        let (mut app, actor, player, _) = runtime_test_app(false);
        set_animation_preview(
            app.world_mut(),
            AnimationPreviewRequest {
                actor,
                state_id: "idle".into(),
                seek_seconds: Some(0.5),
                playing: false,
            },
        )
        .unwrap();

        app.update();

        assert!(app.world().get::<AnimationTransitions>(player).is_none());
        assert!(app
            .world()
            .get::<SuspendedAnimationTransitions>(player)
            .is_some());
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .unwrap()
            .all_paused());
    }

    #[test]
    fn edit_mode_samples_and_pauses_the_explicit_default_state() {
        let (mut app, actor, player, _) = runtime_test_app(false);

        app.update();

        assert_eq!(
            app.world()
                .get::<AuthoredAnimationRuntime>(actor)
                .and_then(|runtime| runtime.active_state.as_deref()),
            Some("idle")
        );
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .is_some_and(AnimationPlayer::all_paused));
    }

    #[test]
    fn stopping_preview_restores_transform_and_morph_baseline_before_resampling() {
        let mut app = App::new();
        app.init_resource::<AnimationPreviewRuntime>();
        let baseline_transform = Transform::from_xyz(1.0, 2.0, 3.0);
        let baseline_morph = MorphWeights::new(vec![0.1, 0.9], None).unwrap();
        let target = app
            .world_mut()
            .spawn((
                Transform::from_xyz(9.0, 8.0, 7.0),
                MorphWeights::new(vec![0.8, 0.2], None).unwrap(),
            ))
            .id();
        let node = AnimationNodeIndex::new(0);
        let mut animation_player = AnimationPlayer::default();
        animation_player.start(node);
        let player = app.world_mut().spawn(animation_player).id();
        let actor = app
            .world_mut()
            .spawn(AnimationControllerDesc {
                states: vec![state("idle", 0)],
                default_state: "idle".into(),
                ..Default::default()
            })
            .id();
        app.world_mut().entity_mut(actor).insert((
            SkinnedPoseBaseline {
                root: target,
                entries: vec![SkinnedPoseBaselineEntry {
                    entity: target,
                    transform: Some(baseline_transform),
                    morph_weights: Some(baseline_morph),
                }],
            },
            AuthoredAnimationRuntime {
                player,
                graph: Handle::default(),
                state_nodes: HashMap::from([("idle".into(), node)]),
                active_state: Some("idle".into()),
                desired_state: Some("idle".into()),
                requested_crossfade_seconds: None,
                paused_by_sim: false,
                applied_preview_revision: None,
                metadata_by_state: HashMap::new(),
                last_event_time: None,
            },
        ));
        set_animation_preview(
            app.world_mut(),
            AnimationPreviewRequest {
                actor,
                state_id: "idle".into(),
                seek_seconds: Some(0.7),
                playing: true,
            },
        )
        .unwrap();

        stop_animation_preview(app.world_mut());

        assert_eq!(
            app.world().get::<Transform>(target),
            Some(&baseline_transform)
        );
        assert_eq!(
            app.world().get::<MorphWeights>(target).unwrap().weights(),
            &[0.1, 0.9]
        );
        assert!(app
            .world()
            .get::<AnimationPlayer>(player)
            .unwrap()
            .playing_animations()
            .next()
            .is_none());
        let runtime = app.world().get::<AuthoredAnimationRuntime>(actor).unwrap();
        assert!(runtime.active_state.is_none());
        assert_eq!(runtime.desired_state.as_deref(), Some("idle"));
        assert_eq!(runtime.requested_crossfade_seconds, Some(0.0));
    }

    #[test]
    fn removing_controller_restores_baseline_and_clears_runtime() {
        let mut world = World::new();
        let target = world.spawn(Transform::from_xyz(9.0, 0.0, 0.0)).id();
        let player = world.spawn(AnimationPlayer::default()).id();
        let actor = world
            .spawn((
                SkinnedPoseBaseline {
                    root: target,
                    entries: vec![SkinnedPoseBaselineEntry {
                        entity: target,
                        transform: Some(Transform::from_xyz(1.0, 0.0, 0.0)),
                        morph_weights: None,
                    }],
                },
                AuthoredAnimationRuntime {
                    player,
                    graph: Handle::default(),
                    state_nodes: HashMap::new(),
                    active_state: Some("idle".into()),
                    desired_state: Some("idle".into()),
                    requested_crossfade_seconds: None,
                    paused_by_sim: false,
                    applied_preview_revision: None,
                    metadata_by_state: HashMap::new(),
                    last_event_time: None,
                },
            ))
            .id();

        cleanup_stale_animation_runtimes(&mut world);

        assert_eq!(world.get::<Transform>(target).unwrap().translation, Vec3::X);
        assert!(world.get::<AuthoredAnimationRuntime>(actor).is_none());
    }

    #[test]
    fn duplicate_skin_records_are_validated_independently_against_shared_joints() {
        let mut world = World::new();
        let actor = world.spawn_empty().id();
        let root = world.spawn_empty().id();
        let joint = world.spawn_empty().id();
        let mut inverse_bindposes = Assets::<SkinnedMeshInverseBindposes>::default();
        let first_bindposes =
            inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
        let second_bindposes =
            inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
        let first_mesh = world
            .spawn(SkinnedMesh {
                inverse_bindposes: first_bindposes,
                joints: vec![joint],
            })
            .id();
        let second_mesh = world
            .spawn(SkinnedMesh {
                inverse_bindposes: second_bindposes,
                joints: vec![joint],
            })
            .id();
        let subtree = HashSet::from([root, joint, first_mesh, second_mesh]);
        let mut diagnostics = AnimationRuntimeDiagnostics::default();
        let mut query_state =
            bevy::ecs::system::SystemState::<Query<(Entity, &SkinnedMesh)>>::new(&mut world);

        {
            let skins = query_state.get(&world).unwrap();
            validate_skinned_bindings(
                actor,
                &subtree,
                &skins,
                Some(&inverse_bindposes),
                &mut diagnostics,
            );
        }
        assert!(diagnostics.entries().next().is_none());

        world
            .get_mut::<SkinnedMesh>(second_mesh)
            .unwrap()
            .joints
            .push(joint);
        {
            let skins = query_state.get(&world).unwrap();
            validate_skinned_bindings(
                actor,
                &subtree,
                &skins,
                Some(&inverse_bindposes),
                &mut diagnostics,
            );
        }
        assert_eq!(
            diagnostics.latest().map(|diagnostic| diagnostic.code),
            Some("animation.skin_binding_count_mismatch")
        );
    }

    #[test]
    fn invalid_state_request_is_rejected_without_message_side_effect() {
        let mut app = App::new();
        app.add_message::<AnimationStateRequest>();
        let actor = app
            .world_mut()
            .spawn(AnimationControllerDesc {
                states: vec![state("idle", 0)],
                default_state: "idle".into(),
                ..Default::default()
            })
            .id();

        let result = request_animation_state(
            app.world_mut(),
            AnimationStateRequest::new(actor, "missing"),
        );
        assert_eq!(
            result.unwrap_err(),
            "animation state `missing` does not exist"
        );

        let result = request_animation_state(
            app.world_mut(),
            AnimationStateRequest::new(actor, "idle").with_crossfade(f32::NAN),
        );
        assert_eq!(
            result.unwrap_err(),
            "animation crossfade must be finite and nonnegative"
        );
    }

    #[test]
    fn invalid_preview_seek_is_rejected() {
        let mut app = App::new();
        app.init_resource::<AnimationPreviewRuntime>();
        let actor = app
            .world_mut()
            .spawn(AnimationControllerDesc {
                states: vec![state("idle", 0)],
                default_state: "idle".into(),
                ..Default::default()
            })
            .id();

        let result = set_animation_preview(
            app.world_mut(),
            AnimationPreviewRequest {
                actor,
                state_id: "idle".into(),
                seek_seconds: Some(f32::INFINITY),
                playing: false,
            },
        );
        assert_eq!(result.unwrap_err(), "animation preview seek must be finite");
    }

    #[test]
    fn runtime_diagnostics_are_bounded() {
        let mut diagnostics = AnimationRuntimeDiagnostics::default();
        for index in 0..(MAX_RUNTIME_DIAGNOSTICS + 4) {
            diagnostics.push(None, "test", format!("diagnostic {index}"));
        }
        assert_eq!(diagnostics.entries().count(), MAX_RUNTIME_DIAGNOSTICS);
        assert_eq!(
            diagnostics.latest().map(|entry| entry.message.as_str()),
            Some("diagnostic 67")
        );
    }
}
