//! Actor kind inference (migration only) and save-time validation.

use std::collections::HashSet;
use std::path::Path;

use bevy::ecs::world::EntityRef;
use bevy::prelude::*;

use crate::{
    animation_clip_source_index, animation_skeleton_source_index, brush_math::validate_brush,
    ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
    LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
    ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
    SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
    AUDIO_CLIP_SUB_ASSET_ID,
};

/// Deterministic presentation/compatibility hint derived from authoring components.
///
/// Validation is component-driven; this priority only chooses an icon/default
/// label when an actor composes several compatible behaviors (for example a
/// rendered mesh with a light).
pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
    entity.get::<LevelObject>()?;
    if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
        return Some(ActorKind::PrefabAnchor);
    }
    if entity.get::<SkinnedMeshRenderer>().is_some() {
        return Some(ActorKind::SkinnedMesh);
    }
    if entity.get::<BrushDesc>().is_some() {
        return Some(ActorKind::Brush);
    }
    if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
        return Some(ActorKind::StaticMesh);
    }
    if entity.get::<ModelRef>().is_some() {
        return Some(ActorKind::ImportedModel);
    }
    if entity.get::<LightDesc>().is_some() {
        return Some(ActorKind::Light);
    }
    if entity.get::<PlayerSpawn>().is_some() {
        return Some(ActorKind::PlayerSpawn);
    }
    if entity.get::<WeaponSpawn>().is_some() {
        return Some(ActorKind::WeaponSpawn);
    }
    if entity.get::<TriggerVolume>().is_some() {
        return Some(ActorKind::TriggerVolume);
    }
    if entity.get::<PostProcessVolumeDesc>().is_some() {
        return Some(ActorKind::PostProcessVolume);
    }
    if entity.get::<TeamSpawn>().is_some() {
        return Some(ActorKind::TeamSpawn);
    }
    if entity.get::<ObjectiveMarker>().is_some() {
        return Some(ActorKind::Objective);
    }
    if entity.get::<AudioSourceDesc>().is_some() {
        return Some(ActorKind::AudioSource);
    }
    if entity.get::<AudioListenerDesc>().is_some() {
        return Some(ActorKind::AudioListener);
    }
    if entity.get::<NavigationBounds>().is_some()
        || entity.get::<NavigationObstacle>().is_some()
        || entity.get::<NavigationArea>().is_some()
        || entity.get::<NavigationLink>().is_some()
    {
        return Some(ActorKind::Navigation);
    }
    Some(ActorKind::Empty)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActorValidationError {
    MissingActorKind,
    MissingTransform,
    BrushMissingDesc,
    BrushHasPrimitive,
    BrushHasStaticMeshRenderer,
    BrushHasLight,
    BrushHasModelRef,
    InvalidBrushGeometry(String),
    StaticMeshMissingPrimitive,
    StaticMeshHasLight,
    StaticMeshHasModelRef,
    ImportedModelMissingModelRef,
    ImportedModelHasPrimitive,
    ImportedModelHasStaticMeshRenderer,
    SkinnedMeshMissingRenderer,
    SkinnedMeshInvalidRenderer,
    SkinnedMeshHasPrimitive,
    SkinnedMeshHasStaticMeshRenderer,
    SkinnedMeshHasModelRef,
    SkinnedMeshRendererActorKindMismatch,
    ConflictingGeometrySources,
    LightMissingLightDesc,
    LightHasPrimitive,
    LightHasModelRef,
    LightHasStaticMeshRenderer,
    PostProcessVolumeInvalidHalfExtents,
    PostProcessVolumeInvalidBlendDistance,
    PostProcessVolumeInvalidOverrideScalar,
    PostProcessVolumeMissingDesc,
    AudioSourceMissingDesc,
    AudioSourceMissingClip,
    AudioSourceInvalidClipReference,
    AudioSourceInvalidGain,
    AudioSourceInvalidPitch,
    AudioSourceInvalidSpatialBlend,
    AudioSourceInvalidAttenuation,
    AudioSourceMissingBus,
    AudioListenerMissingDesc,
    AudioListenerInvalidEarGap,
    AnimationControllerMissingSkinnedMeshRenderer,
    AnimationControllerMissingSkeleton,
    AnimationControllerInvalidSkeletonReference,
    AnimationControllerEmptyStateId,
    AnimationControllerDuplicateStateId,
    AnimationControllerInvalidClipReference,
    AnimationControllerInvalidStateSpeed,
    AnimationControllerInvalidStateRange,
    AnimationControllerInvalidCrossfade,
    AnimationControllerMissingDefaultState,
    AnimationControllerUnknownDefaultState,
    InvalidNavigation(String),
}

fn is_finite_positive(v: f32) -> bool {
    v.is_finite() && v > 0.0
}

fn is_finite_non_negative(v: f32) -> bool {
    v.is_finite() && v >= 0.0
}

/// Validates authoring component sets for a level object before save or play.
pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
    if entity.get::<LevelObject>().is_none() {
        return Ok(());
    }

    let Some(_stored_kind) = entity.get::<ActorKind>() else {
        return Err(ActorValidationError::MissingActorKind);
    };
    let kind = infer_actor_kind(entity).unwrap_or(ActorKind::Empty);

    if entity.get::<Transform>().is_none() {
        return Err(ActorValidationError::MissingTransform);
    }

    if let Some(source) = entity.get::<AudioSourceDesc>() {
        validate_audio_source(source)?;
    }
    if let Some(listener) = entity.get::<AudioListenerDesc>() {
        validate_audio_listener(listener)?;
    }
    if let Some(controller) = entity.get::<AnimationControllerDesc>() {
        if entity.get::<SkinnedMeshRenderer>().is_none() {
            return Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer);
        }
        validate_animation_controller(controller)?;
    }
    let geometry_source_count = usize::from(entity.get::<Primitive>().is_some())
        + usize::from(entity.get::<BrushDesc>().is_some())
        + usize::from(entity.get::<StaticMeshRenderer>().is_some())
        + usize::from(entity.get::<SkinnedMeshRenderer>().is_some())
        + usize::from(entity.get::<ModelRef>().is_some());
    if geometry_source_count > 1 {
        return Err(ActorValidationError::ConflictingGeometrySources);
    }
    if let Some(brush) = entity.get::<BrushDesc>() {
        let report = validate_brush(brush);
        if !report.is_valid() {
            let message = report
                .diagnostics
                .into_iter()
                .find(|diagnostic| {
                    diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
                })
                .map(|diagnostic| diagnostic.message)
                .unwrap_or_else(|| "Brush geometry is invalid.".to_string());
            return Err(ActorValidationError::InvalidBrushGeometry(message));
        }
    }
    if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
        if renderer.path.trim().is_empty() {
            return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
        }
    }
    if let Some(desc) = entity.get::<PostProcessVolumeDesc>() {
        validate_post_process_volume(desc)?;
    }
    if entity.get::<NavigationBounds>().is_some()
        || entity.get::<NavigationObstacle>().is_some()
        || entity.get::<NavigationArea>().is_some()
        || entity.get::<NavigationLink>().is_some()
    {
        validate_navigation_actor(entity)?;
    }

    match &kind {
        ActorKind::Brush => {
            let Some(brush) = entity.get::<BrushDesc>() else {
                return Err(ActorValidationError::BrushMissingDesc);
            };
            if entity.get::<Primitive>().is_some() {
                return Err(ActorValidationError::BrushHasPrimitive);
            }
            if entity.get::<StaticMeshRenderer>().is_some() {
                return Err(ActorValidationError::BrushHasStaticMeshRenderer);
            }
            if entity.get::<ModelRef>().is_some() {
                return Err(ActorValidationError::BrushHasModelRef);
            }
            let _ = brush;
        }
        ActorKind::StaticMesh => {
            let has_static_mesh_renderer = entity
                .get::<StaticMeshRenderer>()
                .is_some_and(|renderer| renderer.slots.iter().any(|slot| slot.mesh.is_resolved()));
            if entity.get::<Primitive>().is_none() && !has_static_mesh_renderer {
                return Err(ActorValidationError::StaticMeshMissingPrimitive);
            }
            if entity.get::<ModelRef>().is_some() {
                return Err(ActorValidationError::StaticMeshHasModelRef);
            }
        }
        ActorKind::SkinnedMesh => {
            let Some(_renderer) = entity.get::<SkinnedMeshRenderer>() else {
                return Err(ActorValidationError::SkinnedMeshMissingRenderer);
            };
            if entity.get::<Primitive>().is_some() {
                return Err(ActorValidationError::SkinnedMeshHasPrimitive);
            }
            if entity.get::<StaticMeshRenderer>().is_some() {
                return Err(ActorValidationError::SkinnedMeshHasStaticMeshRenderer);
            }
            if entity.get::<ModelRef>().is_some() {
                return Err(ActorValidationError::SkinnedMeshHasModelRef);
            }
        }
        ActorKind::ImportedModel => {
            if entity.get::<ModelRef>().is_none() {
                return Err(ActorValidationError::ImportedModelMissingModelRef);
            }
            if entity.get::<Primitive>().is_some() {
                return Err(ActorValidationError::ImportedModelHasPrimitive);
            }
            if entity.get::<StaticMeshRenderer>().is_some() {
                return Err(ActorValidationError::ImportedModelHasStaticMeshRenderer);
            }
        }
        ActorKind::Light => {
            if entity.get::<LightDesc>().is_none() {
                return Err(ActorValidationError::LightMissingLightDesc);
            }
            // Other compatible authoring components may be composed with a light.
        }
        ActorKind::PostProcessVolume => {
            let Some(desc) = entity.get::<PostProcessVolumeDesc>() else {
                return Err(ActorValidationError::PostProcessVolumeMissingDesc);
            };
            let _ = desc;
        }
        ActorKind::AudioSource => {
            if entity.get::<AudioSourceDesc>().is_none() {
                return Err(ActorValidationError::AudioSourceMissingDesc);
            }
        }
        ActorKind::AudioListener => {
            if entity.get::<AudioListenerDesc>().is_none() {
                return Err(ActorValidationError::AudioListenerMissingDesc);
            }
        }
        ActorKind::Navigation => {}
        ActorKind::Empty
        | ActorKind::PrefabAnchor
        | ActorKind::PlayerSpawn
        | ActorKind::WeaponSpawn
        | ActorKind::TriggerVolume
        | ActorKind::TeamSpawn
        | ActorKind::Objective => {}
    }

    Ok(())
}

fn validate_post_process_volume(desc: &PostProcessVolumeDesc) -> Result<(), ActorValidationError> {
    if !is_finite_positive(desc.half_extents.x)
        || !is_finite_positive(desc.half_extents.y)
        || !is_finite_positive(desc.half_extents.z)
    {
        return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
    }
    if !is_finite_non_negative(desc.blend_distance) {
        return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
    }
    let overrides = &desc.overrides;
    if overrides
        .exposure_ev100
        .is_some_and(|value| !value.is_finite())
        || overrides
            .fog_density
            .is_some_and(|value| !value.is_finite() || value < 0.0)
    {
        return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
    }
    Ok(())
}

fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
    let mut component_count = 0;
    if let Some(bounds) = entity.get::<NavigationBounds>() {
        component_count += 1;
        validate_vec3_positive(bounds.half_extents, "navigation bounds")?;
        bounds
            .agent
            .validate()
            .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
        crate::navigation_generated_artifact_path(&bounds.artifact_path)
            .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
        let mut sample_ids = HashSet::new();
        for sample in &bounds.validation_samples {
            let sample_id = sample.id.trim();
            if sample_id.is_empty() {
                return Err(ActorValidationError::InvalidNavigation(
                    "navigation validation sample ID must not be empty".into(),
                ));
            }
            if !sample_ids.insert(sample_id) {
                return Err(ActorValidationError::InvalidNavigation(format!(
                    "navigation validation sample ID `{sample_id}` must be unique within its bounds"
                )));
            }
            if !sample.start.is_finite() || !sample.end.is_finite() || sample.start == sample.end {
                return Err(ActorValidationError::InvalidNavigation(format!(
                    "navigation validation sample `{sample_id}` endpoints must be finite and distinct"
                )));
            }
        }
    }
    if let Some(obstacle) = entity.get::<NavigationObstacle>() {
        component_count += 1;
        validate_vec3_positive(obstacle.half_extents, "navigation obstacle")?;
    }
    if let Some(area) = entity.get::<NavigationArea>() {
        component_count += 1;
        validate_vec3_positive(area.half_extents, "navigation area")?;
        if area.id.trim().is_empty() {
            return Err(ActorValidationError::InvalidNavigation(
                "navigation area ID must not be empty".into(),
            ));
        }
        if !is_finite_positive(area.cost) {
            return Err(ActorValidationError::InvalidNavigation(
                "navigation area cost must be finite and greater than zero".into(),
            ));
        }
    }
    if let Some(link) = entity.get::<NavigationLink>() {
        component_count += 1;
        if !link.start.is_finite() || !link.end.is_finite() || link.start == link.end {
            return Err(ActorValidationError::InvalidNavigation(
                "navigation link endpoints must be finite and distinct".into(),
            ));
        }
        if !is_finite_positive(link.cost) {
            return Err(ActorValidationError::InvalidNavigation(
                "navigation link cost must be finite and greater than zero".into(),
            ));
        }
    }
    if component_count != 1 {
        return Err(ActorValidationError::InvalidNavigation(
            "navigation actors must own exactly one bounds, obstacle, area, or link component"
                .into(),
        ));
    }
    Ok(())
}

fn validate_vec3_positive(value: Vec3, label: &str) -> Result<(), ActorValidationError> {
    if !value.is_finite() || value.min_element() <= 0.0 {
        return Err(ActorValidationError::InvalidNavigation(format!(
            "{label} half extents must be finite and greater than zero"
        )));
    }
    Ok(())
}

fn validate_audio_source(source: &AudioSourceDesc) -> Result<(), ActorValidationError> {
    let Some(clip) = source.clip.as_ref() else {
        return Err(ActorValidationError::AudioSourceMissingClip);
    };
    if !clip.is_resolved() || clip.sub_asset_id != AUDIO_CLIP_SUB_ASSET_ID {
        return Err(ActorValidationError::AudioSourceInvalidClipReference);
    }
    if !source.gain_db.is_finite()
        || !(settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX).contains(&source.gain_db)
    {
        return Err(ActorValidationError::AudioSourceInvalidGain);
    }
    if !is_finite_positive(source.pitch) {
        return Err(ActorValidationError::AudioSourceInvalidPitch);
    }
    if !source.spatial_blend.is_finite() || !(0.0..=1.0).contains(&source.spatial_blend) {
        return Err(ActorValidationError::AudioSourceInvalidSpatialBlend);
    }
    let attenuation = source.attenuation;
    if !is_finite_non_negative(attenuation.min_distance)
        || !attenuation.max_distance.is_finite()
        || attenuation.max_distance <= attenuation.min_distance
        || !is_finite_non_negative(attenuation.rolloff_factor)
    {
        return Err(ActorValidationError::AudioSourceInvalidAttenuation);
    }
    if source.bus.trim().is_empty() {
        return Err(ActorValidationError::AudioSourceMissingBus);
    }
    Ok(())
}

fn validate_audio_listener(listener: &AudioListenerDesc) -> Result<(), ActorValidationError> {
    if !is_finite_positive(listener.ear_gap) {
        return Err(ActorValidationError::AudioListenerInvalidEarGap);
    }
    Ok(())
}

fn validate_animation_controller(
    controller: &AnimationControllerDesc,
) -> Result<(), ActorValidationError> {
    let Some(skeleton) = controller.skeleton.as_ref() else {
        return Err(ActorValidationError::AnimationControllerMissingSkeleton);
    };
    if !skeleton.is_resolved() || animation_skeleton_source_index(&skeleton.sub_asset_id).is_none()
    {
        return Err(ActorValidationError::AnimationControllerInvalidSkeletonReference);
    }

    let mut state_ids = HashSet::with_capacity(controller.states.len());
    for state in &controller.states {
        if state.id.trim().is_empty() {
            return Err(ActorValidationError::AnimationControllerEmptyStateId);
        }
        if !state_ids.insert(state.id.as_str()) {
            return Err(ActorValidationError::AnimationControllerDuplicateStateId);
        }
        let clip_source_is_gltf = state
            .clip
            .source_path
            .as_deref()
            .and_then(|path| Path::new(path).extension())
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| {
                extension.eq_ignore_ascii_case("gltf") || extension.eq_ignore_ascii_case("glb")
            });
        if !state.clip.is_resolved()
            || animation_clip_source_index(&state.clip.sub_asset_id).is_none()
            || !clip_source_is_gltf
        {
            return Err(ActorValidationError::AnimationControllerInvalidClipReference);
        }
        if !state.speed.is_finite() || state.speed <= 0.0 {
            return Err(ActorValidationError::AnimationControllerInvalidStateSpeed);
        }
        if !is_finite_non_negative(state.range.start_seconds)
            || state
                .range
                .end_seconds
                .is_some_and(|end| !end.is_finite() || end < 0.0 || end < state.range.start_seconds)
        {
            return Err(ActorValidationError::AnimationControllerInvalidStateRange);
        }
    }

    if !is_finite_non_negative(controller.default_crossfade_seconds) {
        return Err(ActorValidationError::AnimationControllerInvalidCrossfade);
    }
    if controller.default_state.trim().is_empty() {
        return Err(ActorValidationError::AnimationControllerMissingDefaultState);
    }
    if !state_ids.contains(controller.default_state.as_str()) {
        return Err(ActorValidationError::AnimationControllerUnknownDefaultState);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationPlaybackRange,
        AnimationStateDesc, EditorAssetRef, PostProcessVolumeDesc,
    };

    fn level_entity(world: &mut World, bundle: impl Bundle) -> Entity {
        world
            .spawn((LevelObject, Transform::default(), bundle))
            .id()
    }

    fn audio_source() -> AudioSourceDesc {
        AudioSourceDesc {
            clip: Some(
                crate::EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Tone")
                    .with_source_path("assets/audio/tone.ogg"),
            ),
            ..Default::default()
        }
    }

    fn animation_controller() -> AnimationControllerDesc {
        AnimationControllerDesc {
            skeleton: Some(
                EditorAssetRef::new("model-id", animation_skeleton_sub_asset_id(0, "Rig"), "Rig")
                    .with_source_path("assets/models/animated.glb"),
            ),
            states: vec![AnimationStateDesc {
                id: "idle".into(),
                label: "Idle".into(),
                clip: EditorAssetRef::new(
                    "model-id",
                    animation_clip_sub_asset_id(0, "Idle"),
                    "Idle",
                )
                .with_source_path("assets/models/animated.glb"),
                looping: true,
                speed: 1.0,
                range: AnimationPlaybackRange::default(),
            }],
            default_state: "idle".into(),
            default_crossfade_seconds: 0.2,
        }
    }

    #[test]
    fn validate_static_mesh_ok() {
        let mut world = World::new();
        let e = level_entity(&mut world, (ActorKind::StaticMesh, Primitive::default()));
        assert!(validate_actor(world.entity(e)).is_ok());
    }

    #[test]
    fn validate_static_mesh_renderer_ok() {
        let mut world = World::new();
        let mut renderer = StaticMeshRenderer::default();
        renderer.slots.push(crate::StaticMeshRendererEntry {
            mesh: crate::StaticMeshAssetRef::new("asset-id", "Mesh0/Primitive0", "Crate"),
            ..Default::default()
        });
        let e = level_entity(&mut world, (ActorKind::StaticMesh, renderer));
        assert!(validate_actor(world.entity(e)).is_ok());
    }

    #[test]
    fn validate_static_mesh_composes_with_light_and_uses_derived_hint() {
        let mut world = World::new();
        let e = level_entity(
            &mut world,
            (
                ActorKind::StaticMesh,
                Primitive::default(),
                LightDesc::default(),
            ),
        );
        assert!(validate_actor(world.entity(e)).is_ok());
        assert_eq!(
            infer_actor_kind(world.entity(e)),
            Some(ActorKind::StaticMesh)
        );
    }

    #[test]
    fn validate_brush_ok() {
        let mut world = World::new();
        let e = level_entity(&mut world, (ActorKind::Brush, BrushDesc::default()));
        assert!(validate_actor(world.entity(e)).is_ok());
    }

    #[test]
    fn validate_brush_rejects_static_mesh_renderer() {
        let mut world = World::new();
        let e = level_entity(
            &mut world,
            (
                ActorKind::Brush,
                BrushDesc::default(),
                StaticMeshRenderer::default(),
            ),
        );
        assert_eq!(
            validate_actor(world.entity(e)),
            Err(ActorValidationError::ConflictingGeometrySources)
        );
    }

    #[test]
    fn validate_brush_rejects_invalid_geometry() {
        let mut world = World::new();
        let mut brush = BrushDesc::default();
        brush.faces[0].vertices.clear();
        let e = level_entity(&mut world, (ActorKind::Brush, brush));
        assert_eq!(
            validate_actor(world.entity(e)),
            Err(ActorValidationError::InvalidBrushGeometry(
                "Face has fewer than three vertices.".into()
            ))
        );
    }

    #[test]
    fn validate_post_process_volume_ok() {
        let mut world = World::new();
        let e = level_entity(
            &mut world,
            (
                ActorKind::PostProcessVolume,
                PostProcessVolumeDesc::default(),
            ),
        );
        assert!(validate_actor(world.entity(e)).is_ok());
    }

    #[test]
    fn validate_post_process_volume_rejects_bad_extents() {
        let mut world = World::new();
        let e = level_entity(
            &mut world,
            (
                ActorKind::PostProcessVolume,
                PostProcessVolumeDesc {
                    half_extents: Vec3::ZERO,
                    ..Default::default()
                },
            ),
        );
        assert_eq!(
            validate_actor(world.entity(e)),
            Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents)
        );
    }

    #[test]
    fn infers_and_validates_audio_source_and_listener_actors() {
        let mut world = World::new();
        let source = level_entity(&mut world, audio_source());
        let listener = level_entity(&mut world, AudioListenerDesc::default());

        assert_eq!(
            infer_actor_kind(world.entity(source)),
            Some(ActorKind::AudioSource)
        );
        assert_eq!(
            infer_actor_kind(world.entity(listener)),
            Some(ActorKind::AudioListener)
        );
        world.entity_mut(source).insert(ActorKind::AudioSource);
        world.entity_mut(listener).insert(ActorKind::AudioListener);
        assert!(validate_actor(world.entity(source)).is_ok());
        assert!(validate_actor(world.entity(listener)).is_ok());
    }

    #[test]
    fn audio_source_requires_a_stable_clip_reference() {
        let mut world = World::new();
        let entity = level_entity(
            &mut world,
            (ActorKind::AudioSource, AudioSourceDesc::default()),
        );

        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AudioSourceMissingClip)
        );

        world.entity_mut(entity).insert(AudioSourceDesc {
            clip: Some(crate::EditorAssetRef::new("audio-id", "mesh:0", "Wrong")),
            ..Default::default()
        });
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AudioSourceInvalidClipReference)
        );
    }

    #[test]
    fn audio_scalar_and_attenuation_ranges_are_validated() {
        let mut world = World::new();
        let entity = level_entity(
            &mut world,
            (
                ActorKind::AudioSource,
                AudioSourceDesc {
                    spatial_blend: 1.5,
                    ..audio_source()
                },
            ),
        );
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AudioSourceInvalidSpatialBlend)
        );

        let listener = level_entity(
            &mut world,
            (
                ActorKind::AudioListener,
                AudioListenerDesc {
                    ear_gap: 0.0,
                    ..Default::default()
                },
            ),
        );
        assert_eq!(
            validate_actor(world.entity(listener)),
            Err(ActorValidationError::AudioListenerInvalidEarGap)
        );
    }

    #[test]
    fn animation_controller_requires_skinned_renderer_and_resolved_skeleton() {
        let mut world = World::new();
        let entity = level_entity(&mut world, (ActorKind::SkinnedMesh, animation_controller()));
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer)
        );

        world
            .entity_mut(entity)
            .insert(SkinnedMeshRenderer::new("assets/models/animated.glb"));
        let mut controller = animation_controller();
        controller.skeleton = None;
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerMissingSkeleton)
        );

        let mut controller = animation_controller();
        controller.skeleton = Some(EditorAssetRef::new("model-id", "mesh:0", "Wrong"));
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerInvalidSkeletonReference)
        );

        world.entity_mut(entity).insert(animation_controller());
        assert!(validate_actor(world.entity(entity)).is_ok());
    }

    #[test]
    fn animation_controller_validates_state_identity_and_clip_reference() {
        let mut world = World::new();
        let entity = level_entity(
            &mut world,
            (
                ActorKind::SkinnedMesh,
                SkinnedMeshRenderer::new("assets/models/animated.glb"),
                animation_controller(),
            ),
        );

        let mut controller = animation_controller();
        controller.states[0].id = " ".into();
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerEmptyStateId)
        );

        let mut controller = animation_controller();
        controller.states.push(controller.states[0].clone());
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerDuplicateStateId)
        );

        let mut controller = animation_controller();
        controller.states[0].clip =
            EditorAssetRef::new("model-id", animation_clip_sub_asset_id(0, "Idle"), "Idle")
                .with_source_path("assets/models/animated.fbx");
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerInvalidClipReference)
        );
    }

    #[test]
    fn animation_controller_validates_playback_ranges_and_default_state() {
        let mut world = World::new();
        let entity = level_entity(
            &mut world,
            (
                ActorKind::SkinnedMesh,
                SkinnedMeshRenderer::new("assets/models/animated.glb"),
                animation_controller(),
            ),
        );

        let mut controller = animation_controller();
        controller.states[0].speed = 0.0;
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerInvalidStateSpeed)
        );

        let mut controller = animation_controller();
        controller.states[0].range = AnimationPlaybackRange {
            start_seconds: 1.0,
            end_seconds: Some(0.5),
        };
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerInvalidStateRange)
        );

        let mut controller = animation_controller();
        controller.default_crossfade_seconds = f32::NAN;
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerInvalidCrossfade)
        );

        let mut controller = animation_controller();
        controller.default_state.clear();
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerMissingDefaultState)
        );

        let mut controller = animation_controller();
        controller.default_state = "missing".into();
        world.entity_mut(entity).insert(controller);
        assert_eq!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::AnimationControllerUnknownDefaultState)
        );
    }

    #[test]
    fn navigation_bounds_validate_generated_path_and_unique_samples() {
        let mut world = World::new();
        let mut bounds = NavigationBounds::for_actor("bounds-main");
        bounds.validation_samples.push(crate::NavigationPathSample {
            id: "entry-to-exit".into(),
            start: Vec3::new(-2.0, 0.0, 0.0),
            end: Vec3::new(2.0, 0.0, 0.0),
            enabled: true,
        });
        let entity = level_entity(&mut world, (ActorKind::Navigation, bounds.clone()));
        assert!(validate_actor(world.entity(entity)).is_ok());

        bounds
            .validation_samples
            .push(bounds.validation_samples[0].clone());
        world.entity_mut(entity).insert(bounds.clone());
        assert!(matches!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::InvalidNavigation(message))
                if message.contains("must be unique")
        ));

        bounds.validation_samples.pop();
        bounds.artifact_path = "assets/navigation/manual.nav.ron".into();
        world.entity_mut(entity).insert(bounds);
        assert!(matches!(
            validate_actor(world.entity(entity)),
            Err(ActorValidationError::InvalidNavigation(message))
                if message.contains("assets/navigation/generated/")
        ));
    }
}
