//! Audio primitives. One parameter struct (`AudioSpec`) covers music,
//! ambience, and SFX — no enum of kinds. The only enum is `AudioChannel`,
//! and it's about *UI volume routing*, not playback behaviour.
//!
//! Playback is asset-sourced via `AssetTag` (storage); the audio layer
//! never carries bytes on the wire. See `docs/SPEC_AUDIO.md` for the full
//! design.

use crate::types::AssetTag;

/// Opaque server-allocated identifier for an active playback. Monotonic
/// per session; used by `StopAudio` / `AudioVolume` frames and survives
/// reconnects.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AudioId(pub u64);

/// UI volume routing category. Narrow by design — each variant maps to a
/// per-user volume slider in the client. Adding a new one means adding a
/// new slider, so `enum` is the right shape here (unlike the weather /
/// effect primitives where enum-of-uses was the anti-pattern).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum AudioChannel {
    /// Exclusive slot: starting a new Music track with a different asset
    /// crossfades from the current one.
    #[default]
    Music,
    /// Layered: multiple Ambience tracks can play at once (rain + tavern
    /// crowd + fire crackle).
    Ambience,
    /// Layered, fire-and-forget: one-shots for clicks, hits, rolls.
    Sfx,
}

impl AudioChannel {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Music    => "music",
            Self::Ambience => "ambience",
            Self::Sfx      => "sfx",
        }
    }
}

/// Wire-safe playback description. Music, ambience, SFX are all the same
/// struct with different parameters — features compose presets via the
/// helper constructors below rather than adding new types.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, PartialEq)]
pub struct AudioSpec {
    /// Catalog asset to play. Resolved at the client via the library.
    /// Missing asset → client logs a warning and plays silence.
    pub asset:        AssetTag,
    /// 0 = play once and stop. Positive = repeat that many times.
    /// `u32::MAX` = loop forever (typical for music / ambience).
    pub loop_count:   u32,
    /// Fade-in ramp in milliseconds. 0 = hard start.
    pub fade_in_ms:   u32,
    /// Fade-out ramp on natural end or `StopAudio`.
    pub fade_out_ms:  u32,
    /// 0.0..=1.0 — the server-side volume. Client-side per-channel
    /// sliders multiply in.
    pub volume:       f32,
    pub channel:      AudioChannel,
}

impl AudioSpec {
    /// One-shot sound effect — plays once, no fade, full volume.
    pub fn sfx(asset: AssetTag) -> Self {
        Self {
            asset, channel: AudioChannel::Sfx,
            loop_count: 0,
            fade_in_ms: 0, fade_out_ms: 0,
            volume: 1.0,
        }
    }

    /// Looping background music with a short crossfade.
    pub fn music(asset: AssetTag) -> Self {
        Self {
            asset, channel: AudioChannel::Music,
            loop_count: u32::MAX,
            fade_in_ms: 1500, fade_out_ms: 1500,
            volume: 0.7,
        }
    }

    /// Layered ambience (rain, tavern crowd). Longer fade.
    pub fn ambience(asset: AssetTag) -> Self {
        Self {
            asset, channel: AudioChannel::Ambience,
            loop_count: u32::MAX,
            fade_in_ms: 3000, fade_out_ms: 3000,
            volume: 0.5,
        }
    }

    pub fn with_volume(mut self, v: f32) -> Self { self.volume = v.clamp(0.0, 1.0); self }
    pub fn with_fades(mut self, fade_in_ms: u32, fade_out_ms: u32) -> Self {
        self.fade_in_ms = fade_in_ms; self.fade_out_ms = fade_out_ms; self
    }
}

/// Stable id for a playlist; used in URLs and on the wire.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct PlaylistId(pub String);

impl PlaylistId {
    pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
    pub fn as_str(&self) -> &str { &self.0 }
}

/// A named ordered queue of audio specs that a GameRunner can trigger.
/// Lives in the campaign's shared/playlists.json.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct AudioPlaylist {
    pub id:      PlaylistId,
    pub name:    String,
    pub tracks:  Vec<AudioSpec>,
    pub shuffle: bool,
}

impl AudioPlaylist {
    pub fn new(id: PlaylistId, name: impl Into<String>) -> Self {
        Self { id, name: name.into(), tracks: Vec::new(), shuffle: false }
    }
    pub fn with_track(mut self, track: AudioSpec) -> Self {
        self.tracks.push(track);
        self
    }
    pub fn with_shuffle(mut self, shuffle: bool) -> Self {
        self.shuffle = shuffle;
        self
    }
}

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

    #[test]
    fn sfx_preset_is_fire_and_forget() {
        let s = AudioSpec::sfx(AssetTag::new("click"));
        assert_eq!(s.loop_count, 0);
        assert_eq!(s.fade_in_ms, 0);
        assert_eq!(s.channel, AudioChannel::Sfx);
    }

    #[test]
    fn music_preset_loops_forever() {
        let s = AudioSpec::music(AssetTag::new("tavern-theme"));
        assert_eq!(s.loop_count, u32::MAX);
        assert_eq!(s.channel, AudioChannel::Music);
    }

    #[test]
    fn ambience_preset_is_on_ambience_channel() {
        let s = AudioSpec::ambience(AssetTag::new("rain"));
        assert_eq!(s.channel, AudioChannel::Ambience);
        assert_eq!(s.loop_count, u32::MAX);
    }

    #[test]
    fn with_volume_clamps() {
        let s = AudioSpec::sfx(AssetTag::new("x")).with_volume(1.5);
        assert_eq!(s.volume, 1.0);
        let s2 = AudioSpec::sfx(AssetTag::new("x")).with_volume(-0.2);
        assert_eq!(s2.volume, 0.0);
    }

    #[test]
    fn channel_default_is_music() {
        assert_eq!(AudioChannel::default(), AudioChannel::Music);
    }

    #[test]
    fn playlist_default_shuffle_is_false() {
        let p = AudioPlaylist::new(PlaylistId::new("tavern"), "Tavern Vibes");
        assert!(!p.shuffle);
        assert!(p.tracks.is_empty());
        assert_eq!(p.id.as_str(), "tavern");
    }

    #[test]
    fn playlist_with_track_preserves_order() {
        let p = AudioPlaylist::new(PlaylistId::new("p"), "P")
            .with_track(AudioSpec::music(AssetTag::new("a")))
            .with_track(AudioSpec::music(AssetTag::new("b")))
            .with_track(AudioSpec::music(AssetTag::new("c")));
        assert_eq!(p.tracks.len(), 3);
        assert_eq!(p.tracks[0].asset, AssetTag::new("a"));
        assert_eq!(p.tracks[1].asset, AssetTag::new("b"));
        assert_eq!(p.tracks[2].asset, AssetTag::new("c"));
    }

    #[test]
    fn playlist_with_shuffle_sets_flag() {
        let p = AudioPlaylist::new(PlaylistId::new("p"), "P").with_shuffle(true);
        assert!(p.shuffle);
    }
}

// ---------------------------------------------------------------------------
// Server-side fixture types
// ---------------------------------------------------------------------------
// Relocated from `maat-engine::fixtures::audio` so that the audio
// capability crate can depend on sdk-types instead of engine, satisfying
// the Layer 4 ↛ Layer 3 invariant. The engine re-exports these at their
// old path for backward compatibility.

use std::collections::BTreeMap;

/// State of one active playback track inside a session's `AudioFixture`.
#[derive(Debug, Clone)]
pub struct ActiveTrack {
    pub spec:          AudioSpec,
    pub started_at_ms: u64,
    /// Current server-side volume. Mutated by `AudioVolume` command.
    pub volume_now:    f32,
}

/// Session-scoped audio state: tracks currently-active playbacks.
///
/// IDs are monotonic from `next_id`. Music and Ambience channels are
/// exclusive — `play()` on either with a different asset fades out the
/// existing entry in that channel and starts the new one. Only SFX layers
/// freely.
///
/// **Server-authoritative clock**: `session_epoch_ms` is the tick at which
/// the session started. All `at_ms` / `started_at_ms` values are relative
/// to that, so reconnecting clients can schedule playback deterministically
/// regardless of wall-clock skew.
#[derive(Debug, Default)]
pub struct AudioFixture {
    next_id: u64,
    active:  BTreeMap<AudioId, ActiveTrack>,
    /// Monotonic ms since session start. The session actor advances this
    /// from its own clock; tests inject values directly.
    pub session_epoch_ms: u64,
}

/// What the session actor should do after a `play()` call. Keeps
/// side-effect decisions (broadcast Stop before Start, auto-prune, …)
/// explicit rather than hiding them behind fixture state changes.
#[derive(Debug, Clone)]
pub struct PlayOutcome {
    pub id:         AudioId,
    pub started_at: u64,
    /// If replacing a Music track, the id that should be stopped first.
    /// `fade_out_ms` comes from the replaced track's original spec.
    pub replaces:   Option<(AudioId, u32)>,
}

impl AudioFixture {
    pub fn new() -> Self { Self::default() }

    /// Start a playback. Honours the exclusive-slot semantics of
    /// `AudioChannel::Music` and `AudioChannel::Ambience`: if a track is
    /// already playing in that channel, return the existing id in
    /// `replaces` so the caller crossfades. `replace_id` (caller-specified)
    /// overrides this and always replaces.
    pub fn play(&mut self, spec: AudioSpec, replace_id: Option<AudioId>) -> PlayOutcome {
        self.next_id = self.next_id.wrapping_add(1);
        let id = AudioId(self.next_id);

        let replaces: Option<(AudioId, u32)> = if let Some(rid) = replace_id {
            self.active.get(&rid).map(|t| (rid, t.spec.fade_out_ms))
        } else if matches!(spec.channel, AudioChannel::Music | AudioChannel::Ambience) {
            let ch = spec.channel;
            self.active.iter()
                .find(|(_, t)| t.spec.channel == ch)
                .map(|(id, t)| (*id, t.spec.fade_out_ms))
        } else {
            None
        };

        if let Some((rid, _)) = replaces {
            self.active.remove(&rid);
        }

        let started_at = self.session_epoch_ms;
        let volume_now = spec.volume;
        self.active.insert(id, ActiveTrack { spec, started_at_ms: started_at, volume_now });
        PlayOutcome { id, started_at, replaces }
    }

    /// Stop a track. Returns the fade duration so the caller can tell
    /// clients how long to ramp.
    pub fn stop(&mut self, id: AudioId) -> Option<u32> {
        let fade = self.active.get(&id).map(|t| t.spec.fade_out_ms);
        self.active.remove(&id);
        fade
    }

    /// Adjust server-side volume. Returns `false` if the id isn't active.
    pub fn set_volume(&mut self, id: AudioId, volume: f32) -> bool {
        if let Some(t) = self.active.get_mut(&id) {
            t.volume_now = volume.clamp(0.0, 1.0);
            true
        } else {
            false
        }
    }

    /// Clean up one-shot SFX whose natural duration has elapsed.
    /// Looping tracks (`loop_count == u32::MAX`) are never auto-pruned.
    pub fn prune_expired(&mut self, now_ms: u64, sfx_max_ms: u64) -> Vec<AudioId> {
        let mut removed = Vec::new();
        self.active.retain(|id, t| {
            if t.spec.loop_count != 0 { return true; }
            let elapsed = now_ms.saturating_sub(t.started_at_ms);
            if elapsed > sfx_max_ms { removed.push(*id); false } else { true }
        });
        removed
    }

    pub fn active(&self) -> impl Iterator<Item = (&AudioId, &ActiveTrack)> {
        self.active.iter()
    }

    pub fn len(&self) -> usize { self.active.len() }
    pub fn is_empty(&self) -> bool { self.active.is_empty() }

    /// Snapshot for reconnecting clients: `(id, spec, started_at_ms)` triples.
    pub fn snapshot_tracks(&self) -> Vec<(AudioId, AudioSpec, u64)> {
        self.active.iter()
            .map(|(id, t)| (*id, t.spec.clone(), t.started_at_ms))
            .collect()
    }
}

#[cfg(test)]
mod fixture_tests {
    use super::*;
    use crate::types::AssetTag;

    fn music(name: &str) -> AudioSpec { AudioSpec::music(AssetTag::new(name)) }
    fn sfx(name: &str)   -> AudioSpec { AudioSpec::sfx(AssetTag::new(name)) }
    fn amb(name: &str)   -> AudioSpec { AudioSpec::ambience(AssetTag::new(name)) }

    #[test]
    fn play_allocates_monotonic_ids() {
        let mut f = AudioFixture::new();
        let a = f.play(sfx("a"), None).id;
        let b = f.play(sfx("b"), None).id;
        assert!(b.0 > a.0);
    }

    #[test]
    fn music_channel_is_exclusive() {
        let mut f = AudioFixture::new();
        let first = f.play(music("theme-a"), None);
        assert!(first.replaces.is_none());
        let second = f.play(music("theme-b"), None);
        assert_eq!(second.replaces.map(|(id, _)| id), Some(first.id));
        assert_eq!(f.len(), 1);
    }

    #[test]
    fn ambience_replaces() {
        let mut f = AudioFixture::new();
        let first = f.play(amb("rain"), None);
        let second = f.play(amb("thunder"), None);
        assert_eq!(second.replaces.map(|(id, _)| id), Some(first.id));
        assert_eq!(f.len(), 1);
    }

    #[test]
    fn sfx_layers() {
        let mut f = AudioFixture::new();
        let a = f.play(sfx("click"), None);
        let b = f.play(sfx("hit"), None);
        assert!(a.replaces.is_none());
        assert!(b.replaces.is_none());
        assert_eq!(f.len(), 2);
    }

    #[test]
    fn sfx_layer_and_autoprune() {
        let mut f = AudioFixture::new();
        f.play(sfx("click"), None);
        f.play(sfx("hit"), None);
        let removed = f.prune_expired(10_000, 5_000);
        assert_eq!(removed.len(), 2);
        assert_eq!(f.len(), 0);
    }

    #[test]
    fn loops_are_never_autopruned() {
        let mut f = AudioFixture::new();
        f.play(music("theme"), None);
        f.play(amb("rain"), None);
        let removed = f.prune_expired(1_000_000_000, 1_000);
        assert!(removed.is_empty());
        assert_eq!(f.len(), 2);
    }

    #[test]
    fn set_volume_bounds_and_reports_presence() {
        let mut f = AudioFixture::new();
        let id = f.play(music("t"), None).id;
        assert!(f.set_volume(id, 1.5));
        assert_eq!(f.active().next().unwrap().1.volume_now, 1.0);
        assert!(!f.set_volume(AudioId(9999), 0.5));
    }

    #[test]
    fn explicit_replace_id_wins_over_channel_rule() {
        let mut f = AudioFixture::new();
        let music_id = f.play(music("theme"), None).id;
        let amb_id   = f.play(amb("rain"),   None).id;
        let outcome  = f.play(sfx("finale"), Some(amb_id));
        assert_eq!(outcome.replaces.map(|(i, _)| i), Some(amb_id));
        assert!(f.active().any(|(id, _)| *id == music_id));
    }

    #[test]
    fn snapshot_returns_all_active() {
        let mut f = AudioFixture::new();
        f.session_epoch_ms = 200;
        f.play(music("a"), None);
        f.play(amb("b"), None);
        f.session_epoch_ms = 500;
        f.play(sfx("c"), None);
        let snap = f.snapshot_tracks();
        assert_eq!(snap.len(), 3);
        assert!(snap.iter().any(|(_, spec, started)| spec.asset.name == "a" && *started == 200));
        assert!(snap.iter().any(|(_, spec, started)| spec.asset.name == "c" && *started == 500));
    }
}
