//! Transient, non-dirty audio audition owned by the editor.

use bevy::asset::LoadState;
use bevy::audio::{
    AudioPlayer, AudioSink, AudioSinkPlayback, AudioSource, PlaybackMode, PlaybackSettings,
    SpatialAudioSink, SpatialListener, SpatialScale, Volume,
};
use bevy::prelude::*;
use settings::AUDIO_BUS_SFX_ID;
use shared::{asset_server_path, AudioAttenuationDesc, AudioSourceDesc};

use crate::assets::{EditorAsset, EditorAssetKind};
use crate::camera::EditorCamera;
use crate::infra::EditorOnly;
use crate::state::EditorMode;

#[derive(Resource, Debug, Clone, Default)]
pub struct AudioPreviewState {
    pub voices: Vec<Entity>,
    pub source: Option<Entity>,
    pub clip_path: Option<String>,
    pub status: String,
    clip_handle: Option<Handle<AudioSource>>,
    listener: Option<Entity>,
}

#[derive(Component, Debug, Clone)]
struct AudioPreviewVoice {
    source: Option<Entity>,
    bus: String,
    source_gain_db: f32,
    mix_weight: f32,
    spatial: bool,
    attenuation: AudioAttenuationDesc,
}

#[derive(Component, Debug, Clone, Copy)]
struct EditorAudioPreviewListener;

pub struct AudioPreviewPlugin;

impl Plugin for AudioPreviewPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<AudioPreviewState>()
            .add_systems(Update, update_audio_preview)
            .add_systems(OnEnter(EditorMode::Playing), stop_audio_preview);
    }
}

pub fn audition_audio_source(world: &mut World, source: Entity) -> Result<(), String> {
    ensure_edit_mode(world)?;
    let descriptor = world
        .get::<AudioSourceDesc>(source)
        .cloned()
        .ok_or_else(|| "Selected actor has no AudioSourceDesc".to_string())?;
    let path = descriptor
        .clip
        .as_ref()
        .and_then(|clip| clip.source_path.as_deref())
        .filter(|path| !path.trim().is_empty())
        .ok_or_else(|| "Assign an audio clip before auditioning this source".to_string())?
        .to_string();
    spawn_preview(world, path, Some((source, descriptor)))
}

pub fn audition_audio_asset(world: &mut World, asset: &EditorAsset) -> Result<(), String> {
    ensure_edit_mode(world)?;
    if !matches!(asset.kind, EditorAssetKind::AudioClip) {
        return Err("Selected asset is not an audio clip".into());
    }
    let path = asset
        .path
        .as_deref()
        .filter(|path| !path.trim().is_empty())
        .ok_or_else(|| "Audio clip has no project path".to_string())?
        .to_string();
    spawn_preview(world, path, None)
}

fn ensure_edit_mode(world: &World) -> Result<(), String> {
    if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
        return Err("Audio audition is available only in Edit mode".into());
    }
    Ok(())
}

fn spawn_preview(
    world: &mut World,
    path: String,
    source: Option<(Entity, AudioSourceDesc)>,
) -> Result<(), String> {
    stop_audio_preview(world);
    let descriptor = source
        .as_ref()
        .map(|(_, descriptor)| descriptor.clone())
        .unwrap_or_else(|| AudioSourceDesc {
            autoplay: false,
            spatial_blend: 0.0,
            bus: AUDIO_BUS_SFX_ID.into(),
            ..Default::default()
        });
    let source_entity = source.as_ref().map(|(entity, _)| *entity);
    let mixes = game::audio::audio_voice_mix(descriptor.spatial_blend);
    let panning_scale = game::audio::spatial_panning_scale(&descriptor.attenuation, 0.2);
    let needs_listener = mixes.iter().any(|(spatial, _)| *spatial);
    let listener = if needs_listener {
        let mut query = world
            .query_filtered::<(Entity, &GlobalTransform, Has<SpatialListener>), With<EditorCamera>>(
            );
        Some(
            query
                .iter(world)
                .next()
                .map(|(entity, transform, has_listener)| (entity, *transform, has_listener))
                .ok_or_else(|| "Editor camera is unavailable for spatial audition".to_string())?,
        )
    } else {
        None
    };
    let listener = listener.map(|(camera, camera_transform, has_listener)| {
        if has_listener {
            camera
        } else {
            world
                .spawn((
                    Name::new("Editor Audio Preview Listener"),
                    EditorOnly,
                    Transform::IDENTITY,
                    camera_transform,
                    SpatialListener::new(0.2),
                    EditorAudioPreviewListener,
                    ChildOf(camera),
                ))
                .id()
        }
    });

    let asset_server = world.resource::<AssetServer>().clone();
    let handle: Handle<AudioSource> = asset_server.load(asset_server_path(&path));
    let mut voices = Vec::with_capacity(mixes.len());
    for (spatial, mix_weight) in mixes {
        let mut voice = world.spawn((
            Name::new(if spatial {
                "Editor Spatial Audio Preview"
            } else {
                "Editor 2D Audio Preview"
            }),
            EditorOnly,
            Transform::IDENTITY,
            AudioPlayer::new(handle.clone()),
            PlaybackSettings {
                mode: PlaybackMode::Once,
                volume: Volume::Linear(0.0),
                speed: descriptor.pitch,
                paused: false,
                muted: false,
                spatial,
                spatial_scale: spatial.then(|| SpatialScale::new(panning_scale)),
                start_position: None,
                duration: None,
            },
            AudioPreviewVoice {
                source: source_entity,
                bus: descriptor.bus.clone(),
                source_gain_db: descriptor.gain_db,
                mix_weight,
                spatial,
                attenuation: descriptor.attenuation,
            },
        ));
        if let Some(source) = source_entity {
            voice.insert(ChildOf(source));
        }
        voices.push(voice.id());
    }
    *world.resource_mut::<AudioPreviewState>() = AudioPreviewState {
        voices,
        source: source_entity,
        clip_path: Some(path.clone()),
        status: format!("Auditioning {}", file_label(&path)),
        clip_handle: Some(handle),
        listener,
    };
    Ok(())
}

fn file_label(path: &str) -> &str {
    std::path::Path::new(path)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(path)
}

pub fn stop_audio_preview(world: &mut World) {
    stop_audio_preview_with_status(world, "Audio preview stopped");
}

fn stop_audio_preview_with_status(world: &mut World, status: impl Into<String>) {
    let voices = std::mem::take(&mut world.resource_mut::<AudioPreviewState>().voices);
    for voice in voices {
        if let Some(sink) = world.get::<AudioSink>(voice) {
            sink.stop();
        }
        if let Some(sink) = world.get::<SpatialAudioSink>(voice) {
            sink.stop();
        }
        if let Ok(entity_mut) = world.get_entity_mut(voice) {
            entity_mut.despawn();
        }
    }
    let mut query = world.query_filtered::<Entity, With<EditorAudioPreviewListener>>();
    let listeners: Vec<Entity> = query.iter(world).collect();
    for listener in listeners {
        if let Ok(entity_mut) = world.get_entity_mut(listener) {
            entity_mut.despawn();
        }
    }
    let state = world.resource_mut::<AudioPreviewState>().into_inner();
    state.source = None;
    state.clip_path = None;
    state.clip_handle = None;
    state.listener = None;
    state.status = status.into();
}

fn update_audio_preview(world: &mut World) {
    let state = world.resource::<AudioPreviewState>().clone();
    if state.voices.is_empty() {
        return;
    }
    if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
        stop_audio_preview_with_status(world, "Audio preview stopped on entering Play mode");
        return;
    }
    if state.source.is_some_and(|source| {
        world.get_entity(source).is_err() || world.get::<AudioSourceDesc>(source).is_none()
    }) {
        stop_audio_preview_with_status(world, "Audio preview stopped because its source changed");
        return;
    }
    if state
        .listener
        .is_some_and(|listener| world.get::<GlobalTransform>(listener).is_none())
    {
        stop_audio_preview_with_status(
            world,
            "Audio preview stopped because the editor camera changed",
        );
        return;
    }
    if let Some(LoadState::Failed(error)) = state
        .clip_handle
        .as_ref()
        .map(|handle| world.resource::<AssetServer>().load_state(handle.id()))
    {
        let status = format!("Audio preview failed: {error}");
        stop_audio_preview_with_status(world, status.clone());
        if let Some(mut scene_io) = world.get_resource_mut::<crate::scene_io::SceneIo>() {
            scene_io.set_status(status);
        }
        return;
    }

    let listener_position = state
        .listener
        .and_then(|listener| world.get::<GlobalTransform>(listener))
        .map(GlobalTransform::translation);
    let project_audio = world.resource::<settings::ProjectSettings>().audio.clone();
    let bus_runtime = world.resource::<game::audio::AudioBusRuntime>().clone();
    let mut finished = 0usize;
    for voice_entity in &state.voices {
        let Some(voice) = world.get::<AudioPreviewVoice>(*voice_entity).cloned() else {
            finished += 1;
            continue;
        };
        let source_position = voice
            .source
            .and_then(|source| world.get::<GlobalTransform>(source))
            .map(GlobalTransform::translation);
        let attenuation = if voice.spatial {
            match (listener_position, source_position) {
                (Some(listener), Some(source)) => {
                    game::audio::attenuation_gain(&voice.attenuation, listener.distance(source))
                }
                _ => 0.0,
            }
        } else {
            1.0
        };
        let gain = game::audio::db_to_linear(voice.source_gain_db)
            * game::audio::effective_bus_gain_linear(&project_audio, &bus_runtime, &voice.bus)
            * voice.mix_weight
            * attenuation;
        if let Some(mut sink) = world.get_mut::<AudioSink>(*voice_entity) {
            sink.set_volume(Volume::Linear(gain));
            if sink.empty() {
                finished += 1;
            }
        } else if let Some(mut sink) = world.get_mut::<SpatialAudioSink>(*voice_entity) {
            sink.set_volume(Volume::Linear(gain));
            if sink.empty() {
                finished += 1;
            }
        }
    }
    if finished == state.voices.len() {
        stop_audio_preview_with_status(world, "Audio preview finished");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::asset::{AssetApp, AssetPlugin};
    use bevy::state::app::StatesPlugin;
    use shared::{EditorAssetRef, AUDIO_CLIP_SUB_ASSET_ID};

    #[test]
    fn file_label_is_stable_for_project_paths() {
        assert_eq!(file_label("assets/audio/impact.ogg"), "impact.ogg");
        assert_eq!(file_label("clip.wav"), "clip.wav");
    }

    fn preview_app() -> App {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, AssetPlugin::default(), StatesPlugin))
            .init_asset::<AudioSource>()
            .init_state::<EditorMode>()
            .add_plugins(AudioPreviewPlugin)
            .init_resource::<crate::scene_io::SceneIo>()
            .init_resource::<crate::history::EditorHistory>()
            .init_resource::<game::audio::AudioBusRuntime>()
            .insert_resource(settings::ProjectSettings::default());
        app
    }

    fn spawn_camera(app: &mut App, listener: Option<SpatialListener>) -> Entity {
        let mut camera = app.world_mut().spawn((
            EditorCamera::default(),
            Transform::IDENTITY,
            GlobalTransform::IDENTITY,
        ));
        if let Some(listener) = listener {
            camera.insert(listener);
        }
        camera.id()
    }

    fn spawn_source(app: &mut App) -> Entity {
        app.world_mut()
            .spawn((
                Transform::from_xyz(0.0, 0.0, -2.0),
                GlobalTransform::IDENTITY,
                AudioSourceDesc {
                    clip: Some(
                        EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Impact")
                            .with_source_path("assets/audio/impact.ogg"),
                    ),
                    ..Default::default()
                },
            ))
            .id()
    }

    #[test]
    fn audition_and_stop_do_not_dirty_scene_or_push_history() {
        let mut app = preview_app();
        spawn_camera(&mut app, None);
        let source = spawn_source(&mut app);

        audition_audio_source(app.world_mut(), source).unwrap();

        assert!(!app
            .world()
            .resource::<AudioPreviewState>()
            .voices
            .is_empty());
        assert!(!app.world().resource::<crate::scene_io::SceneIo>().dirty);
        assert_eq!(
            app.world()
                .resource::<crate::history::EditorHistory>()
                .undo_depth(),
            0
        );

        stop_audio_preview(app.world_mut());

        assert!(app
            .world()
            .resource::<AudioPreviewState>()
            .voices
            .is_empty());
        assert!(!app.world().resource::<crate::scene_io::SceneIo>().dirty);
        assert_eq!(
            app.world()
                .resource::<crate::history::EditorHistory>()
                .undo_depth(),
            0
        );
    }

    #[test]
    fn spatial_preview_preserves_an_existing_editor_camera_listener() {
        let mut app = preview_app();
        let camera = spawn_camera(&mut app, Some(SpatialListener::new(0.5)));
        let source = spawn_source(&mut app);
        let expected = app.world().get::<SpatialListener>(camera).unwrap().clone();

        audition_audio_source(app.world_mut(), source).unwrap();

        let actual = app.world().get::<SpatialListener>(camera).unwrap();
        assert_eq!(actual.left_ear_offset, expected.left_ear_offset);
        assert_eq!(actual.right_ear_offset, expected.right_ear_offset);
        assert_eq!(
            app.world().resource::<AudioPreviewState>().listener,
            Some(camera)
        );
        let mut preview_listeners = app
            .world_mut()
            .query_filtered::<Entity, With<EditorAudioPreviewListener>>();
        assert_eq!(preview_listeners.iter(app.world()).count(), 0);

        stop_audio_preview(app.world_mut());

        let actual = app.world().get::<SpatialListener>(camera).unwrap();
        assert_eq!(actual.left_ear_offset, expected.left_ear_offset);
        assert_eq!(actual.right_ear_offset, expected.right_ear_offset);
    }

    #[test]
    fn source_component_removal_cleans_up_preview() {
        let mut app = preview_app();
        spawn_camera(&mut app, None);
        let source = spawn_source(&mut app);
        audition_audio_source(app.world_mut(), source).unwrap();

        app.world_mut()
            .entity_mut(source)
            .remove::<AudioSourceDesc>();
        update_audio_preview(app.world_mut());

        let state = app.world().resource::<AudioPreviewState>();
        assert!(state.voices.is_empty());
        assert!(state.source.is_none());
        assert!(state.status.contains("source changed"));
    }

    #[test]
    fn entering_play_mode_cleans_up_preview() {
        let mut app = preview_app();
        spawn_camera(&mut app, None);
        let source = spawn_source(&mut app);
        audition_audio_source(app.world_mut(), source).unwrap();

        app.world_mut()
            .resource_mut::<NextState<EditorMode>>()
            .set(EditorMode::Playing);
        app.update();

        let state = app.world().resource::<AudioPreviewState>();
        assert!(state.voices.is_empty());
        assert!(state.source.is_none());
        let mut preview_listeners = app
            .world_mut()
            .query_filtered::<Entity, With<EditorAudioPreviewListener>>();
        assert_eq!(preview_listeners.iter(app.world()).count(), 0);
    }
}
