//! The module registry: capability as add-ons (learning path step 9a/9b).
//!
//! A capability (audio today; scripting, 3D, VR later) is a type that
//! implements the two-method [`Module`] trait and registers with
//! `App::add_module`. The app gives every module two hooks, one at startup
//! and one per frame, and otherwise ignores it. The renderer, the World,
//! and the scheduler never learn the module's name: new capability is a new
//! module, not a cross-cutting edit.

use crate::app::App;
use crate::world::World;

/// The entire plugin interface of the engine: two hooks, both with default
/// empty bodies. They are not stubs, they are default implementations: a
/// module that only needs startup work implements `init` and inherits the
/// do-nothing `update` for free, and vice versa.
///
/// `init` gets the whole [`App`] because startup is when a module claims
/// services and resources; `update` gets the [`World`] and the delta time
/// because per-frame work is world work, the same substrate systems run on.
#[allow(unused_variables)]
pub trait Module {
    /// Once, after the window and GPU are up, in registration order.
    fn init(&mut self, app: &mut App) {}

    /// Every frame, before the game systems run.
    fn update(&mut self, world: &mut World, dt: f32) {}
}

// ---------------------------------------------------------------------------
// AudioModule: the first real module (step 9b). audio.rs is untouched.
// ---------------------------------------------------------------------------

/// A play request as plain data. Anything that can write a component
/// (a system today, a script later) can make noise; game code never
/// touches rodio.
#[cfg(feature = "audio")]
pub struct PlaySound {
    pub bytes: Vec<u8>,
}

/// A play request BY FILE (item 7 of the v0.4 sweep): the module reads and
/// caches the bytes on first play (per path), so repeated SFX cost one
/// decode setup and zero disk reads after the first. Path is resolved
/// against the module's content root, like any asset.
#[cfg(feature = "audio")]
pub struct PlaySoundFile(pub String);

/// Wraps step 6's [`Audio`](crate::audio::Audio) as a module: `init` claims
/// the output device once (and must keep the handle alive; dropping it
/// silences everything). `update` drains this frame's [`PlaySound`]
/// requests out of the World and plays them.
#[cfg(feature = "audio")]
#[derive(Default)]
pub struct AudioModule {
    audio: Option<crate::audio::Audio>, // None until init; stays None if no device
    root: Option<std::path::PathBuf>,   // content root for PlaySoundFile paths
    cache: std::collections::HashMap<String, Vec<u8>>, // path -> file bytes
}

#[cfg(feature = "audio")]
impl AudioModule {
    /// XNA-style content root for [`PlaySoundFile`] paths.
    pub fn with_root(root: impl Into<std::path::PathBuf>) -> Self {
        Self {
            root: Some(root.into()),
            ..Default::default()
        }
    }
}

#[cfg(feature = "audio")]
impl Module for AudioModule {
    fn init(&mut self, _app: &mut App) {
        // Open the output once. Failing soft matters: a CI box or a lecture
        // podium with no audio device degrades to silence, not a crash.
        match crate::audio::Audio::new() {
            Ok(a) => self.audio = Some(a),
            Err(e) => log::warn!("audio unavailable: {e}"),
        }
    }

    fn update(&mut self, world: &mut World, _dt: f32) {
        let Some(audio) = &self.audio else {
            return;
        };
        // Take this frame's PlaySound components out of the world, play them.
        // detach(): fire-and-forget. Dropping the Sound handle would stop
        // playback (the RAII lesson from step 6), so we explicitly let go.
        for req in world.drain::<PlaySound>() {
            match audio.play_bytes(req.bytes) {
                Ok(sound) => sound.detach(),
                Err(e) => log::warn!("play failed: {e}"),
            }
        }

        // File requests: read + cache on first play, then it is the bytes
        // path. entry().or_insert_with is the find-or-populate idiom.
        for req in world.drain::<PlaySoundFile>() {
            let path = match &self.root {
                Some(root) => root.join(&req.0).to_string_lossy().into_owned(),
                None => req.0.clone(),
            };
            let bytes = self
                .cache
                .entry(req.0.clone())
                .or_insert_with(|| std::fs::read(&path).unwrap_or_default());
            if bytes.is_empty() {
                log::warn!("sound file missing or empty: {path}");
                continue;
            }
            match audio.play_bytes(bytes.clone()) {
                Ok(sound) => sound.detach(),
                Err(e) => log::warn!("play failed: {e}"),
            }
        }
    }
}
